From afefba31847ce86f02147c41c9e002d0a732e722 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Fri, 19 Jun 2026 07:27:35 +0200 Subject: [PATCH] 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 --- README.md | 35 +- cmd/uopi/main.go | 16 +- docs/FUNCTIONAL_SPEC.md | 179 +++++-- docs/TECHNICAL_SPEC.md | 129 ++++- internal/access/access.go | 57 +- internal/access/access_test.go | 35 ++ internal/api/api.go | 194 ++++++- internal/api/api_test.go | 9 +- internal/config/config.go | 10 + internal/controllogic/cron.go | 161 ++++++ internal/controllogic/cron_test.go | 92 ++++ internal/controllogic/engine.go | 688 ++++++++++++++++++++++++ internal/controllogic/expr.go | 527 ++++++++++++++++++ internal/controllogic/expr_test.go | 86 +++ internal/controllogic/lua.go | 115 ++++ internal/controllogic/model.go | 65 +++ internal/controllogic/store.go | 111 ++++ internal/server/server.go | 5 +- uopi.example.toml | 11 +- web/src/Canvas.tsx | 5 + web/src/ContextualHelp.tsx | 4 + web/src/ControlLogicEditor.tsx | 826 +++++++++++++++++++++++++++++ web/src/EditMode.tsx | 15 +- web/src/HelpModal.tsx | 186 ++++++- web/src/InterfaceList.tsx | 82 ++- web/src/LogicDialogs.tsx | 45 ++ web/src/LogicEditor.tsx | 80 ++- web/src/SignalTree.tsx | 4 +- web/src/SyntheticEditor.tsx | 347 ------------ web/src/SyntheticGraphEditor.tsx | 582 ++++++++++++++++++++ web/src/ViewMode.tsx | 15 + web/src/lib/auth.ts | 3 +- web/src/lib/logic.ts | 105 ++++ web/src/lib/types.ts | 21 +- web/src/styles.css | 255 +++++++++ 35 files changed, 4633 insertions(+), 467 deletions(-) create mode 100644 internal/access/access_test.go create mode 100644 internal/controllogic/cron.go create mode 100644 internal/controllogic/cron_test.go create mode 100644 internal/controllogic/engine.go create mode 100644 internal/controllogic/expr.go create mode 100644 internal/controllogic/expr_test.go create mode 100644 internal/controllogic/lua.go create mode 100644 internal/controllogic/model.go create mode 100644 internal/controllogic/store.go create mode 100644 web/src/ControlLogicEditor.tsx create mode 100644 web/src/LogicDialogs.tsx delete mode 100644 web/src/SyntheticEditor.tsx create mode 100644 web/src/SyntheticGraphEditor.tsx diff --git a/README.md b/README.md index 68030f3..6b981a8 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,17 @@ uopi runs as a **single portable binary** with no runtime dependencies. Point an | Feature | Details | |---------|---------| -| **Live data** | EPICS Channel Access (CA) and user-defined synthetic signals | +| **Live data** | EPICS Channel Access (CA) / PVAccess and user-defined synthetic signals | | **HMI editor** | Drag-and-drop widgets, resize, align/distribute, undo/redo, saved as XML | -| **Widget library** | Text view, gauge, LED, bar, set-point control, button, plots | +| **Widget library** | Text view/label, gauge, LED, multi-LED, bar, set-point control, button, image, link, plots | | **Plot types** | Time-series (uPlot), FFT, waterfall, histogram, bar chart, logic analyser | +| **Plot panels** | Dedicated chart panels with a recursive split layout (tmux/IDE-style) where plots fill the viewport | +| **Panel logic** | In-editor node-graph flow editor (triggers → actions, dialogs) that runs client-side in view mode | +| **Control logic** | Server-side always-on flow graphs with cron/alarm triggers and Lua blocks | +| **Local variables** | Panel-scoped state variables for set-points, toggles and counters used by logic | | **Historical data** | EPICS Archive Appliance integration via REST; time range picker in UI | -| **Synthetic signals** | Compose, filter, and transform signals with a DSP pipeline (gain, offset, moving average, lowpass, highpass, derivative, integral, clamp, formula) | +| **Synthetic signals** | Compose, filter, and transform signals via a wizard or visual node-graph editor (gain, offset, moving average, lowpass, highpass, derivative, integral, clamp, formula); panel/user/global visibility scopes | +| **Access control** | Identity via trusted proxy header; per-user global level (write/read-only/no-access); per-panel ownership + sharing; nested folders; optional logic-editor allowlist | | **Multi-client** | Subscriptions are multiplexed — N clients share one upstream connection per signal | | **Signal discovery** | Filter/search, CSV import, manual add, Channel Finder proxy | | **Observability** | Prometheus-format metrics at `/metrics` | @@ -64,7 +69,20 @@ Create `uopi.toml` (all fields are optional — shown values are defaults): ```toml [server] listen = ":8080" -storage_dir = "./data" # where interface XML files and synthetic.json are stored +storage_dir = "./interfaces" # where interface XML, ACL and logic data are stored + +# Identity & access control (all optional) +trusted_user_header = "" # HTTP header carrying the proxy-authenticated user +default_user = "" # identity used when the header is absent (LAN/dev) +# logic_editors = [] # restrict who may edit panel/control logic (users or groups) + +# [[server.blacklist]] # downgrade a user: level = "readonly" or "noaccess" +# user = "guest" +# level = "readonly" + +# [[groups]] # named user sets, referenced by panel sharing +# name = "operators" +# members = ["alice", "bob"] [datasource.epics] enabled = false # requires build with -tags epics @@ -118,7 +136,16 @@ The REST API is available under `/api/v1`. Key endpoints: | `GET/POST` | `/api/v1/interfaces` | List or create HMI interfaces | | `GET/PUT/DELETE` | `/api/v1/interfaces/{id}` | Read, update, or delete an interface | | `POST` | `/api/v1/interfaces/{id}/clone` | Duplicate an interface | +| `POST` | `/api/v1/interfaces/reorder` | Reorder panels / move them between folders | +| `GET/PUT` | `/api/v1/interfaces/{id}/acl` | Read or set a panel's sharing rules | +| `GET` | `/api/v1/interfaces/{id}/versions` | List saved versions of a panel | +| `GET/POST` | `/api/v1/folders` | List or create panel folders | +| `PUT/DELETE` | `/api/v1/folders/{id}` | Rename/reparent or delete a folder | | `GET/POST/DELETE` | `/api/v1/synthetic` | Manage synthetic signal definitions | +| `GET/POST` | `/api/v1/controllogic` | List or create server-side control-logic graphs | +| `GET/PUT/DELETE` | `/api/v1/controllogic/{id}` | Read, update, or delete a control-logic graph | +| `GET` | `/api/v1/me` | Caller identity, access level, groups, logic-edit permission | +| `GET` | `/api/v1/usergroups` | List configured users and groups (for sharing) | | `GET` | `/metrics` | Prometheus-format server metrics | | `GET` | `/healthz` | Health check | diff --git a/cmd/uopi/main.go b/cmd/uopi/main.go index b498a93..6968dda 100644 --- a/cmd/uopi/main.go +++ b/cmd/uopi/main.go @@ -14,6 +14,7 @@ import ( "github.com/uopi/uopi/internal/access" "github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/config" + "github.com/uopi/uopi/internal/controllogic" "github.com/uopi/uopi/internal/datasource/epics" "github.com/uopi/uopi/internal/datasource/pva" "github.com/uopi/uopi/internal/datasource/stub" @@ -132,9 +133,20 @@ func main() { for _, g := range cfg.Groups { groups[g.Name] = g.Members } - policy := access.New(cfg.Server.DefaultUser, blacklist, groups) + policy := access.New(cfg.Server.DefaultUser, blacklist, groups, cfg.Server.LogicEditors) - srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, policy, aclStore, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, log) + // Server-side control logic: flow graphs that run continuously under the root + // context, independent of any panel. The store is loaded from disk and the + // engine starts all enabled graphs. + ctrlStore, err := controllogic.NewStore(cfg.Server.StorageDir) + if err != nil { + log.Error("failed to open control logic store", "err", err) + os.Exit(1) + } + ctrlEngine := controllogic.NewEngine(ctx, brk, ctrlStore, log) + ctrlEngine.Reload() + + srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, policy, aclStore, ctrlStore, ctrlEngine, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, log) if err := srv.Start(ctx); err != nil { fmt.Fprintf(os.Stderr, "server error: %v\n", err) diff --git a/docs/FUNCTIONAL_SPEC.md b/docs/FUNCTIONAL_SPEC.md index 97e69af..cb15118 100644 --- a/docs/FUNCTIONAL_SPEC.md +++ b/docs/FUNCTIONAL_SPEC.md @@ -8,13 +8,23 @@ uopi is a web-based HMI (Human-Machine Interface) for monitoring and controlling ## 2. Users and Roles -| Role | Description | -|------|-------------| -| Operator | Uses interfaces in View mode; can interact with controls but cannot edit layouts | -| Engineer | Creates and edits interfaces in Edit mode; manages signal lists | -| Administrator | Manages server configuration, data sources, and saved interfaces | +User identity is established from a header set by a trusted authenticating reverse +proxy (`server.trusted_user_header`), with a configurable `default_user` fallback for +unproxied/LAN deployments. There is no login page inside the application. -In the initial version all users share the same access level. Role-based access control is deferred to a future release. +**Global access levels.** Every user is trusted with full **write** access by default. A +configuration blacklist can downgrade specific users to **read-only** (view only, no +writes) or **no access** (denied). Named **groups** are defined in configuration and +referenced by per-panel sharing. + +**Per-panel access.** Panels are owned by their creator and private by default. Owners +share a panel with specific users/groups (read or write) or make it public; the owner's +global level always caps the per-panel permission. Panels are organised into nested +folders whose permissions inherit down the chain. See §9. + +**Logic-edit restriction.** Adding/editing panel logic and server-side control logic can +optionally be limited to an allowlist of users/groups (`server.logic_editors`); when +unset, any writer may edit logic. --- @@ -30,27 +40,27 @@ The default mode when opening the application. - "New interface" button opens Edit mode with a blank canvas. - The pane width can be adjusted by dragging the resize handle on its right edge. -**Tabs** -- **HMI tab**: the live widget canvas (described below). -- **Plot tab**: a dedicated live multi-signal plot panel (see §3.3). -- **Info tab**: signal info and metadata display for the last right-clicked signal. - **HMI canvas (center)** - Renders the selected interface as a live, interactive panel. - Widgets display real-time data; controls (set-value, buttons) are active. +- Panel logic (if any) runs while the panel is open (see §6). - No drag, resize, or layout operations are possible in this mode. - Right-clicking any widget opens a context menu: - - **Signal info** — switches to the Info tab showing DS name, type, unit, range, current value and timestamp. + - **Signal info** — shows DS name, type, unit, range, current value and timestamp. - **Copy signal name** — copies the signal identifier to the clipboard. - **Export data to CSV** — downloads buffered data for the signal(s) used by the widget. - - **Plot** — adds the signal(s) to the Plot tab. + +> Dedicated multi-signal plotting is provided by **plot panels** — a special interface +> kind whose plots fill the viewport (see §3.3) — rather than a separate live "Plot tab". **Top toolbar** - Show/hide interface list pane. - **⏱ History** button: toggle historical time navigation bar. +- **⚙ Control logic** button: open the server-side control-logic editor (see §7). + Shown only to users permitted to edit logic. - Zoom control (A− / % / A+): adjust the UI scale (see §3.4). - Edit button: switch to Edit mode for the current interface. -- Connection status indicator. +- Connection status indicator and signed-in user chip. **Historical time navigation bar** (shown when History is active) - Date/time range pickers (start and end). @@ -71,9 +81,17 @@ Activated via the "New interface" button or by clicking Edit in the toolbar. - Filter/search box to narrow the list. - Synthetic signals show an edit (✎) button to reopen the wizard. -**Widget canvas (center)** -- Free-form canvas where widgets can be placed at arbitrary pixel positions. -- Background grid with snap-to-grid. +**Center area — Layout / Logic tabs** +- **Layout**: free-form canvas where widgets can be placed at arbitrary pixel positions, + over a background grid with snap-to-grid. (For plot panels this is replaced by the + split-layout editor; see §3.3.) +- **Logic**: a node-graph flow editor for panel behaviour (see §6). Hidden when the user + is not permitted to edit logic. + +**Local variables** +- A panel may declare panel-scoped variables (data source `local`) with initial values. +- They are written by set-value widgets, buttons and logic actions, and referenced + anywhere a signal is. Added from the signal tree's *Local* group / the Logic palette. **Properties pane (right, resizable and collapsible)** - Appears when one or more widgets are selected. @@ -88,25 +106,27 @@ Activated via the "New interface" button or by clicking Edit in the toolbar. - Close (return to View mode). - Zoom control (A− / % / A+). -### 3.3 Plot Tab (Live Multi-Signal Plot) +### 3.3 Plot Panels (Split Layout) -A dedicated panel for live time-series plotting of any signals, independent of the interface layout. +A **plot panel** is a special interface kind dedicated to charts. Rather than free-form +widget boxes, plots **fill the viewport** and the user divides the space between them with +a recursive split layout (tmux/IDE-style tiling). Created via **+ Plot** in the interface +list, opening with a single full-viewport empty plot. -**Adding signals:** right-click any widget in the HMI tab → **Plot**. The signal is added to the Plot tab immediately. +**Editing (Edit mode):** +- Hover a pane and use its split buttons (**⬌** vertical / **⬍** horizontal) to divide it; + a new empty plot fills the freed half. Nesting is unlimited. +- Drag the divider between two panes to resize them. +- Click a pane to select it and configure its plot in the Properties pane (plot sub-type, + Y range, time window, legend, per-signal colour). Drag a signal onto a pane to add it. +- A pane's **✕** removes it; the layout collapses onto its sibling. -**Time window selector:** 10s / 30s / 1m / 5m / 15m / 1h buttons control how much history is displayed. +**View mode:** the saved split layout fills the screen with live, streaming plots; the +per-widget right-click menu (signal info / copy / CSV) and historical time navigation +apply as for any time-series plot. -**Per-signal legend:** -- Color swatch and signal name. -- Statistics table: Last / Min / Max / Mean over the current window. -- ✎ button opens an inline style editor: - - **Color**: color picker. - - **Width**: line width (none, 1, 1.5, 2, 3 px). - - **Line**: solid / dashed / dotted. - - **Markers**: none, S (3 px), M (5 px), L (8 px). -- ✕ button removes the signal from the plot. - -**Chart area:** rendered with uPlot; auto-scaled Y axis; time axis tracks the rolling window in real time. +Per-plot configuration reuses the standard Plot widget (§4.4), so all plot sub-types and +options are available within a pane. ### 3.4 UI Zoom @@ -210,16 +230,18 @@ When the signal's metadata reports enum strings (e.g. EPICS mbbi/mbbo records): A signal defined by composing one or more input signals through a chain of processing nodes. -**New Synthetic Signal Wizard:** -1. Click "New synthetic signal" in the signal tree. -2. Name the signal and optionally set a unit. -3. Add processing nodes from the node type dropdown and connect them. -4. Configure each node's parameters inline. -5. Click Create — the signal appears in the tree and updates live. +**Two authoring surfaces:** +- **Wizard** — for the common single-input case: name the signal, pick an input and a + processing node, set parameters, Create. +- **Node-graph editor** — a visual editor that wires one or more inputs through a chain of + DSP blocks, for multi-input pipelines. It compiles to the same inputs + pipeline model. -The wizard dialog is resizable (drag its corner) and defaults to 600 px wide to accommodate the Lua editor. +**Visibility scope:** each synthetic signal is scoped as *panel* (visible only to the panel +that created it), *user*, or *global* (shared with everyone). -**Editing an existing synthetic signal:** click the ✎ button next to the signal in the tree to reopen the wizard. +The dialogs are resizable and default to a width that accommodates the Lua editor. + +**Editing an existing synthetic signal:** click the ✎ button next to the signal in the tree to reopen the editor. **Built-in node types:** @@ -237,15 +259,80 @@ The wizard dialog is resizable (drag its corner) and defaults to 600 px wide to --- -## 6. Interface Persistence +## 6. Panel Logic -- Interfaces are saved to the server in XML format and are available to all connected clients. -- Export/Import allows local file exchange of XML files. -- The XML schema records: widget type, position, size, signal bindings, and all property values. +The **Logic** tab in the panel editor is a node-graph (Node-RED-style) flow editor that +gives a panel interactive behaviour. The flow runs **client-side** while the panel is open +in View mode; it is saved as part of the interface XML. The tab and any logic editing are +gated by the logic-edit restriction (§2). + +**Authoring:** drag blocks from the palette (or click to add), connect output ports to +input ports, and edit the selected node in the inspector. Expression fields reference a +signal as `{ds:name}` and a panel-local variable by its bare name, and support arithmetic, +comparisons, boolean logic and common math functions. The editor has its own undo/redo and +copy/paste. + +**Node categories:** + +| Category | Nodes | +|----------|-------| +| Triggers | Button press, threshold crossing, value change, timer/interval, panel loop, On-open / On-close lifecycle | +| Logic | AND gate, If (then/else), Loop (count or while) | +| Actions | Write to signal/variable, Delay, Log; Accumulate / Export-CSV / Clear for in-memory data arrays | +| Dialogs | Info and Error pop-ups; Set-point prompt (asks the user for a number and writes it) | + +**System helpers in expressions:** `{sys:time}` (epoch seconds) and `{sys:dt}` (seconds +since the firing trigger last fired). --- -## 7. Historical Data Navigation +## 7. Control Logic + +**Control logic** is server-side automation: flow graphs that run continuously on the +server, independent of any connected client (unlike panel logic, which only runs while a +panel is open). Opened with the **⚙ Control logic** button in the View-mode toolbar and +managed through the REST API. + +- Triggers include *cron* schedules and signal *alarm*/threshold conditions. +- A **Lua** block provides custom logic; results are written back to signals. +- Each graph can be enabled/disabled independently; saving reloads the engine live. +- Editing is gated by the logic-edit restriction (§2). + +--- + +## 8. Interface Persistence + +- Interfaces are saved to the server in XML format and are available to all connected clients. +- Per-panel access rules and panel-folder placement are stored server-side (sidecar JSON). +- Saved versions are retained; a panel's version history can be listed, tagged and promoted. +- Export/Import allows local file exchange of XML files. +- The XML schema records: interface kind (panel/plot) and split layout, widget type, + position, size, signal bindings, all property values, local variables, and panel logic. + +--- + +## 9. Access Control, Sharing & Folders + +**Identity** is resolved per request from the trusted proxy header, falling back to +`default_user`. The `/api/v1/me` endpoint reports the caller's identity, global level, +group memberships and whether they may edit logic; the UI hides affordances accordingly. + +**Global levels** (§2): write (default), read-only, or no-access via the config blacklist. + +**Per-panel sharing:** the **Share** dialog on a panel grants read or write to specific +users or groups, or marks the panel public. New panels are private to their owner. The +owner's global level caps any per-panel grant. + +**Folders:** panels are organised into nested folders; permissions inherit down the folder +chain. Panels can be dragged to reorder or move between folders. + +**Logic-editor allowlist:** when `server.logic_editors` is set, only the listed users/ +groups may add or change panel logic and control logic; everyone else keeps full access to +the rest of the application. + +--- + +## 10. Historical Data Navigation When the server has archive access configured: - The **⏱ History** button in the View mode toolbar reveals a time range bar. @@ -257,7 +344,7 @@ When the server has archive access configured: --- -## 8. Non-functional Requirements +## 11. Non-functional Requirements | Requirement | Target | |-------------|--------| diff --git a/docs/TECHNICAL_SPEC.md b/docs/TECHNICAL_SPEC.md index 09c09c4..1c6de44 100644 --- a/docs/TECHNICAL_SPEC.md +++ b/docs/TECHNICAL_SPEC.md @@ -186,17 +186,35 @@ Framing: JSON messages over a single persistent WebSocket connection per client. Base path: `/api/v1` -| Method | Path | Description | -| ------ | ------------------------- | -------------------------------------------- | -| GET | `/datasources` | List connected data sources and their status | -| GET | `/signals?ds=epics` | List signals for a data source | -| GET | `/signals/:ds/:name/meta` | Get full metadata for a signal | -| GET | `/interfaces` | List saved interfaces | -| POST | `/interfaces` | Create a new interface (body: XML) | -| GET | `/interfaces/:id` | Download interface XML | -| PUT | `/interfaces/:id` | Update interface XML | -| DELETE | `/interfaces/:id` | Delete interface | -| POST | `/interfaces/:id/clone` | Clone an interface | +| Method | Path | Description | +| --------------- | ------------------------------------- | ------------------------------------------------- | +| GET | `/me` | Caller identity, global level, groups, `canEditLogic` | +| GET | `/datasources` | List connected data sources and their status | +| GET | `/signals?ds=epics` | List signals for a data source | +| GET | `/signals/search?q=` | Search signals across all sources | +| GET | `/channel-finder?q=` | Proxy to EPICS Channel Finder | +| GET | `/archiver/search?q=` | Search the EPICS archiver for PV names | +| GET, POST | `/interfaces` | List saved interfaces / create one (body: XML) | +| POST | `/interfaces/reorder` | Reorder panels / move between folders | +| GET, PUT, DELETE| `/interfaces/{id}` | Download, update, or delete an interface | +| POST | `/interfaces/{id}/clone` | Clone an interface | +| GET | `/interfaces/{id}/versions` | List saved versions; `…/{version}` to fetch one | +| PUT | `/interfaces/{id}/versions/{v}/tag` | Tag a version | +| POST | `/interfaces/{id}/versions/{v}/promote` | Promote a version to current | +| POST | `/interfaces/{id}/versions/{v}/fork` | Fork a version into a new panel | +| GET, PUT | `/interfaces/{id}/acl` | Read or set a panel's sharing rules | +| GET, POST | `/folders` | List or create panel folders | +| PUT, DELETE | `/folders/{id}` | Rename/reparent or delete a folder | +| GET | `/usergroups` | List configured users and groups (for sharing) | +| GET, PUT | `/groups` | Read or set group definitions | +| GET, POST | `/synthetic` | List or create synthetic signal definitions | +| GET, PUT, DELETE| `/synthetic/{name}` | Read, update, or delete a synthetic definition | +| GET, POST | `/controllogic` | List or create server-side control-logic graphs | +| GET, PUT, DELETE| `/controllogic/{id}` | Read, update, or delete a control-logic graph | + +Mutating requests are gated by the access middleware (§8): global level for writes, +per-panel ACL for interface endpoints, and the logic-editor allowlist for control-logic +endpoints and for any change to a panel's `` block. ### 3.5 EPICS Data Source @@ -232,7 +250,15 @@ Base path: `/api/v1` ### 3.7 Interface Storage -Interfaces are stored as XML files in a configurable directory on the server. +Interfaces are stored as XML files in a configurable directory on the server. The +`` element carries an optional `kind` attribute (`panel` default, or `plot`); +plot panels add a nested `` split tree referencing plot widgets by id. Beyond +widgets, an interface may also contain panel-local variables and a `` flow graph +(`` + `` + ``), 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 @@ -255,6 +281,39 @@ Interfaces are stored as XML files in a configurable directory on the server. ``` +### 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 `` block; it is surfaced to the frontend through `/api/v1/me` +(`canEditLogic`) so the UI can hide logic-editing affordances. + --- ## 4. Frontend Architecture @@ -298,16 +357,17 @@ View mode renders widgets as absolutely positioned Preact components on a scroll - Plot widgets maintain a rolling ring buffer of 200,000 samples per signal for smooth long-window display. - Step-hold interpolation: when multiple signals at different update rates share a plot, the most recent value is carried forward to fill the shared time axis correctly. -### 4.5 Plot Panel (`PlotPanel.tsx`) +### 4.5 Plot Panels (`SplitLayout.tsx`, `PlotPanelCanvas.tsx`) -A live multi-signal plot panel accessible from the "Plot" tab in View mode: +Plot panels are interfaces of `kind === 'plot'` whose plots fill the viewport in a recursive +split layout (`lib/plotLayout.ts` holds the pure tree helpers; `SplitLayout.tsx` is the +shared presentational renderer with draggable dividers): -- Signals are added by right-clicking any widget and choosing "Plot". -- Supports configurable time window (10s to 1h). -- Per-signal style editor: color picker, line width (0 = hidden), line dash (solid/dashed/dotted), marker size (none/S/M/L). -- Statistics table per signal: last, min, max, mean over the current time window. -- Uses a `requestAnimationFrame` loop limited to ≤1 redraw/second when data is not changing. -- Chart fills its container; `ResizeObserver` keeps the uPlot canvas sized correctly. +- In View mode, `Canvas` renders the saved layout read-only with live `PlotWidget`s. +- In Edit mode, `PlotPanelCanvas.tsx` adds per-pane overlays: split (⬌/⬍), close (✕), click + to select, and signal-drop to add a signal to that pane's plot. +- Each pane reuses the standard `PlotWidget`, so all plot sub-types and options apply. +- Layout edits (split/close/resize) participate in the editor's undo/redo. ### 4.6 Resizable Panels @@ -407,17 +467,31 @@ Server is configured via a TOML file (default: `uopi.toml`, overridable via `--c listen = ":8080" storage_dir = "./interfaces" +# Access control (all optional) +trusted_user_header = "" # header carrying the proxy-authenticated user +default_user = "" # identity when the header is absent (LAN/dev) +logic_editors = [] # users/groups allowed to edit panel & control logic + +# [[server.blacklist]] # downgrade users: level = "readonly" | "noaccess" +# user = "guest" +# level = "readonly" + +# [[groups]] # named user sets for per-panel sharing +# name = "operators" +# members = ["alice", "bob"] + [datasource.epics] enabled = true ca_addr_list = "" # EPICS_CA_ADDR_LIST override archive_url = "" # EPICS Archive Appliance URL +channel_finder_url = "" # EPICS Channel Finder URL [datasource.synthetic] enabled = true -definitions_file = "./synthetic.toml" ``` -All settings can also be overridden with `UOPI_*` environment variables (e.g. `UOPI_SERVER_LISTEN`, `UOPI_EPICS_CA_ADDR_LIST`). +All settings can also be overridden with `UOPI_*` environment variables (e.g. +`UOPI_SERVER_LISTEN`, `UOPI_SERVER_LOGIC_EDITORS`, `UOPI_EPICS_CA_ADDR_LIST`). --- @@ -440,13 +514,20 @@ All settings can also be overridden with `UOPI_*` environment variables (e.g. `U - Lua sandbox: disable `os`, `io`, `package`, `debug` libraries; restrict `math` and `string` to safe subsets. - WebSocket write operations: validate that the target signal is writable before forwarding to the data source. - Interface XML parsing: use strict schema validation to prevent XXE. -- No authentication in v1; intended for trusted LAN / SSH-tunnel deployment. +- **Identity & access control:** the end-user identity is taken from a header set by a + trusted authenticating reverse proxy (`trusted_user_header`), never from client-supplied + values — the proxy MUST strip any inbound copy of that header or it can be spoofed. A + global blacklist downgrades users (read-only/no-access); per-panel ACLs and the optional + `logic_editors` allowlist provide finer control. An unidentified caller (no header, no + `default_user`) is treated as a trusted-LAN user with full write access, preserving the + unproxied/SSH-tunnel deployment model. --- ## 9. Non-goals (v1) -- User authentication and authorisation. +- Built-in user authentication (a login page / credential store) — identity is delegated to + the front-end reverse proxy; authorisation (levels, ACLs, logic allowlist) is implemented. - TLS termination (expected to be handled by SSH tunnel or a reverse proxy). - Windows or macOS server binary. - Mobile-optimised frontend layout. diff --git a/internal/access/access.go b/internal/access/access.go index 17ce3c1..ac4c923 100644 --- a/internal/access/access.go +++ b/internal/access/access.go @@ -56,19 +56,29 @@ func ParseLevel(s string) Level { // Policy holds the resolved global access configuration. It is immutable after // construction and safe for concurrent use. type Policy struct { - defaultUser string - blacklist map[string]Level // user → downgraded level - userGroups map[string][]string // user → groups they belong to - groupNames []string // all configured group names (sorted) + defaultUser string + blacklist map[string]Level // user → downgraded level + userGroups map[string][]string // user → groups they belong to + groupNames []string // all configured group names (sorted) + logicEditors map[string]bool // users + group names allowed to edit logic } // New builds a Policy. blacklist maps a username to a config level string; -// groups maps a group name to its member usernames. -func New(defaultUser string, blacklist map[string]string, groups map[string][]string) *Policy { +// groups maps a group name to its member usernames. logicEditors optionally +// restricts who may edit panel/control logic (usernames or group names); empty +// means no restriction. +func New(defaultUser string, blacklist map[string]string, groups map[string][]string, logicEditors []string) *Policy { p := &Policy{ - defaultUser: strings.TrimSpace(defaultUser), - blacklist: make(map[string]Level), - userGroups: make(map[string][]string), + defaultUser: strings.TrimSpace(defaultUser), + blacklist: make(map[string]Level), + userGroups: make(map[string][]string), + logicEditors: make(map[string]bool), + } + for _, e := range logicEditors { + e = strings.TrimSpace(e) + if e != "" { + p.logicEditors[e] = true + } } for user, lvl := range blacklist { u := strings.TrimSpace(user) @@ -126,6 +136,35 @@ func (p *Policy) Level(user string) Level { return LevelWrite } +// LogicRestricted reports whether a logic-editor allowlist is configured. When +// false, any write-capable user may edit panel/control logic. +func (p *Policy) LogicRestricted() bool { + return len(p.logicEditors) > 0 +} + +// CanEditLogic reports whether a user may add or edit panel logic and +// server-side control logic. When no allowlist is configured everyone with +// write access qualifies; otherwise the user (or one of their groups) must be +// listed. Anonymous/trusted-LAN callers (user=="") are always permitted. +func (p *Policy) CanEditLogic(user string) bool { + user = strings.TrimSpace(user) + if user == "" { + return true + } + if !p.LogicRestricted() { + return true + } + if p.logicEditors[user] { + return true + } + for _, g := range p.userGroups[user] { + if p.logicEditors[g] { + return true + } + } + return false +} + // GroupsOf returns a copy of the groups a user belongs to. func (p *Policy) GroupsOf(user string) []string { src := p.userGroups[strings.TrimSpace(user)] diff --git a/internal/access/access_test.go b/internal/access/access_test.go new file mode 100644 index 0000000..0b73283 --- /dev/null +++ b/internal/access/access_test.go @@ -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) + } + } +} diff --git a/internal/api/api.go b/internal/api/api.go index e8a4dae..e308b03 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -15,6 +15,7 @@ import ( "github.com/uopi/uopi/internal/access" "github.com/uopi/uopi/internal/broker" + "github.com/uopi/uopi/internal/controllogic" "github.com/uopi/uopi/internal/datasource" "github.com/uopi/uopi/internal/datasource/synthetic" "github.com/uopi/uopi/internal/panelacl" @@ -28,19 +29,23 @@ type Handler struct { store *storage.Store policy *access.Policy acl *panelacl.Store + ctrlLogic *controllogic.Store + ctrlEngine *controllogic.Engine channelFinderURL string // empty if not configured archiverURL string // empty if not configured log *slog.Logger } // New creates an API Handler. synth may be nil if the synthetic DS is disabled. -func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, channelFinderURL, archiverURL string, log *slog.Logger) *Handler { +func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, channelFinderURL, archiverURL string, log *slog.Logger) *Handler { return &Handler{ broker: b, synthetic: synth, store: store, policy: policy, acl: acl, + ctrlLogic: ctrlLogic, + ctrlEngine: ctrlEngine, channelFinderURL: channelFinderURL, archiverURL: archiverURL, log: log, @@ -87,6 +92,13 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) { mux.HandleFunc("GET "+prefix+"/synthetic/{name}", h.getSynthetic) mux.HandleFunc("PUT "+prefix+"/synthetic/{name}", h.updateSynthetic) mux.HandleFunc("DELETE "+prefix+"/synthetic/{name}", h.deleteSynthetic) + // Server-side control logic CRUD (mutations are write-gated by the access + // middleware; each mutation reloads the running engine). + mux.HandleFunc("GET "+prefix+"/controllogic", h.listControlLogic) + mux.HandleFunc("POST "+prefix+"/controllogic", h.createControlLogic) + mux.HandleFunc("GET "+prefix+"/controllogic/{id}", h.getControlLogic) + mux.HandleFunc("PUT "+prefix+"/controllogic/{id}", h.updateControlLogic) + mux.HandleFunc("DELETE "+prefix+"/controllogic/{id}", h.deleteControlLogic) } // ── /me ───────────────────────────────────────────────────────────────────── @@ -102,9 +114,10 @@ func (h *Handler) getMe(w http.ResponseWriter, r *http.Request) { groups = []string{} } jsonOK(w, map[string]any{ - "user": user, - "level": h.policy.Level(user).String(), - "groups": groups, + "user": user, + "level": h.policy.Level(user).String(), + "groups": groups, + "canEditLogic": h.policy.CanEditLogic(user), }) } @@ -420,9 +433,10 @@ func (h *Handler) putGroups(w http.ResponseWriter, r *http.Request) { // render sharing affordances and filter the visible set. type interfaceListItem struct { storage.InterfaceMeta - Owner string `json:"owner,omitempty"` - Folder string `json:"folder,omitempty"` - Perm string `json:"perm"` + Owner string `json:"owner,omitempty"` + Folder string `json:"folder,omitempty"` + Order float64 `json:"order,omitempty"` + Perm string `json:"perm"` } func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) { @@ -442,6 +456,7 @@ func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) { if acl := h.acl.GetPanel(m.ID); acl != nil { item.Owner = acl.Owner item.Folder = acl.Folder + item.Order = acl.Order } out = append(out, item) } @@ -455,6 +470,12 @@ func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) { jsonError(w, http.StatusBadRequest, "read body: "+err.Error()) return } + // Editing panel logic may be restricted to an allowlist. A brand-new panel + // carrying a non-empty block requires that permission. + if !h.policy.CanEditLogic(caller(r)) && extractLogicBlock(body) != "" { + jsonError(w, http.StatusForbidden, "you are not permitted to edit panel logic") + return + } id, err := h.store.Create(body, r.URL.Query().Get("tag")) if err != nil { h.log.Error("create interface", "err", err) @@ -474,6 +495,44 @@ func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]string{"id": id}) } +// reorderInterfaces places a set of panels into a folder (or the root when folder +// is empty) in the given order, stamping each id with order=index. The caller must +// have write access to the destination folder (if any) and to each panel. +func (h *Handler) reorderInterfaces(w http.ResponseWriter, r *http.Request) { + var req struct { + Folder string `json:"folder"` + IDs []string `json:"ids"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil { + jsonError(w, http.StatusBadRequest, "decode body: "+err.Error()) + return + } + if req.Folder != "" { + if _, err := h.acl.GetFolder(req.Folder); err != nil { + jsonError(w, http.StatusNotFound, "folder not found: "+req.Folder) + return + } + if h.folderPerm(r, req.Folder) < panelacl.PermWrite { + jsonError(w, http.StatusForbidden, "you do not have write access to this folder") + return + } + } + for _, id := range req.IDs { + if h.panelPerm(r, id) < panelacl.PermWrite { + jsonError(w, http.StatusForbidden, "you do not have write access to panel: "+id) + return + } + } + for i, id := range req.IDs { + if err := h.acl.PlacePanel(id, req.Folder, float64(i)); err != nil { + h.log.Error("reorder interface", "id", id, "err", err) + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + } + w.WriteHeader(http.StatusNoContent) +} + func (h *Handler) getInterface(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if h.panelPerm(r, id) < panelacl.PermRead { @@ -504,6 +563,14 @@ func (h *Handler) updateInterface(w http.ResponseWriter, r *http.Request) { jsonError(w, http.StatusBadRequest, "read body: "+err.Error()) return } + // Editing panel logic may be restricted to an allowlist. Permit unrestricted + // edits to the rest of the panel as long as the block is unchanged. + if !h.policy.CanEditLogic(caller(r)) { + if cur, err := h.store.Get(id); err != nil || extractLogicBlock(body) != extractLogicBlock(cur) { + jsonError(w, http.StatusForbidden, "you are not permitted to edit panel logic") + return + } + } if err := h.store.Update(id, body, r.URL.Query().Get("tag")); err != nil { if errors.Is(err, storage.ErrNotFound) { jsonError(w, http.StatusNotFound, "interface not found: "+id) @@ -1009,8 +1076,121 @@ func (h *Handler) deleteSynthetic(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// ── Control logic ────────────────────────────────────────────────────────────── + +func (h *Handler) listControlLogic(w http.ResponseWriter, _ *http.Request) { + if h.ctrlLogic == nil { + jsonOK(w, []any{}) + return + } + graphs := h.ctrlLogic.List() + if graphs == nil { + graphs = []controllogic.Graph{} + } + jsonOK(w, graphs) +} + +func (h *Handler) getControlLogic(w http.ResponseWriter, r *http.Request) { + if h.ctrlLogic == nil { + jsonError(w, http.StatusServiceUnavailable, "control logic not enabled") + return + } + g, err := h.ctrlLogic.Get(r.PathValue("id")) + if err != nil { + jsonError(w, http.StatusNotFound, "control logic graph not found: "+r.PathValue("id")) + return + } + jsonOK(w, g) +} + +func (h *Handler) createControlLogic(w http.ResponseWriter, r *http.Request) { + if h.ctrlLogic == nil { + jsonError(w, http.StatusServiceUnavailable, "control logic not enabled") + return + } + if !h.policy.CanEditLogic(caller(r)) { + jsonError(w, http.StatusForbidden, "you are not permitted to edit logic") + return + } + var g controllogic.Graph + if err := json.NewDecoder(r.Body).Decode(&g); err != nil { + jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + g.ID = genID("cl") + if err := h.ctrlLogic.Save(g); err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + h.ctrlEngine.Reload() + w.WriteHeader(http.StatusCreated) + jsonOK(w, g) +} + +func (h *Handler) updateControlLogic(w http.ResponseWriter, r *http.Request) { + if h.ctrlLogic == nil { + jsonError(w, http.StatusServiceUnavailable, "control logic not enabled") + return + } + if !h.policy.CanEditLogic(caller(r)) { + jsonError(w, http.StatusForbidden, "you are not permitted to edit logic") + return + } + id := r.PathValue("id") + if _, err := h.ctrlLogic.Get(id); err != nil { + jsonError(w, http.StatusNotFound, "control logic graph not found: "+id) + return + } + var g controllogic.Graph + if err := json.NewDecoder(r.Body).Decode(&g); err != nil { + jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + g.ID = id + if err := h.ctrlLogic.Save(g); err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + h.ctrlEngine.Reload() + jsonOK(w, g) +} + +func (h *Handler) deleteControlLogic(w http.ResponseWriter, r *http.Request) { + if h.ctrlLogic == nil { + jsonError(w, http.StatusServiceUnavailable, "control logic not enabled") + return + } + if !h.policy.CanEditLogic(caller(r)) { + jsonError(w, http.StatusForbidden, "you are not permitted to edit logic") + return + } + if err := h.ctrlLogic.Delete(r.PathValue("id")); err != nil { + jsonError(w, http.StatusNotFound, "control logic graph not found: "+r.PathValue("id")) + return + } + h.ctrlEngine.Reload() + w.WriteHeader(http.StatusNoContent) +} + // ── Helpers ─────────────────────────────────────────────────────────────────── +// extractLogicBlock returns the verbatim 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, "") + if start < 0 { + return "" + } + end := strings.Index(s[start:], "") + if end < 0 { + return "" + } + return s[start : start+end+len("")] +} + func metaToSignalInfo(m datasource.Metadata) signalInfo { return signalInfo{ Name: m.Name, diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 41094b3..7fb85a9 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -15,6 +15,7 @@ import ( "github.com/uopi/uopi/internal/access" "github.com/uopi/uopi/internal/api" "github.com/uopi/uopi/internal/broker" + "github.com/uopi/uopi/internal/controllogic" "github.com/uopi/uopi/internal/datasource/stub" "github.com/uopi/uopi/internal/panelacl" "github.com/uopi/uopi/internal/storage" @@ -45,8 +46,14 @@ func setup(t *testing.T) (*httptest.Server, func()) { t.Fatal("panelacl.New:", err) } + clStore, err := controllogic.NewStore(dir) + if err != nil { + t.Fatal("controllogic.NewStore:", err) + } + clEngine := controllogic.NewEngine(ctx, brk, clStore, log) + mux := http.NewServeMux() - api.New(brk, nil, store, access.New("", nil, nil), acl, "", "", log).Register(mux, "/api/v1") + api.New(brk, nil, store, access.New("", nil, nil, nil), acl, clStore, clEngine, "", "", log).Register(mux, "/api/v1") srv := httptest.NewServer(mux) return srv, func() { diff --git a/internal/config/config.go b/internal/config/config.go index 13a8edc..2ef06f9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -50,6 +50,13 @@ type ServerConfig struct { // Blacklist downgrades specific users' global access level. Everyone not // listed is trusted with full write access. Blacklist []BlacklistEntry `toml:"blacklist"` + + // LogicEditors optionally restricts who may add or edit panel logic (the + // block of interfaces) and server-side control logic. Entries are + // usernames or group names. When empty, no restriction applies (any user + // with write access may edit logic). Anonymous/trusted-LAN callers are + // always permitted. + LogicEditors []string `toml:"logic_editors"` } type DatasourceConfig struct { @@ -131,6 +138,9 @@ func applyEnv(cfg *Config) { if v := env("UOPI_SERVER_DEFAULT_USER"); v != "" { cfg.Server.DefaultUser = v } + if v := env("UOPI_SERVER_LOGIC_EDITORS"); v != "" { + cfg.Server.LogicEditors = strings.Fields(v) + } if v := env("UOPI_EPICS_CA_ADDR_LIST"); v != "" { cfg.Datasource.EPICS.CAAddrList = v } diff --git a/internal/controllogic/cron.go b/internal/controllogic/cron.go new file mode 100644 index 0000000..58a863d --- /dev/null +++ b/internal/controllogic/cron.go @@ -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 +} diff --git a/internal/controllogic/cron_test.go b/internal/controllogic/cron_test.go new file mode 100644 index 0000000..12345a0 --- /dev/null +++ b/internal/controllogic/cron_test.go @@ -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) + } + } +} diff --git a/internal/controllogic/engine.go b/internal/controllogic/engine.go new file mode 100644 index 0000000..865e9fe --- /dev/null +++ b/internal/controllogic/engine.go @@ -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) + }) +} diff --git a/internal/controllogic/expr.go b/internal/controllogic/expr.go new file mode 100644 index 0000000..5cad86c --- /dev/null +++ b/internal/controllogic/expr.go @@ -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 "" +} diff --git a/internal/controllogic/expr_test.go b/internal/controllogic/expr_test.go new file mode 100644 index 0000000..17d0eea --- /dev/null +++ b/internal/controllogic/expr_test.go @@ -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) + } +} diff --git a/internal/controllogic/lua.go b/internal/controllogic/lua.go new file mode 100644 index 0000000..013e369 --- /dev/null +++ b/internal/controllogic/lua.go @@ -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()) + } +} diff --git a/internal/controllogic/model.go b/internal/controllogic/model.go new file mode 100644 index 0000000..26541a0 --- /dev/null +++ b/internal/controllogic/model.go @@ -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] +} diff --git a/internal/controllogic/store.go b/internal/controllogic/store.go new file mode 100644 index 0000000..b98d2d8 --- /dev/null +++ b/internal/controllogic/store.go @@ -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() +} diff --git a/internal/server/server.go b/internal/server/server.go index e8cfca3..88e6a48 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -11,6 +11,7 @@ import ( "github.com/uopi/uopi/internal/access" "github.com/uopi/uopi/internal/api" "github.com/uopi/uopi/internal/broker" + "github.com/uopi/uopi/internal/controllogic" "github.com/uopi/uopi/internal/datasource/synthetic" "github.com/uopi/uopi/internal/metrics" "github.com/uopi/uopi/internal/panelacl" @@ -26,7 +27,7 @@ type Server struct { // New creates the HTTP server, registers all routes, and returns a ready-to-start Server. // synth may be nil if the synthetic data source is not enabled. -func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server { +func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server { mux := http.NewServeMux() // Health check @@ -44,7 +45,7 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti // REST API — registered on a dedicated mux so it can be wrapped with the // access-control middleware (identity resolution + global level enforcement). apiMux := http.NewServeMux() - api.New(brk, synth, store, policy, acl, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix) + api.New(brk, synth, store, policy, acl, ctrlLogic, ctrlEngine, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix) mux.Handle(apiPrefix+"/", accessMiddleware(policy, trustedUserHeader, apiMux)) // Embedded frontend — must be last (catch-all) diff --git a/uopi.example.toml b/uopi.example.toml index 64a31bb..5cd739a 100644 --- a/uopi.example.toml +++ b/uopi.example.toml @@ -18,11 +18,17 @@ default_user = "" # user = "guest" # level = "readonly" -# Named sets of users, referenced by per-panel sharing rules (future phases). +# Named sets of users, referenced by per-panel sharing rules. # [[groups]] # name = "operators" # members = ["alice", "bob"] +# Optional: restrict who may add or edit panel logic (the block of +# interfaces) AND server-side control logic. Entries are usernames or group +# names. Empty/unset = no restriction (any writer may edit logic). Also settable +# via UOPI_SERVER_LOGIC_EDITORS (space-separated). +# logic_editors = ["operators", "alice"] + [datasource.epics] enabled = true ca_addr_list = "" # overrides EPICS_CA_ADDR_LIST if set @@ -31,5 +37,4 @@ channel_finder_url = "" # e.g. http://channelfinder:8080/ChannelFinder auto_sync_filter = "" # e.g. area=StorageRing or tags=production [datasource.synthetic] -enabled = true -definitions_file = "./synthetic.json" +enabled = true diff --git a/web/src/Canvas.tsx b/web/src/Canvas.tsx index 892a561..375bb9d 100644 --- a/web/src/Canvas.tsx +++ b/web/src/Canvas.tsx @@ -18,6 +18,7 @@ import LinkWidget from './widgets/LinkWidget'; import ContextMenu from './ContextMenu'; import InfoPanel from './InfoPanel'; import SplitLayout from './SplitLayout'; +import LogicDialogs from './LogicDialogs'; const COMPONENTS: Record = { textview: TextView, @@ -119,6 +120,8 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) { {infoSignal && ( setInfoSignal(null)} /> )} + + ); } @@ -159,6 +162,8 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) { {infoSignal && ( setInfoSignal(null)} /> )} + + ); } diff --git a/web/src/ContextualHelp.tsx b/web/src/ContextualHelp.tsx index a4c3b37..a93ad2c 100644 --- a/web/src/ContextualHelp.tsx +++ b/web/src/ContextualHelp.tsx @@ -12,6 +12,8 @@ const TIPS: Record> = { { text: 'Right-click a widget to view signal info or export data as CSV.', section: 'view' }, { text: 'The ● Live button shows live streaming is active. Use the date pickers to load historical data.', section: 'history' }, { text: 'Click Edit (top-right) to open the drag-and-drop panel builder.', section: 'edit' }, + { text: 'Use the Share button on a panel to grant access to users or groups.', section: 'sharing' }, + { text: 'The ⚙ Control logic button opens server-side automation that runs even with no panel open.', section: 'control' }, { text: 'The dot in the status chip turns green when the WebSocket is connected.', section: 'view' }, ], edit: [ @@ -20,6 +22,8 @@ const TIPS: Record> = { { text: 'Ctrl+click to select multiple widgets; then use the align toolbar.', section: 'edit' }, { text: 'Ctrl+Z to undo, Ctrl+Y to redo. Up to 50 steps are remembered.', section: 'shortcuts' }, { text: 'Use "+ Synthetic" in the signal tree to create computed/filtered signals.', section: 'signals' }, + { text: 'Switch to the Logic tab to add interactive behaviour with a node-graph flow editor.', section: 'logic' }, + { text: 'Add Local variables for set-points and counters that live inside the panel.', section: 'edit' }, { text: 'Arrow keys nudge the selected widget by 1 px (Shift for grid size).', section: 'shortcuts' }, ], }; diff --git a/web/src/ControlLogicEditor.tsx b/web/src/ControlLogicEditor.tsx new file mode 100644 index 0000000..d2dc294 --- /dev/null +++ b/web/src/ControlLogicEditor.tsx @@ -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; +} +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; } + +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 = { + '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([]); + const [selectedId, setSelectedId] = useState(null); + const [graph, setGraph] = useState(null); + const [dirty, setDirty] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(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 = { 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) { + if (!graph) return; + setGraph({ ...graph, ...patch }); + setDirty(true); + } + + return ( +
{ if (e.target === e.currentTarget) onClose(); }}> +
+
+ Control logic + Server-side flows — run continuously, independent of any panel. +
+ {graph && dirty && unsaved} + {graph && } + +
+
+ + {error &&
{error}
} + +
+
+
+ Graphs + +
+ {graphs.length === 0 &&
No control logic yet.
} + {graphs.map(g => ( +
selectGraph(g)}> + + {g.name || '(unnamed)'} + + +
+ ))} +
+ +
+ {!graph &&
Select a graph on the left, or create a new one.
} + {graph && ( + +
+ patchGraph({ name: (e.target as HTMLInputElement).value })} /> + + Saving a disabled graph stops it; enabling + saving starts it. +
+ { setGraph({ ...graph, nodes: g.nodes, wires: g.wires }); setDirty(true); }} /> +
+ )} +
+
+
+
+ ); +} + +// ── 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(null); + const [selectedWire, setSelectedWire] = useState(null); + const [dataSources, setDataSources] = useState([]); + const [dsSignals, setDsSignals] = useState>({}); + + const canvasRef = useRef(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) { + 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 ( +
+
+
Triggers
+ {PALETTE.filter(e => category(e.kind) === 'trigger').map(paletteBtn)} +
Logic
+ {PALETTE.filter(e => category(e.kind) === 'gate' || category(e.kind) === 'flow').map(paletteBtn)} +
Actions
+ {PALETTE.filter(e => category(e.kind) === 'action').map(paletteBtn)} +
+ Triggers start a flow. Drag a node's right port to another node's left port to connect. + In expressions, reference signals as {'{ds:name}'} and graph-local vars by name. +
+
+ +
{ setSelectedNode(null); setSelectedWire(null); }} + onDragOver={onCanvasDragOver} + onDrop={onCanvasDrop}> +
+ + {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 ( + { e.stopPropagation(); setSelectedWire(idx); setSelectedNode(null); }} /> + ); + })} + {pendingWire && (() => { + const a = byId.get(pendingWire.from); + if (!a) return null; + const p1 = outAnchor(a, pendingWire.port); + return ; + })()} + + + {nodes.map(node => ( +
startNodeDrag(e, node)} + onMouseUp={() => { if (pendingRef.current) finishWire(node); }}> +
+ {KIND_LABEL[node.kind]} + +
+
{nodeSummary(node)}
+ + {hasInput(node.kind) && ( +
e.stopPropagation()} + onMouseUp={(e) => { e.stopPropagation(); finishWire(node); }} /> + )} + {outputs(node.kind).map((port, i) => ( + + {port.label && ( + {port.label} + )} +
startWire(e, node, port.id)} /> + + ))} +
+ ))} +
+
+ +
+ {!selected &&
Select a node to edit it, or add one from the palette.
} + {selected && ( + +
{KIND_LABEL[selected.kind]}
+ + {(selected.kind === 'trigger.threshold' || selected.kind === 'trigger.change' || selected.kind === 'trigger.alarm') && (() => { + const { ds, name } = splitRef(selected.params.signal ?? ''); + return ( + +
+ + { loadSignals(newDs); patchParams(selected.id, { signal: `${newDs}:` }); }} /> +
+
+ + patchParams(selected.id, { signal: `${ds}:${sig}` })} + placeholder={ds ? 'Search signals…' : 'Select data source first'} /> +
+ {selected.kind === 'trigger.threshold' && ( +
+
+ + +
+
+ + patchParams(selected.id, { value: (e.target as HTMLInputElement).value })} /> +
+
+ )} + {selected.kind === 'trigger.alarm' && ( +
+
+ + patchParams(selected.id, { min: (e.target as HTMLInputElement).value })} /> +
+
+ + patchParams(selected.id, { max: (e.target as HTMLInputElement).value })} /> +
+
+ )} + {selected.kind === 'trigger.change' &&

Fires whenever the signal's value changes.

} + {selected.kind === 'trigger.alarm' &&

Fires when the signal leaves the [min, max] range (rising edge).

} +
+ ); + })()} + + {selected.kind === 'trigger.timer' && ( +
+ + patchParams(selected.id, { interval: (e.target as HTMLInputElement).value })} /> +

Fires repeatedly on the server while the graph is enabled.

+
+ )} + + {selected.kind === 'trigger.cron' && ( + +
+ + patchParams(selected.id, { spec: (e.target as HTMLInputElement).value })} /> +
+
+ + +
+

5 fields: minute hour day-of-month month day-of-week. Supports *, */n, ranges a-b and lists a,b.

+
+ )} + + {selected.kind === 'gate.and' && ( +

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).

+ )} + + {selected.kind === 'flow.if' && ( + 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' && ( + +
+ + +
+ {(selected.params.mode ?? 'count') === 'count' ? ( +
+ + patchParams(selected.id, { count: (e.target as HTMLInputElement).value })} /> +
+ ) : ( + 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" /> + )} +

Runs the body port repeatedly, then continues on done.

+
+ )} + + {selected.kind === 'action.write' && (() => { + const { ds, name } = splitRef(selected.params.target ?? ''); + return ( + +
+ + { loadSignals(newDs); patchParams(selected.id, { target: `${newDs}:` }); }} /> +
+
+ + patchParams(selected.id, { target: `${ds}:${sig}` })} + placeholder={ds ? 'Search signals…' : 'Select data source first'} /> +
+ 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}." /> +
+ ); + })()} + + {selected.kind === 'action.delay' && ( +
+ + patchParams(selected.id, { ms: (e.target as HTMLInputElement).value })} /> +
+ )} + + {selected.kind === 'action.log' && ( + +
+ + patchParams(selected.id, { label: (e.target as HTMLInputElement).value })} /> +
+ 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." /> +
+ )} + + {selected.kind === 'action.lua' && ( +
+ + patchParams(selected.id, { script: v })} rows={14} /> +

+ Host functions: get("ds:name") reads a signal/sys/local value, + set("ds:name", v) writes (bare name → graph-local var), + log(msg) logs to the server. The os/io libraries are disabled. +

+
+ )} + + +
+ )} +
+
+ ); + + function paletteBtn(entry: PaletteEntry) { + return ( + + ); + } +} + +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 ( +
+ + onChange((e.target as HTMLInputElement).value)} /> + {err &&

{err}

} +
+ { setInsDs(ds); loadSignals(ds); }} placeholder="ds…" /> + onInsert(insDs, sig)} + placeholder={insDs ? '+ insert signal' : 'pick ds first'} /> +
+

{hint}

+
+ ); +} + +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 ''; + } +} diff --git a/web/src/EditMode.tsx b/web/src/EditMode.tsx index f0d940d..992ea73 100644 --- a/web/src/EditMode.tsx +++ b/web/src/EditMode.tsx @@ -88,7 +88,8 @@ const STATIC_WIDGET_TYPES = [ export default function EditMode({ initial, onDone }: Props) { const [iface, setIface] = useState(initial ?? blankInterface()); - const writable = canWrite(useAuth().level); + const me = useAuth(); + const writable = canWrite(me.level); const [selectedIds, setSelectedIds] = useState([]); const [dirty, setDirty] = 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' : ''}`} onClick={() => setCenterTab('layout')} >Layout - + {me.canEditLogic && ( + + )}
- {centerTab === 'logic' ? ( + {centerTab === 'logic' && me.canEditLogic ? ( Align & distribute

When 2+ widgets are selected the alignment toolbar appears. Buttons align edges or centres; with 3+ selected you can distribute spacing evenly.

+

Layout & Logic tabs

+

The centre of the editor has two tabs:

+
    +
  • Layout — the drag-and-drop widget canvas described above.
  • +
  • Logic — a visual flow editor that adds interactive behaviour to the + panel (buttons that run actions, thresholds that pop up dialogs, timers, …). See the + Panel Logic section. The tab is hidden if you are not permitted to edit logic.
  • +
+ +

Local variables

+

+ Panels can define their own local variables — lightweight values that live + only inside the panel (no data source needed). Add them from the signal tree's + Local 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. +

+

Saving

  • Save — stores the panel on the server (XML). A indicates unsaved changes.
  • @@ -520,6 +542,141 @@ function SectionEdit() { ); } +function SectionPlots() { + return ( +
    +

    + A plot panel is a special interface dedicated to charts. Instead of + free-form boxes, plots fill the viewport and you split the space between them like + panes in a tiling window manager (tmux / IDE style). +

    + +

    Creating one

    +
      +
    • Click + Plot in the interface list to create a plot panel — it opens + with one full-viewport empty plot.
    • +
    + +

    Splitting & arranging

    +
      +
    • Hover a pane and use its split buttons ( vertical / + horizontal) to divide it — a new empty plot appears in the freed half.
    • +
    • Drag the divider between two panes to resize them; nesting is unlimited.
    • +
    • Click a pane to select it, then configure its plot in the Properties pane (plot type, + time window, range, legend, per-signal colour).
    • +
    • Drag a signal from the tree onto a pane to add it to that pane's plot.
    • +
    • Use a pane's to remove it; the layout collapses onto its sibling.
    • +
    +

    In view mode the saved split layout fills the screen with live, streaming plots.

    +
    + ); +} + +function SectionLogic() { + return ( +
    +

    + The Logic tab in the panel editor is a node-graph (Node-RED style) flow + editor. You wire trigger nodes to action nodes to give a panel interactive + behaviour. Logic runs entirely client-side while the panel is open in view mode. +

    + +

    Building a flow

    +
      +
    • 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.
    • +
    • Select a node to edit its parameters in the right inspector.
    • +
    • Expression fields accept arithmetic/logic over signals — reference a signal as + {'{ds:name}'} and a panel-local variable by its bare name.
    • +
    • The editor has its own undo/redo and copy/paste (see Keyboard Shortcuts).
    • +
    + +

    Node types

    +
    + {[ + { 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 => ( +
    + {w.type} + {w.desc} +
    + ))} +
    + +

    System helpers

    +
      +
    • {'{sys:time}'} — current time in epoch seconds.
    • +
    • {'{sys:dt}'} — seconds since the firing trigger last fired (useful for rates).
    • +
    +
    + ); +} + +function SectionControl() { + return ( +
    +

    + Control logic 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 ⚙ Control logic + button in the view-mode toolbar. +

    + +

    What it can do

    +
      +
    • React to cron schedules and signal alarm/threshold conditions.
    • +
    • Run a Lua block for custom logic and compute writes back to signals.
    • +
    • Each graph can be enabled/disabled independently; changes reload the engine live.
    • +
    + +

    + Editing both panel logic and control logic can be restricted to an allowlist of users or + groups (server.logic_editors 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. +

    +
    + ); +} + +function SectionSharing() { + return ( +
    +

    + 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. +

    + +

    Global access levels

    +
      +
    • Every user is trusted with full write access by default.
    • +
    • A config blacklist can downgrade specific users to read-only or + no access. Read-only users see panels but cannot edit or write signals.
    • +
    • Named groups are defined in the config and used by panel sharing.
    • +
    + +

    Panel ownership & sharing

    +
      +
    • New panels are private to their owner by default.
    • +
    • Use the Share button on a panel (in the interface list) to grant + read or write to specific users/groups, or make it public.
    • +
    • Your global level always caps the per-panel permission (read-only stays read-only).
    • +
    + +

    Folders

    +
      +
    • Organise panels into nested folders in the interface list.
    • +
    • Permissions inherit down the folder chain.
    • +
    • Drag panels to reorder them or move them between folders.
    • +
    +
    + ); +} + function SectionWidgets() { return (
    @@ -602,7 +759,19 @@ function SectionSignals() {

    Synthetic signals

    -

    Click + Synthetic 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.

    +

    Click + Synthetic to compose a new signal from existing ones. A quick + wizard 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 node-graph editor lets you wire + inputs through a chain of DSP blocks visually. The new signal appears in the tree + immediately.

    +

    Each synthetic signal has a visibility scope: panel (only the + panel that created it), user, or global (shared with everyone).

    + +

    Local variables

    +

    The Local 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.

@@ -676,14 +845,15 @@ function SectionShortcuts() {

API & Metrics

    -
  • REST API: /api/v1/datasources, /api/v1/signals, /api/v1/interfaces, …
  • -
  • Prometheus metrics: /metrics
  • -
  • Health check: /healthz
  • -
  • WebSocket: ws://<host>/ws
  • +
  • Signals/data: /api/v1/datasources, /api/v1/signals, /api/v1/synthetic
  • +
  • Panels: /api/v1/interfaces, /api/v1/folders, /api/v1/interfaces/{'{id}'}/acl
  • +
  • Identity & groups: /api/v1/me, /api/v1/usergroups
  • +
  • Control logic: /api/v1/controllogic
  • +
  • Prometheus metrics: /metrics · Health check: /healthz · WebSocket: ws://<host>/ws

Configuration file

-
[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
+
[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
); } @@ -693,7 +863,11 @@ const SECTION_COMPONENTS: Record JSX.Element> = { view: SectionView, edit: SectionEdit, widgets: SectionWidgets, + plots: SectionPlots, signals: SectionSignals, + logic: SectionLogic, + control: SectionControl, + sharing: SectionSharing, history: SectionHistory, shortcuts: SectionShortcuts, }; diff --git a/web/src/InterfaceList.tsx b/web/src/InterfaceList.tsx index 5cb55a6..bc6c7a4 100644 --- a/web/src/InterfaceList.tsx +++ b/web/src/InterfaceList.tsx @@ -21,10 +21,60 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE const [loading, setLoading] = useState(true); const [share, setShare] = useState<{ id: string; name: string } | null>(null); const [expanded, setExpanded] = useState>({}); + const [dropTarget, setDropTarget] = useState(null); const fileInputRef = useRef(null); + const dragId = useRef(null); const me = useAuth(); const writable = canWrite(me.level); + // Panels in a folder ('' = root), sorted by their stored order then name. + function panelsIn(folder: string): InterfaceListItem[] { + return interfaces + .filter(i => (i.folder || '') === folder) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || (a.name || a.id).localeCompare(b.name || b.id)); + } + + // Persist a new ordering for a folder's panels (also moves panels between folders). + async function reorder(folder: string, ids: string[]) { + const res = await fetch('/api/v1/interfaces/reorder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ folder, ids }), + }); + if (res.ok) refresh(); + } + + function endDrag() { + dragId.current = null; + setDropTarget(null); + } + + // Drop dragged panel just before `target` within target's folder. + function dropOnPanel(target: InterfaceListItem, e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + const id = dragId.current; + if (!id || id === target.id) { endDrag(); return; } + const dest = target.folder || ''; + const ids = panelsIn(dest).map(p => p.id).filter(pid => pid !== id); + const idx = ids.indexOf(target.id); + ids.splice(idx < 0 ? ids.length : idx, 0, id); + reorder(dest, ids); + endDrag(); + } + + // Drop dragged panel at the end of `folder` ('' = root). + function dropInFolder(folder: string, e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + const id = dragId.current; + if (!id) { endDrag(); return; } + const ids = panelsIn(folder).map(p => p.id).filter(pid => pid !== id); + ids.push(id); + reorder(folder, ids); + endDrag(); + } + function refresh() { setLoading(true); Promise.all([ @@ -39,6 +89,7 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE version: Number(item.version || item.Version || 0), owner: item.owner ? String(item.owner) : '', folder: item.folder ? String(item.folder) : '', + order: Number(item.order || 0), perm: (item.perm as InterfaceListItem['perm']) || 'write', })).filter((item: InterfaceListItem) => item.id); setInterfaces(normalized); @@ -116,8 +167,18 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE } function renderPanel(item: InterfaceListItem) { + const drag = writable && item.perm === 'write'; return ( -
  • +
  • { dragId.current = item.id; e.dataTransfer!.effectAllowed = 'move'; } : undefined} + onDragEnd={endDrag} + onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.stopPropagation(); setDropTarget(`p:${item.id}`); } }} + onDragLeave={() => setDropTarget(t => t === `p:${item.id}` ? null : t)} + onDrop={(e) => dropOnPanel(item, e)} + > onSelect?.(item.id)} title={item.owner ? `Owner: ${item.owner}` : undefined}> {item.name || item.id} {item.perm === 'read' && ro} @@ -136,11 +197,17 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE // Render a folder and everything beneath it (subfolders first, then panels). function renderFolder(folder: Folder) { const children = folders.filter(f => f.parent === folder.id); - const panels = interfaces.filter(i => i.folder === folder.id); + const panels = panelsIn(folder.id); const open = expanded[folder.id] !== false; // default expanded + const canDrop = folder.perm === 'write'; return (
  • -
    +
    { 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} + > toggle(folder.id)}> {open ? '▾' : '▸'} {folder.name} @@ -162,7 +229,7 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE } const rootFolders = folders.filter(f => !f.parent); - const rootPanels = interfaces.filter(i => !i.folder); + const rootPanels = panelsIn(''); return (