Compare commits
8 Commits
90669c5fd6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 914108e575 | |||
| ba836f00e6 | |||
| afefba3184 | |||
| aba394b84d | |||
| 71430bc3b0 | |||
| 6ff8fb5c25 | |||
| 912ecdd9ed | |||
| 0a5a85e4c4 |
@@ -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 |
|
||||
|
||||
|
||||
+43
-3
@@ -11,12 +11,15 @@ import (
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"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"
|
||||
"github.com/uopi/uopi/internal/datasource/synthetic"
|
||||
"github.com/uopi/uopi/internal/panelacl"
|
||||
"github.com/uopi/uopi/internal/server"
|
||||
"github.com/uopi/uopi/internal/storage"
|
||||
"github.com/uopi/uopi/web"
|
||||
@@ -49,6 +52,12 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
aclStore, err := panelacl.New(cfg.Server.StorageDir)
|
||||
if err != nil {
|
||||
log.Error("failed to open panel ACL store", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
webFS, err := fs.Sub(web.FS, "dist")
|
||||
if err != nil {
|
||||
log.Error("failed to sub web dist", "err", err)
|
||||
@@ -58,7 +67,7 @@ func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
brk := broker.New(ctx, log)
|
||||
brk := broker.New(ctx, log, broker.WithMaxUpdateRate(cfg.Server.MaxUpdateRateHz))
|
||||
|
||||
// Stub data source: built-in simulated signals (sine, ramp, noise, setpoint).
|
||||
// Enabled by default; disable via [datasource.stub] enabled = false in config.
|
||||
@@ -88,7 +97,14 @@ func main() {
|
||||
// The pure-Go implementation is used by default (no CGo required).
|
||||
// Build with -tags epics to use the CGo-based libca implementation instead.
|
||||
if cfg.Datasource.EPICS.Enabled && epics.Available() {
|
||||
ds := epics.New(cfg.Datasource.EPICS.CAAddrList, cfg.Datasource.EPICS.ArchiveURL, cfg.Datasource.EPICS.PVNames)
|
||||
ds := epics.New(
|
||||
cfg.Datasource.EPICS.CAAddrList,
|
||||
cfg.Datasource.EPICS.ArchiveURL,
|
||||
cfg.Datasource.EPICS.ChannelFinderURL,
|
||||
cfg.Datasource.EPICS.AutoSyncFilter,
|
||||
cfg.Datasource.EPICS.AutoSyncFromArchiver,
|
||||
cfg.Datasource.EPICS.PVNames,
|
||||
)
|
||||
if err := ds.Connect(ctx); err != nil {
|
||||
log.Error("epics connect", "err", err)
|
||||
} else {
|
||||
@@ -106,7 +122,31 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, cfg.Datasource.EPICS.ChannelFinderURL, log)
|
||||
// Build the global access policy from config: every user is trusted with
|
||||
// full write access unless downgraded by the blacklist; groups are named
|
||||
// sets of users referenced by per-panel sharing.
|
||||
blacklist := make(map[string]string, len(cfg.Server.Blacklist))
|
||||
for _, e := range cfg.Server.Blacklist {
|
||||
blacklist[e.User] = e.Level
|
||||
}
|
||||
groups := make(map[string][]string, len(cfg.Groups))
|
||||
for _, g := range cfg.Groups {
|
||||
groups[g.Name] = g.Members
|
||||
}
|
||||
policy := access.New(cfg.Server.DefaultUser, blacklist, groups, cfg.Server.LogicEditors)
|
||||
|
||||
// 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)
|
||||
|
||||
+211
-66
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -24,58 +34,107 @@ In the initial version all users share the same access level. Role-based access
|
||||
|
||||
The default mode when opening the application.
|
||||
|
||||
**Interface list pane (left, collapsible)**
|
||||
- Displays all interfaces saved on the server, grouped into a tree by folder.
|
||||
**Interface list pane (left, collapsible, resizable)**
|
||||
- Displays all interfaces saved on the server.
|
||||
- Right-click on an interface: options to open in Edit mode or clone it.
|
||||
- "New interface" button opens Edit mode with a blank canvas.
|
||||
- "Import" option loads an interface from a local XML file.
|
||||
- The pane width can be adjusted by dragging the resize handle on its right edge.
|
||||
|
||||
**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** — floating window 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/historical data for the signal(s) used by the widget.
|
||||
- **Export data to CSV** — downloads buffered data for the signal(s) used by the widget.
|
||||
|
||||
> 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.
|
||||
- Time selector: navigate to a past timestamp (enabled only when the server has archive access for all signals on the canvas).
|
||||
- "Live" button: return to real-time data after historical navigation.
|
||||
- **⏱ 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 and signed-in user chip.
|
||||
|
||||
**Historical time navigation bar** (shown when History is active)
|
||||
- Date/time range pickers (start and end).
|
||||
- **Load** button fetches archive data and replaces live data in all widgets.
|
||||
- **Live** button discards archive data and resumes real-time streaming.
|
||||
- Status shown on plot widgets: "Loading history…", "No archive data for this range", "Archive unavailable".
|
||||
|
||||
### 3.2 Edit Mode
|
||||
|
||||
Activated via the "New interface" button or by right-clicking an existing interface.
|
||||
Activated via the "New interface" button or by clicking Edit in the toolbar.
|
||||
|
||||
**Signal tree pane (left, resizable and collapsible)**
|
||||
- Shows all signals known to each connected data source.
|
||||
- Sources are shown as top-level nodes; signals are nested within.
|
||||
- User can add custom entries:
|
||||
- For EPICS: manually enter a PV name.
|
||||
- For Synthetic: define a new synthetic signal (see §5.2).
|
||||
- User can load a CSV file with columns `NAME, DataSource, DS_PARAMETERS` to import a batch of signals.
|
||||
- For Synthetic: define a new synthetic signal via the Synthetic Wizard (see §5.2).
|
||||
- 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 optional 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.
|
||||
|
||||
**Properties pane (right, collapsible)**
|
||||
**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.
|
||||
- Displays and edits all options for the selected widget (see §4).
|
||||
- Width is adjustable by dragging the resize handle on its left edge.
|
||||
|
||||
**Top toolbar**
|
||||
- Show/hide signal pane.
|
||||
- Show/hide properties pane.
|
||||
- Show/hide signal pane / properties pane.
|
||||
- Undo / Redo (also Ctrl+Z / Ctrl+Shift+Z).
|
||||
- Import / Export interface to/from local XML file.
|
||||
- Save interface to server.
|
||||
- Load interface from server.
|
||||
- Export interface to local XML file.
|
||||
- Import interface from local XML file.
|
||||
- "Add text" tool — inserts a static text label.
|
||||
- "Add image" tool — inserts a static image (uploaded to server or embedded as base64).
|
||||
- "Add link" tool — inserts a button that opens another interface.
|
||||
- Close (return to View mode).
|
||||
- Zoom control (A− / % / A+).
|
||||
|
||||
### 3.3 Plot Panels (Split 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.
|
||||
|
||||
**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.
|
||||
|
||||
**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-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
|
||||
|
||||
The toolbar in both modes contains a zoom control (**A−** / **nn%** / **A+**) that adjusts the base font size of the entire UI:
|
||||
|
||||
- 11 zoom steps from 50% to 250% (50, 60, 75, 85, 100, 115, 130, 150, 175, 200, 250%).
|
||||
- The selected zoom level is persisted in `localStorage` and restored on the next page load.
|
||||
- At 100%, the base font size adapts to viewport height via `clamp(13px, 1.5vh, 18px)`, making the UI naturally usable on 4K displays without any manual adjustment.
|
||||
|
||||
---
|
||||
|
||||
@@ -83,7 +142,7 @@ Activated via the "New interface" button or by right-clicking an existing interf
|
||||
|
||||
### 4.1 Creating Widgets
|
||||
|
||||
Drag a signal from the signal tree and drop it onto the canvas. A picker appears showing all widget types compatible with the signal's data type (iconised). The user selects one and the widget is placed at the drop location with default size.
|
||||
Drag a signal from the signal tree and drop it onto the canvas. A picker appears showing all widget types compatible with the signal's data type. The user selects one and the widget is placed at the drop location with default size.
|
||||
|
||||
### 4.2 Selecting Widgets
|
||||
|
||||
@@ -104,8 +163,7 @@ When multiple widgets are selected:
|
||||
- An align/distribute toolbar appears above the canvas with:
|
||||
- Align left / center horizontal / right.
|
||||
- Align top / center vertical / bottom.
|
||||
- Distribute evenly — by center spacing (horizontal/vertical).
|
||||
- Distribute evenly — by gap size (horizontal/vertical).
|
||||
- Distribute evenly (horizontal/vertical).
|
||||
|
||||
### 4.4 Widget Catalogue
|
||||
|
||||
@@ -115,7 +173,7 @@ When multiple widgets are selected:
|
||||
| Gauge | numeric scalar | Circular or arc gauge with configurable range |
|
||||
| Vertical bar | numeric scalar | Vertical level indicator |
|
||||
| Horizontal bar | numeric scalar | Horizontal level indicator |
|
||||
| Set value | numeric or string, writable | Shows `name: [input field] current_value unit` + Set button |
|
||||
| Set value | numeric, string, or enum; writable | Input field or enum dropdown + Set button |
|
||||
| LED | boolean / numeric | Coloured indicator with configurable condition and label |
|
||||
| Multi-LED | integer (bitset) | One LED per bit with individual labels and conditions |
|
||||
| Button | writable | Sends a fixed value or command on click |
|
||||
@@ -140,67 +198,153 @@ When multiple widgets are selected:
|
||||
Common to all:
|
||||
- Label text, font size, text colour.
|
||||
- Position (X, Y) and size (W, H) — editable numerically.
|
||||
- Data source and signal name (read-only after creation; reassignable via drag).
|
||||
- Data source and signal name.
|
||||
|
||||
Per type:
|
||||
- **Gauge / Bar**: min value, max value, alert thresholds with colours, unit label.
|
||||
- **LED / Multi-LED**: condition expression (e.g. `value > 0`), colours for true/false states.
|
||||
- **Plot**: plot sub-type selector, Y-axis range (auto or manual), time window duration, legend position, colour per signal, line style.
|
||||
- **Set value**: input type (numeric / string / enum), confirmation prompt toggle.
|
||||
- **LED / Multi-LED**: condition expression (e.g. `value > 0`), colours for true/false states, label.
|
||||
- **Plot**: plot sub-type selector, Y-axis range (auto or manual min/max), time window duration, legend position (top / bottom / none), value format string.
|
||||
- **Set value**: no special options — enum mode is detected automatically from signal metadata.
|
||||
- **Link**: target interface name.
|
||||
|
||||
### 4.6 Set-value Widget — Enum Mode
|
||||
|
||||
When the signal's metadata reports enum strings (e.g. EPICS mbbi/mbbo records):
|
||||
- The input field is replaced by a `<select>` dropdown showing all enum options.
|
||||
- The current live value is shown as the pre-selected option (display only).
|
||||
- The user selects a value from the dropdown, then clicks **Set** to write it.
|
||||
- The Set button is always required; changing the dropdown does not write immediately.
|
||||
|
||||
---
|
||||
|
||||
## 5. Data Sources
|
||||
|
||||
### 5.1 EPICS
|
||||
|
||||
- Connects to an EPICS environment via Channel Access (CA) or PVAccess (PVA).
|
||||
- On connect, retrieves full metadata from the PV name: data type, engineering units, display range (DRVL/DRVH, LOPR/HOPR), alarm limits, enum strings (for mbbi/mbbo records), read/write mode, acquisition mode.
|
||||
- Prefers monitor/subscription over polling. Falls back to polling only when monitors are unavailable.
|
||||
- Attempts to enumerate available PV names from the IOC (e.g. via PV lists or Channel Finder). Unknown PVs can still be manually added by the user.
|
||||
- When an EPICS Archive Appliance or Channel Archiver is configured, the server can satisfy historical data requests.
|
||||
- Connects to an EPICS environment via Channel Access (CA).
|
||||
- On connect, retrieves full metadata from the PV name: data type, engineering units, display range, alarm limits, enum strings (for mbbi/mbbo records), read/write mode.
|
||||
- Uses `ca_add_event` monitors — no polling.
|
||||
- When an EPICS Archive Appliance is configured, the server can satisfy historical data requests from the toolbar time navigator.
|
||||
|
||||
### 5.2 Synthetic
|
||||
### 5.2 Synthetic Signals
|
||||
|
||||
A signal defined by composing one or more input signals (from any data source) through a chain of processing functions.
|
||||
A signal defined by composing one or more input signals through a chain of processing nodes.
|
||||
|
||||
**Built-in processing functions (non-exhaustive):**
|
||||
- Arithmetic: gain, offset, add, subtract, multiply, divide.
|
||||
- Signal processing: moving average (N samples or time window), RMS, bandpass filter (IIR/FIR), lowpass / highpass filter, derivative, integral.
|
||||
- FFT, inverse FFT.
|
||||
- Peak detection, threshold crossing.
|
||||
- Custom formula: inline expression (`a * sin(b) + c`).
|
||||
- Lua script block: arbitrary Lua code with access to input values and state.
|
||||
**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.
|
||||
|
||||
**User workflow:**
|
||||
1. Click "New synthetic signal" in the signal tree.
|
||||
2. Name the signal and choose input signals.
|
||||
3. Build a processing pipeline by chaining function blocks (UI similar to a node graph or an ordered list).
|
||||
4. Optionally write a Lua snippet for custom logic.
|
||||
5. The synthetic signal appears in the tree and can be used like any other signal.
|
||||
**Visibility scope:** each synthetic signal is scoped as *panel* (visible only to the panel
|
||||
that created it), *user*, or *global* (shared with everyone).
|
||||
|
||||
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:**
|
||||
|
||||
| Node | Parameters | Description |
|
||||
|------|------------|-------------|
|
||||
| Source | DS, signal name | Input from any data source |
|
||||
| Gain | factor | Multiply by constant |
|
||||
| Offset | value | Add constant |
|
||||
| Moving average | window (samples) | Rolling mean |
|
||||
| Low-pass filter | frequency (Hz), order (1–8) | IIR low-pass; correct for non-uniform sample rates |
|
||||
| Formula | expression | Inline math (variables: `a`, `b`, …) |
|
||||
| Lua script | script | Arbitrary Lua 5.1 with persistent state table |
|
||||
|
||||
**Lua editor:** the Lua node parameter uses a code editor with syntax highlighting (keywords, strings, comments, numbers rendered in distinct colours). The editor is a full-height multi-line input that grows with the dialog.
|
||||
|
||||
---
|
||||
|
||||
## 6. Interface Persistence
|
||||
## 6. Panel Logic
|
||||
|
||||
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. 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: widget type, position, size, signal bindings, and all property values.
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
## 7. Historical Data Navigation
|
||||
## 9. Access Control, Sharing & Folders
|
||||
|
||||
When the server has archive access for all signals on the current canvas:
|
||||
- The top toolbar shows a date/time picker.
|
||||
- Selecting a past time replays data from the archive into all widgets.
|
||||
- The "Live" button resumes real-time streaming.
|
||||
- Widgets that support time-axis (plots) show the historical range; point-value widgets show the value at the selected time.
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
## 8. Non-functional Requirements
|
||||
## 10. Historical Data Navigation
|
||||
|
||||
When the server has archive access configured:
|
||||
- The **⏱ History** button in the View mode toolbar reveals a time range bar.
|
||||
- Users set a start and end date/time and click **Load**.
|
||||
- Plot widgets fetch and display the archived data for the selected range.
|
||||
- Point-value widgets (text view, gauge, bar, LED) show the value at the end of the selected range.
|
||||
- The **Live** button resumes real-time streaming.
|
||||
- Status overlays on plot widgets indicate loading, empty, or error states.
|
||||
|
||||
---
|
||||
|
||||
## 11. Non-functional Requirements
|
||||
|
||||
| Requirement | Target |
|
||||
|-------------|--------|
|
||||
@@ -210,4 +354,5 @@ When the server has archive access for all signals on the current canvas:
|
||||
| Concurrent clients | ≥ 20 simultaneous browser clients |
|
||||
| Data fan-out latency | < 5 ms added latency vs. raw EPICS update rate |
|
||||
| Frontend responsiveness | 60 fps canvas rendering during live updates |
|
||||
| Screen DPI | Frontend adapts to device pixel ratio |
|
||||
| Screen DPI | UI scales with viewport height; manual zoom 50–250% |
|
||||
| Plot buffer | 200,000 samples per signal retained in-browser |
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Guide: Configuring uopi for ITER CS-Studio
|
||||
|
||||
This guide explains how to configure **uopi** to work within the **ITER CODAC (Control System Studio / Phoebus)** environment. Since uopi is a single portable binary, it can be deployed alongside or as a lightweight web-based alternative to standard CS-Studio screens.
|
||||
|
||||
---
|
||||
|
||||
## 1. Prerequisite Information
|
||||
|
||||
To connect uopi to the ITER control system, you need three pieces of information usually found in your standard CODAC environment or terminal:
|
||||
|
||||
| Info Needed | Purpose | Where to find it in ITER |
|
||||
| :--- | :--- | :--- |
|
||||
| **CA Addr List** | PV Discovery via UDP | Run `echo $EPICS_CA_ADDR_LIST` in a terminal. |
|
||||
| **Archiver URL** | Historical Trends | Check your CS-Studio preferences under *Archive Appliance* (usually `http://arch-mgmt:17665/`). |
|
||||
| **CFS URL** | Automatic PV Search | Check CS-Studio preferences under *Channel Finder* (usually `http://cf-service:8080/ChannelFinder`). |
|
||||
|
||||
---
|
||||
|
||||
## 2. Configuration (`uopi.toml`)
|
||||
|
||||
Create a file named `uopi.toml` in the directory where you will run uopi. Use the values found above:
|
||||
|
||||
```toml
|
||||
[server]
|
||||
listen = ":8080" # The port uopi will serve the web HMI on
|
||||
storage_dir = "./interfaces" # Where your XML screens will be saved
|
||||
|
||||
[datasource.epics]
|
||||
enabled = true
|
||||
# Paste the output of 'echo $EPICS_CA_ADDR_LIST' here
|
||||
ca_addr_list = "10.0.x.x 10.0.x.255"
|
||||
# ITER Archive Appliance URL
|
||||
archive_url = "http://arch-mgmt:17665/"
|
||||
# ITER Channel Finder URL
|
||||
channel_finder_url = "http://cf-service:8080/ChannelFinder"
|
||||
|
||||
# Automatic discovery on startup
|
||||
auto_sync_from_archiver = true
|
||||
auto_sync_filter = "tags=production"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Deployment in the ITER Environment
|
||||
|
||||
### A. Running via Terminal
|
||||
If you are on a CODAC workstation or a server with network access to the Plant System:
|
||||
1. Copy the `uopi` binary to the machine.
|
||||
2. Run it pointing to your config:
|
||||
```bash
|
||||
./uopi -config uopi.toml
|
||||
```
|
||||
3. Open a browser and navigate to `http://<machine-ip>:8080`.
|
||||
|
||||
### B. Offline Usage (No Internet)
|
||||
uopi is built with **zero external dependencies**. All scripts and the favicon are embedded. No `npm` or `curl` calls are made at runtime. It will function perfectly on isolated ITER networks.
|
||||
|
||||
---
|
||||
|
||||
## 4. Discovering ITER PVs
|
||||
|
||||
Once uopi is running:
|
||||
|
||||
1. **Automatic Discovery:** If `auto_sync_from_archiver` is set to `true`, the sidebar will be pre-populated with thousands of ITER PVs on boot.
|
||||
2. **Smart Grouping:** Click the **"Group By"** dropdown in the sidebar. Select **"Area"** or **"System"**. uopi will use the metadata from the ITER Channel Finder to organize PVs into folders (e.g., `Vacuum`, `Cryogenics`, `PowerSupply`).
|
||||
3. **Advanced Search:**
|
||||
* Click the **🔍 (Advanced Search)** icon in the sidebar.
|
||||
* Use the **Channel Finder** tab to find signals by tags (e.g., `interlock`) or properties (e.g., `iocName`).
|
||||
* Use the **Archive Appliance** tab to perform a glob search (e.g., `*TEMP*`) directly against the ITER Archiver database.
|
||||
|
||||
---
|
||||
|
||||
## 5. Transitioning from CS-Studio (`.bob` / `.opi`)
|
||||
|
||||
uopi uses a custom XML format for its screens, but the layout logic is similar to Phoebus/CS-Studio.
|
||||
|
||||
* **Signals:** Just like in CS-Studio, you can drag PVs from the sidebar directly onto the canvas to create widgets.
|
||||
* **Historical Data:** Any PV discovered via the Archiver tab or configured with the Archiver URL will automatically show historical data when added to a **Plot Widget**.
|
||||
* **Synthetic Signals:** You can create complex ITER-specific calculations (e.g., `R = V/I`) by clicking the **Σ** icon and combining multiple EPICS PVs into a single virtual signal.
|
||||
|
||||
---
|
||||
|
||||
## 6. Troubleshooting
|
||||
|
||||
* **Timeout Error:** If `caget` times out in uopi, verify that `ca_addr_list` includes the correct broadcast address for your ITER subnet (e.g., ends in `.255`).
|
||||
* **Metadata Missing:** If PVs show up but have no units/descriptions, ensure the `channel_finder_url` is reachable from the uopi binary.
|
||||
+253
-104
@@ -20,31 +20,31 @@
|
||||
| Package | Purpose |
|
||||
| ---------------------------- | ------------------------------------------------------ |
|
||||
| `nhooyr.io/websocket` | WebSocket server (no CGo, more ergonomic than gorilla) |
|
||||
| `go-epics/ca` or CGo wrapper | EPICS Channel Access |
|
||||
| CGo wrapper to `libca` | EPICS Channel Access |
|
||||
| `yuin/gopher-lua` | Lua 5.1 runtime for synthetic signals |
|
||||
| `gonum.org/v1/gonum` | DSP and math functions (FFT, filters) |
|
||||
| `BurntSushi/toml` | TOML config parsing |
|
||||
| `encoding/xml` (stdlib) | Interface file serialisation |
|
||||
| `net/http` (stdlib) | HTTP server and static file serving |
|
||||
|
||||
### 1.2 Frontend — Svelte + TypeScript
|
||||
### 1.2 Frontend — Preact + TypeScript
|
||||
|
||||
**Rationale:**
|
||||
|
||||
- Svelte compiles to vanilla JS with no virtual DOM, giving the smallest bundle and lowest runtime overhead — essential for the 60 fps reactive feel required.
|
||||
- Fine-grained reactivity via Svelte stores keeps widget rendering decoupled from data arrival.
|
||||
- Preact is a 3 kB React-compatible virtual DOM library — small bundle, fast diffing, no extra framework overhead.
|
||||
- esbuild (invoked via its Go API) bundles the TypeScript/TSX source in milliseconds with no Node.js or npm dependency at build time.
|
||||
- TypeScript catches signal subscription and widget property type errors at build time.
|
||||
- All vendor JS/CSS is checked into `web/vendor/` so the repo builds without internet access.
|
||||
|
||||
**Key dependencies:**
|
||||
**Key dependencies (vendored):**
|
||||
|
||||
| Package | Purpose |
|
||||
| ----------------- | ------------------------------------------------------------------------------ |
|
||||
| `svelte` + `vite` | Framework and build toolchain |
|
||||
| `uPlot` | Extremely fast time-series/line plot (canvas-based, < 40 kB) |
|
||||
| `Apache ECharts` | FFT, waterfall, histogram, bar, logic analyser plots |
|
||||
| `konva` | 2-D canvas scene graph for the edit-mode widget canvas (handles, drag, resize) |
|
||||
| `svelte-konva` | Svelte bindings for Konva |
|
||||
| Package | Purpose |
|
||||
| ---------------- | --------------------------------------------------------------- |
|
||||
| `preact` 10 | Virtual DOM UI framework |
|
||||
| `uPlot` | Extremely fast time-series/line plot (canvas-based, < 40 kB) |
|
||||
| `Apache ECharts` | FFT, waterfall, histogram, bar, logic analyser plots |
|
||||
| `uplot.css` | uPlot default stylesheet |
|
||||
|
||||
**Intentionally excluded:** React, Vue, WebGPU, jQuery.
|
||||
**Intentionally excluded:** React, Vue, Svelte, Konva, WebGPU, jQuery, npm at runtime.
|
||||
|
||||
---
|
||||
|
||||
@@ -52,36 +52,49 @@
|
||||
|
||||
```
|
||||
uopi/
|
||||
├── cmd/uopi/ # main package — CLI flags, wiring
|
||||
├── cmd/uopi/ # main package — CLI flags, wiring
|
||||
├── internal/
|
||||
│ ├── server/ # HTTP + WebSocket handlers
|
||||
│ ├── broker/ # signal fan-out to clients
|
||||
│ ├── server/ # HTTP + WebSocket handlers
|
||||
│ ├── broker/ # signal fan-out to clients
|
||||
│ ├── datasource/
|
||||
│ │ ├── iface.go # DataSource interface
|
||||
│ │ ├── epics/ # EPICS CA/PVA implementation
|
||||
│ │ └── synthetic/ # synthetic signal engine
|
||||
│ ├── lua/ # Lua sandbox helpers
|
||||
│ ├── dsp/ # DSP functions (wraps gonum + custom)
|
||||
│ ├── storage/ # interface XML read/write
|
||||
│ └── api/ # REST handler functions
|
||||
├── web/ # Svelte source
|
||||
│ ├── src/
|
||||
│ │ ├── iface.go # DataSource interface
|
||||
│ │ ├── epics/ # EPICS CA implementation (CGo)
|
||||
│ │ └── synthetic/ # synthetic signal engine + DSP bridge
|
||||
│ ├── dsp/ # DSP node implementations (lowpass, MA, etc.)
|
||||
│ ├── storage/ # interface XML read/write
|
||||
│ └── api/ # REST handler functions
|
||||
├── web/
|
||||
│ ├── embed.go # //go:embed dist — exports FS to Go
|
||||
│ ├── src/ # TypeScript/TSX source (Preact)
|
||||
│ │ ├── lib/
|
||||
│ │ │ ├── ws.ts # WebSocket client + subscription manager
|
||||
│ │ │ ├── stores.ts # Svelte stores for signal values
|
||||
│ │ │ ├── widgets/ # one .svelte file per widget type
|
||||
│ │ │ └── editor/ # edit-mode canvas, toolbar, properties pane
|
||||
│ │ ├── routes/
|
||||
│ │ │ ├── +page.svelte # view mode
|
||||
│ │ │ └── edit/+page.svelte # edit mode
|
||||
│ │ └── app.html
|
||||
│ ├── package.json
|
||||
│ └── vite.config.ts
|
||||
├── docs/ # specs, work plan
|
||||
│ │ │ ├── stores.ts # signal value + metadata stores
|
||||
│ │ │ ├── types.ts # shared TypeScript interfaces
|
||||
│ │ │ ├── xml.ts # interface XML parse/serialize
|
||||
│ │ │ └── format.ts # value formatting helpers
|
||||
│ │ ├── widgets/ # one .tsx file per widget type
|
||||
│ │ ├── App.tsx # top-level component, mode routing
|
||||
│ │ ├── ViewMode.tsx # view mode layout + tabs
|
||||
│ │ ├── EditMode.tsx # edit mode layout + toolbar
|
||||
│ │ ├── Canvas.tsx # live HMI canvas (view mode)
|
||||
│ │ ├── EditCanvas.tsx # free-form widget editor canvas
|
||||
│ │ ├── PlotPanel.tsx # live plot side-panel (Plot tab)
|
||||
│ │ ├── InfoPanel.tsx # signal info side-panel
|
||||
│ │ ├── ZoomControl.tsx # UI zoom A-/A+ control
|
||||
│ │ ├── SyntheticWizard.tsx # new synthetic signal dialog
|
||||
│ │ ├── SyntheticEditor.tsx # edit existing synthetic signal
|
||||
│ │ ├── LuaEditor.tsx # Lua code editor with syntax highlight
|
||||
│ │ └── styles.css # all component styles
|
||||
│ ├── vendor/ # vendored JS/CSS (preact, uplot, echarts)
|
||||
│ └── dist/ # built frontend — generated, not committed
|
||||
├── tools/buildfrontend/ # esbuild Go API bundler (go generate)
|
||||
├── docs/ # specs, work plan
|
||||
├── CLAUDE.md
|
||||
└── README.md
|
||||
```
|
||||
|
||||
The embed package lives at `web/embed.go` (not in `cmd/`) because `//go:embed` paths cannot use `..`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Backend Architecture
|
||||
@@ -173,22 +186,40 @@ Framing: JSON messages over a single persistent WebSocket connection per client.
|
||||
|
||||
Base path: `/api/v1`
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ------------------------- | -------------------------------------------- |
|
||||
| GET | `/datasources` | List connected data sources and their status |
|
||||
| GET | `/signals?ds=epics` | List signals for a data source |
|
||||
| GET | `/signals/:ds/:name/meta` | Get full metadata for a signal |
|
||||
| GET | `/interfaces` | List saved interfaces |
|
||||
| POST | `/interfaces` | Create a new interface (body: XML) |
|
||||
| GET | `/interfaces/:id` | Download interface XML |
|
||||
| PUT | `/interfaces/:id` | Update interface XML |
|
||||
| DELETE | `/interfaces/:id` | Delete interface |
|
||||
| POST | `/interfaces/:id/clone` | Clone an interface |
|
||||
| Method | Path | Description |
|
||||
| --------------- | ------------------------------------- | ------------------------------------------------- |
|
||||
| GET | `/me` | Caller identity, global level, groups, `canEditLogic` |
|
||||
| GET | `/datasources` | List connected data sources and their status |
|
||||
| GET | `/signals?ds=epics` | List signals for a data source |
|
||||
| GET | `/signals/search?q=` | Search signals across all sources |
|
||||
| GET | `/channel-finder?q=` | Proxy to EPICS Channel Finder |
|
||||
| GET | `/archiver/search?q=` | Search the EPICS archiver for PV names |
|
||||
| GET, POST | `/interfaces` | List saved interfaces / create one (body: XML) |
|
||||
| POST | `/interfaces/reorder` | Reorder panels / move between folders |
|
||||
| GET, PUT, DELETE| `/interfaces/{id}` | Download, update, or delete an interface |
|
||||
| POST | `/interfaces/{id}/clone` | Clone an interface |
|
||||
| GET | `/interfaces/{id}/versions` | List saved versions; `…/{version}` to fetch one |
|
||||
| PUT | `/interfaces/{id}/versions/{v}/tag` | Tag a version |
|
||||
| POST | `/interfaces/{id}/versions/{v}/promote` | Promote a version to current |
|
||||
| POST | `/interfaces/{id}/versions/{v}/fork` | Fork a version into a new panel |
|
||||
| GET, PUT | `/interfaces/{id}/acl` | Read or set a panel's sharing rules |
|
||||
| GET, POST | `/folders` | List or create panel folders |
|
||||
| PUT, DELETE | `/folders/{id}` | Rename/reparent or delete a folder |
|
||||
| GET | `/usergroups` | List configured users and groups (for sharing) |
|
||||
| GET, PUT | `/groups` | Read or set group definitions |
|
||||
| GET, POST | `/synthetic` | List or create synthetic signal definitions |
|
||||
| GET, PUT, DELETE| `/synthetic/{name}` | Read, update, or delete a synthetic definition |
|
||||
| GET, POST | `/controllogic` | List or create server-side control-logic graphs |
|
||||
| GET, PUT, DELETE| `/controllogic/{id}` | Read, update, or delete a control-logic graph |
|
||||
|
||||
Mutating requests are gated by the access middleware (§8): global level for writes,
|
||||
per-panel ACL for interface endpoints, and the logic-editor allowlist for control-logic
|
||||
endpoints and for any change to a panel's `<logic>` block.
|
||||
|
||||
### 3.5 EPICS Data Source
|
||||
|
||||
- Uses CGo bindings to EPICS Base `libca` (Channel Access). PVAccess support via `p4p` C library or a pure-Go PVA client if available.
|
||||
- Channel connections are lazy: a channel is connected on first Subscribe and disconnected when the broker releases it.
|
||||
- Uses CGo bindings to EPICS Base `libca` (Channel Access).
|
||||
- Channel connections are lazy: connected on first Subscribe, disconnected when the broker releases it.
|
||||
- On connect, a `ca_get` retrieves full DBR_CTRL metadata (units, limits, enum strings).
|
||||
- `ca_add_event` sets up the monitor. Update callbacks push into the broker's raw channel.
|
||||
- Multiple PV subscriptions share one CA context per data source instance (thread-safe with `ca_attach_context`).
|
||||
@@ -198,13 +229,36 @@ Base path: `/api/v1`
|
||||
|
||||
- Each synthetic signal is defined as a directed acyclic graph (DAG) of processing nodes.
|
||||
- Processing nodes are re-evaluated whenever any upstream signal emits a new value.
|
||||
- Built-in node types implemented on top of `gonum/dsp` and custom code.
|
||||
- Lua nodes receive a sandboxed `lua.LState` with access to input values and a persistent state table.
|
||||
- Synthetic signal definitions are stored as part of the server configuration (JSON/TOML file), distinct from interface XML files.
|
||||
- Definitions are stored in a configurable JSON/TOML file alongside server configuration.
|
||||
- The `dsp_bridge.go` file maps node type names to `dsp.Node` implementations.
|
||||
|
||||
**Built-in node types:**
|
||||
|
||||
| Node type | Parameters | Description |
|
||||
| ------------ | ----------------------------------- | ------------------------------------------------ |
|
||||
| `source` | `ds`, `name` | Reads a signal from any data source |
|
||||
| `gain` | `factor` | Multiplies by a constant |
|
||||
| `offset` | `value` | Adds a constant |
|
||||
| `moving_avg` | `window` (samples) | Rolling mean |
|
||||
| `lowpass` | `freq` (Hz), `order` (1–8) | Cascaded IIR Butterworth-style low-pass filter |
|
||||
| `formula` | `expr` | Inline math expression (variables: `a`, `b`, …) |
|
||||
| `lua` | `script` | Arbitrary Lua 5.1 code with persistent state |
|
||||
|
||||
**Low-pass filter implementation:** Cascaded first-order IIR sections. Each stage computes `y = y_prev + α·(x − y_prev)` where `α = dt / (RC + dt)` and `RC = 1/(2π·fc)`. `dt` is computed per sample from source timestamps so the filter is correct for event-driven (non-uniform) data.
|
||||
|
||||
**Lua node:** Receives `inputs` table (indexed by signal name) and a persistent `state` table across calls. The `os`, `io`, `package`, and `debug` libraries are disabled.
|
||||
|
||||
### 3.7 Interface Storage
|
||||
|
||||
Interfaces are stored as XML files in a configurable directory on the server.
|
||||
Interfaces are stored as XML files in a configurable directory on the server. The
|
||||
`<interface>` element carries an optional `kind` attribute (`panel` default, or `plot`);
|
||||
plot panels add a nested `<layout>` split tree referencing plot widgets by id. Beyond
|
||||
widgets, an interface may also contain panel-local variables and a `<logic>` flow graph
|
||||
(`<node>` + `<param>` + `<wire>`), all round-tripping through the same XML.
|
||||
|
||||
Per-panel access rules and folder placement are kept in a sidecar `acl.json` in the same
|
||||
directory (not in the interface XML), and prior versions of each panel are retained for the
|
||||
version history endpoints.
|
||||
|
||||
```xml
|
||||
<interface name="My Panel" version="1" created="2026-04-24T12:00:00Z">
|
||||
@@ -215,6 +269,7 @@ Interfaces are stored as XML files in a configurable directory on the server.
|
||||
<option key="yMin" value="auto"/>
|
||||
<option key="yMax" value="auto"/>
|
||||
<option key="timeWindow" value="60"/>
|
||||
<option key="legend" value="bottom"/>
|
||||
</widget>
|
||||
<widget id="w2" type="led" x="50" y="50" w="80" h="80">
|
||||
<signal ds="epics" name="EPICS:STATUS"/>
|
||||
@@ -226,51 +281,117 @@ Interfaces are stored as XML files in a configurable directory on the server.
|
||||
</interface>
|
||||
```
|
||||
|
||||
### 3.8 Panel Logic Engine
|
||||
|
||||
Panel logic is a client-side flow engine (`web/src/lib/logic.ts`, singleton `logicEngine`).
|
||||
The graph is built/edited in the panel editor's Logic tab (`LogicEditor.tsx`) and serialized
|
||||
into the interface XML. In View mode, `Canvas` calls `logicEngine.load(iface.logic)` on
|
||||
mount and `clear()` on unmount. The engine subscribes to every referenced signal into a live
|
||||
cache, then on each trigger activation walks the wired graph (gates, if/loop, actions),
|
||||
evaluating expression fields via a small safe recursive-descent evaluator (no `eval`).
|
||||
Triggers include button/threshold/change/timer/loop and On-open/On-close lifecycle; actions
|
||||
include signal writes, delay, log, in-memory data arrays (accumulate/export-CSV/clear) and
|
||||
user dialogs (info/error/set-point). The engine runs entirely in the browser — no backend
|
||||
component.
|
||||
|
||||
### 3.9 Control Logic Engine
|
||||
|
||||
Control logic is server-side (`internal/controllogic`): always-on flow graphs that run under
|
||||
the root context, independent of any client. The engine subscribes to the broker, fires on
|
||||
cron schedules and signal alarm/threshold conditions, executes a Lua block for custom logic,
|
||||
and writes results back to signals. Graphs are persisted by a store and managed via
|
||||
`/api/v1/controllogic`; each mutation calls `Engine.Reload()` to apply changes live. Graphs
|
||||
can be individually enabled/disabled.
|
||||
|
||||
### 3.10 Access Control
|
||||
|
||||
Identity and global policy live in `internal/access` (`Policy`, `Level`). The user identity
|
||||
is read per request from `server.trusted_user_header` (with a `default_user` fallback) and
|
||||
stored on the request context. `accessMiddleware` gates mutating HTTP methods by the user's
|
||||
global level. Per-panel ownership and ACL evaluation (with folder inheritance) live in
|
||||
`internal/panelacl`, backed by the `acl.json` sidecar. `Policy.CanEditLogic(user)` enforces
|
||||
the optional `server.logic_editors` allowlist over control-logic endpoints and over any
|
||||
change to a panel's `<logic>` block; it is surfaced to the frontend through `/api/v1/me`
|
||||
(`canEditLogic`) so the UI can hide logic-editing affordances.
|
||||
|
||||
---
|
||||
|
||||
## 4. Frontend Architecture
|
||||
|
||||
### 4.1 WebSocket Client (`ws.ts`)
|
||||
### 4.1 WebSocket Client (`lib/ws.ts`)
|
||||
|
||||
- Singleton WebSocket connection, reconnects with exponential back-off.
|
||||
- Subscription reference counting: multiple widgets subscribing to the same signal result in one server subscription message.
|
||||
- Incoming updates are dispatched to signal stores.
|
||||
- Incoming updates are dispatched to per-signal stores.
|
||||
- `wsClient.history(sig, start, end, maxPoints)` returns a Promise resolving to timestamped point arrays.
|
||||
|
||||
### 4.2 Signal Stores (`stores.ts`)
|
||||
### 4.2 Signal Stores (`lib/stores.ts`)
|
||||
|
||||
```typescript
|
||||
// One writable store per subscribed signal
|
||||
const signalStores = new Map<string, Writable<SignalValue>>();
|
||||
// One nanostores atom per subscribed signal
|
||||
const signalStores = new Map<string, SignalStore>();
|
||||
|
||||
function getStore(signal: string): Readable<SignalValue> { ... }
|
||||
export function getSignalStore(ref: SignalRef): SignalStore { ... }
|
||||
export function getMetaStore(ref: SignalRef): MetaStore { ... }
|
||||
```
|
||||
|
||||
Widgets import `getStore(signalName)` and bind to it reactively. Svelte's fine-grained reactivity ensures only the relevant widgets re-render on each update.
|
||||
Widgets subscribe to stores directly; store updates trigger re-renders only in the consuming component.
|
||||
|
||||
### 4.3 Edit Mode Canvas
|
||||
### 4.3 Edit Mode Canvas (`EditCanvas.tsx`)
|
||||
|
||||
The edit-mode canvas is implemented with **Konva.js** via `svelte-konva`:
|
||||
The edit canvas is a free-form HTML div with absolutely positioned widget components:
|
||||
|
||||
- Each widget is a Konva Group containing its visual elements.
|
||||
- A `Transformer` node provides resize handles and enforces minimum sizes.
|
||||
- Drag-and-drop from the signal tree uses the HTML Drag-and-Drop API; on drop, the canvas coordinate is computed from `stage.getPointerPosition()`.
|
||||
- Undo/redo uses a command pattern: each mutating operation pushes an inverse operation onto a stack (max depth 100).
|
||||
- Align/distribute operations compute target positions geometrically and generate a single grouped undo entry.
|
||||
- Each widget renders as an absolutely positioned `<div>` at `(x, y)` with `(w, h)` dimensions.
|
||||
- Selection shows a CSS-outlined bounding box with 8 resize handles rendered as small squares.
|
||||
- Drag-and-drop from the signal tree uses the HTML Drag-and-Drop API; on drop, the canvas coordinate is computed from the drop event offset.
|
||||
- Undo/redo uses an array of past interface snapshots (max depth 50).
|
||||
- Align/distribute operations compute target positions geometrically and generate a single undo entry.
|
||||
- Multi-select via Ctrl+click or rubber-band area select.
|
||||
|
||||
### 4.4 Widget Rendering in View Mode
|
||||
### 4.4 Widget Rendering in View Mode (`Canvas.tsx`)
|
||||
|
||||
In view mode the Konva canvas is replaced with a lightweight SVG/HTML layer. Each widget is a Svelte component that:
|
||||
View mode renders widgets as absolutely positioned Preact components on a scrollable canvas div:
|
||||
|
||||
1. Subscribes to its signal store(s) in `onMount`.
|
||||
2. Receives reactive updates and re-renders only its own DOM subtree.
|
||||
- Each widget subscribes to its signal store(s) in a `useEffect` and re-renders only when values change.
|
||||
- uPlot (time series) and ECharts (histogram, bar, FFT, waterfall, logic analyser) manage their own canvas elements inside their widget component.
|
||||
- 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.
|
||||
|
||||
Plot widgets (`uPlot` for time series, `ECharts` for others) manage their own canvas elements inside the Svelte component.
|
||||
### 4.5 Plot Panels (`SplitLayout.tsx`, `PlotPanelCanvas.tsx`)
|
||||
|
||||
### 4.5 DPI Adaptation
|
||||
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):
|
||||
|
||||
- CSS uses `rem` units throughout for text.
|
||||
- Canvas elements read `window.devicePixelRatio` and set `canvas.width` / `canvas.height` accordingly while keeping CSS size fixed.
|
||||
- Konva's `Stage` is scaled by `devicePixelRatio` on init and on `resize`.
|
||||
- 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
|
||||
|
||||
Both edit and view modes support mouse-drag panel resizing:
|
||||
|
||||
- **View mode**: drag handle between the interface list pane and the main content area.
|
||||
- **Edit mode**: drag handles on both sides of the central canvas (signal tree ↔ canvas, canvas ↔ properties pane).
|
||||
- Handle width: 5 px, cursor changes to `ew-resize` on hover.
|
||||
- Minimum panel widths enforced to prevent collapse below usable size.
|
||||
|
||||
### 4.7 HiDPI / Zoom Support
|
||||
|
||||
- `html { font-size: clamp(13px, 1.5vh, 18px); }` — base font scales with viewport height, making the UI naturally larger on 4K screens where the browser zoom level is 100%.
|
||||
- Key structural heights (toolbar, panel headers, tab bar, plot toolbar) are expressed in `rem` so they scale with the base font.
|
||||
- **ZoomControl** (A− / % / A+) in the toolbar lets users manually override the zoom level in 11 steps from 50% to 250%. The preference is persisted in `localStorage` (`uopi:ui-zoom`) and applied by setting `document.documentElement.style.fontSize` on load.
|
||||
- Canvas pixel rendering (uPlot, ECharts) reads `window.devicePixelRatio` and sizes canvases accordingly.
|
||||
|
||||
### 4.8 Lua Editor (`LuaEditor.tsx`)
|
||||
|
||||
A syntax-highlighted code editor for Lua scripts in the Synthetic signal wizard:
|
||||
|
||||
- Implemented as a `<textarea>` overlaid on a `<pre>` element; the textarea has `color: transparent; caret-color: #e2e8f0` so only the caret is visible — the `<pre>` provides the coloured text behind it.
|
||||
- Tokeniser handles: `--` line comments, `"..."` / `'...'` string literals, `[[...]]` long strings, hex and float numeric literals, and all Lua 5.1 keywords.
|
||||
- Scroll position is synchronised between textarea and pre on every scroll event.
|
||||
|
||||
---
|
||||
|
||||
@@ -278,34 +399,42 @@ Plot widgets (`uPlot` for time series, `ECharts` for others) manage their own ca
|
||||
|
||||
### 5.1 Backend
|
||||
|
||||
```makefile
|
||||
# Build static binary (requires EPICS base installed or cross-compiled libca)
|
||||
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \
|
||||
go build -ldflags="-s -w" -o dist/uopi ./cmd/uopi
|
||||
```bash
|
||||
# Full build (frontend then backend)
|
||||
make all
|
||||
|
||||
# Run tests
|
||||
go test ./...
|
||||
# Backend only (frontend must already be built)
|
||||
make backend
|
||||
|
||||
# Run a single test
|
||||
# All tests
|
||||
make test
|
||||
|
||||
# Single Go test
|
||||
go test ./internal/broker/... -run TestFanOut
|
||||
|
||||
# Go vet
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
EPICS `libca.a` is statically linked via `CGO_LDFLAGS` in `internal/datasource/epics/cgo.go`.
|
||||
|
||||
### 5.2 Frontend
|
||||
|
||||
The frontend is built by a Go tool in `tools/buildfrontend/` that invokes the esbuild Go API:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm install
|
||||
npm run dev # dev server at http://localhost:5173 (proxies /api to backend)
|
||||
npm run build # outputs to web/dist/
|
||||
npm run check # svelte-check type checking
|
||||
npm run lint # eslint + prettier
|
||||
make frontend
|
||||
# or equivalently:
|
||||
go generate ./web/...
|
||||
```
|
||||
|
||||
### 5.3 Combined Build
|
||||
No Node.js, npm, or any JS build tool is required on the host. The bundler:
|
||||
- Reads entry point `web/src/main.tsx`.
|
||||
- Resolves `preact`, `uplot`, and `echarts` imports from `web/vendor/`.
|
||||
- Outputs `web/dist/main.js` and `web/dist/main.css`.
|
||||
- Copies `web/dist/index.html`, vendor CSS, and other static assets.
|
||||
|
||||
A `Makefile` at the repo root:
|
||||
### 5.3 Combined Build
|
||||
|
||||
```makefile
|
||||
.PHONY: all frontend backend clean
|
||||
@@ -313,20 +442,19 @@ A `Makefile` at the repo root:
|
||||
all: frontend backend
|
||||
|
||||
frontend:
|
||||
cd web && npm ci && npm run build
|
||||
go run ./tools/buildfrontend
|
||||
|
||||
backend: frontend
|
||||
go build -ldflags="-s -w" -o dist/uopi ./cmd/uopi
|
||||
backend:
|
||||
CGO_ENABLED=1 go build -ldflags="-s -w" -o dist/uopi ./cmd/uopi
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
cd web && npm run check
|
||||
go test ./...
|
||||
|
||||
clean:
|
||||
rm -rf dist/ web/dist/
|
||||
rm -rf dist/ web/dist/
|
||||
```
|
||||
|
||||
The backend's `//go:embed web/dist` directive picks up the built frontend automatically.
|
||||
The backend's `//go:embed dist` directive in `web/embed.go` picks up the built frontend automatically. `main.go` does `fs.Sub(web.FS, "dist")` to serve a clean root.
|
||||
|
||||
---
|
||||
|
||||
@@ -339,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.json"
|
||||
```
|
||||
|
||||
All settings can also be overridden with environment variables: `UOPI_SERVER_LISTEN`, `UOPI_EPICS_CA_ADDR_LIST`, etc.
|
||||
All settings can also be overridden with `UOPI_*` environment variables (e.g.
|
||||
`UOPI_SERVER_LISTEN`, `UOPI_SERVER_LOGIC_EDITORS`, `UOPI_EPICS_CA_ADDR_LIST`).
|
||||
|
||||
---
|
||||
|
||||
@@ -359,11 +501,11 @@ All settings can also be overridden with environment variables: `UOPI_SERVER_LIS
|
||||
| ------------------ | --------------------------------------------------------------------------------- |
|
||||
| Broker | Unit tests with mock data source; verify fan-out, subscribe/unsubscribe lifecycle |
|
||||
| Synthetic DSP | Table-driven unit tests against known signal inputs/outputs |
|
||||
| Low-pass filter | Unit tests: step response, frequency attenuation vs. analytical expectation |
|
||||
| Lua sandbox | Unit tests for sandbox isolation and API surface |
|
||||
| REST API | `httptest` integration tests |
|
||||
| WebSocket protocol | Integration tests with a test client |
|
||||
| EPICS data source | Integration tests against a local SoftIOC (optional, CI-gated) |
|
||||
| Frontend | Svelte component tests via `vitest` + `@testing-library/svelte` |
|
||||
| EPICS data source | Integration tests against a local SoftIOC (optional, CI-gated) |
|
||||
|
||||
---
|
||||
|
||||
@@ -372,13 +514,20 @@ All settings can also be overridden with environment variables: `UOPI_SERVER_LIS
|
||||
- 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.
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
// Package access implements uopi's global user-access policy: every user is
|
||||
// trusted (full write) by default, while a configured blacklist can downgrade
|
||||
// specific users to read-only or no access. It also resolves the per-request
|
||||
// user identity and the user→group memberships defined in config.
|
||||
//
|
||||
// This is the foundation layer (Phase 1). Per-panel ownership/ACL evaluation is
|
||||
// layered on top of this in a later phase.
|
||||
package access
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Level is a global access level. Higher levels include lower ones.
|
||||
type Level int
|
||||
|
||||
const (
|
||||
// LevelNone denies all access.
|
||||
LevelNone Level = iota
|
||||
// LevelRead permits reads only (no create/update/delete/share, no signal writes).
|
||||
LevelRead
|
||||
// LevelWrite permits full access. This is the default for any user not blacklisted.
|
||||
LevelWrite
|
||||
)
|
||||
|
||||
// String renders the level using the same tokens accepted by ParseLevel and
|
||||
// surfaced to the frontend via /api/v1/me.
|
||||
func (l Level) String() string {
|
||||
switch l {
|
||||
case LevelNone:
|
||||
return "none"
|
||||
case LevelRead:
|
||||
return "readonly"
|
||||
default:
|
||||
return "write"
|
||||
}
|
||||
}
|
||||
|
||||
// ParseLevel maps a config string to a Level. Unknown values restrict to
|
||||
// read-only, since the only reason to list a user is to limit them.
|
||||
func ParseLevel(s string) Level {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "noaccess", "none", "no":
|
||||
return LevelNone
|
||||
case "readonly", "read", "ro":
|
||||
return LevelRead
|
||||
case "write", "readwrite", "rw", "full":
|
||||
return LevelWrite
|
||||
default:
|
||||
return LevelRead
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
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. 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),
|
||||
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)
|
||||
if u == "" {
|
||||
continue
|
||||
}
|
||||
p.blacklist[u] = ParseLevel(lvl)
|
||||
}
|
||||
for g, members := range groups {
|
||||
g = strings.TrimSpace(g)
|
||||
if g == "" {
|
||||
continue
|
||||
}
|
||||
p.groupNames = append(p.groupNames, g)
|
||||
for _, m := range members {
|
||||
m = strings.TrimSpace(m)
|
||||
if m == "" {
|
||||
continue
|
||||
}
|
||||
p.userGroups[m] = append(p.userGroups[m], g)
|
||||
}
|
||||
}
|
||||
sort.Strings(p.groupNames)
|
||||
return p
|
||||
}
|
||||
|
||||
// GroupNames returns a copy of every configured user-group name, sorted.
|
||||
func (p *Policy) GroupNames() []string {
|
||||
out := make([]string, len(p.groupNames))
|
||||
copy(out, p.groupNames)
|
||||
return out
|
||||
}
|
||||
|
||||
// ResolveUser trims the proxy-provided header value and falls back to the
|
||||
// configured default_user when it is empty (e.g. unproxied/dev deployments).
|
||||
func (p *Policy) ResolveUser(headerValue string) string {
|
||||
u := strings.TrimSpace(headerValue)
|
||||
if u == "" {
|
||||
return p.defaultUser
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// Level returns the global access level for a user. Users that are neither
|
||||
// blacklisted nor anonymous get full write access.
|
||||
func (p *Policy) Level(user string) Level {
|
||||
user = strings.TrimSpace(user)
|
||||
if user == "" {
|
||||
// No identity at all (no proxy header, no default_user): trusted LAN.
|
||||
return LevelWrite
|
||||
}
|
||||
if lvl, ok := p.blacklist[user]; ok {
|
||||
return lvl
|
||||
}
|
||||
return LevelWrite
|
||||
}
|
||||
|
||||
// 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)]
|
||||
out := make([]string, len(src))
|
||||
copy(out, src)
|
||||
return out
|
||||
}
|
||||
|
||||
// ── request-scoped user identity ────────────────────────────────────────────
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
// WithUser returns a copy of ctx carrying the resolved end-user identity.
|
||||
func WithUser(ctx context.Context, user string) context.Context {
|
||||
return context.WithValue(ctx, ctxKey{}, user)
|
||||
}
|
||||
|
||||
// UserFrom returns the identity stored by WithUser, or "" if none.
|
||||
func UserFrom(ctx context.Context) string {
|
||||
u, _ := ctx.Value(ctxKey{}).(string)
|
||||
return u
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+884
-17
File diff suppressed because it is too large
Load Diff
@@ -12,9 +12,12 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -38,9 +41,19 @@ func setup(t *testing.T) (*httptest.Server, func()) {
|
||||
if err != nil {
|
||||
t.Fatal("storage.New:", err)
|
||||
}
|
||||
acl, err := panelacl.New(dir)
|
||||
if err != nil {
|
||||
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, "", 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() {
|
||||
@@ -396,7 +409,7 @@ func TestStorageValidateID(t *testing.T) {
|
||||
}
|
||||
|
||||
// Create a real interface first to get a valid ID.
|
||||
id, err := store.Create([]byte(sampleXML))
|
||||
id, err := store.Create([]byte(sampleXML), "")
|
||||
if err != nil {
|
||||
t.Fatal("create:", err)
|
||||
}
|
||||
@@ -416,7 +429,7 @@ func TestStorageValidateID(t *testing.T) {
|
||||
if _, err := store.Get(bad); err == nil {
|
||||
t.Errorf("Get(%q) should have failed, got nil error", bad)
|
||||
}
|
||||
if err := store.Update(bad, []byte(sampleXML)); err == nil {
|
||||
if err := store.Update(bad, []byte(sampleXML), ""); err == nil {
|
||||
t.Errorf("Update(%q) should have failed", bad)
|
||||
}
|
||||
if err := store.Delete(bad); err == nil {
|
||||
|
||||
+69
-11
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
@@ -41,18 +42,38 @@ type Broker struct {
|
||||
sources map[string]datasource.DataSource
|
||||
subs map[SignalRef]*signalSub
|
||||
|
||||
log *slog.Logger
|
||||
maxInterval time.Duration // 0 = unlimited
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// Option is a functional option for Broker configuration.
|
||||
type Option func(*Broker)
|
||||
|
||||
// WithMaxUpdateRate limits fan-out to at most hz updates per second per signal.
|
||||
// When upstream delivers faster, intermediate values are coalesced: the most
|
||||
// recent value in each interval is forwarded once the interval elapses.
|
||||
// A value ≤ 0 disables rate limiting (the default).
|
||||
func WithMaxUpdateRate(hz float64) Option {
|
||||
return func(b *Broker) {
|
||||
if hz > 0 {
|
||||
b.maxInterval = time.Duration(float64(time.Second) / hz)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// New creates a Broker whose upstream subscriptions are bound to ctx.
|
||||
// Cancel ctx (or the parent context passed to main) to shut everything down.
|
||||
func New(ctx context.Context, log *slog.Logger) *Broker {
|
||||
return &Broker{
|
||||
func New(ctx context.Context, log *slog.Logger, opts ...Option) *Broker {
|
||||
b := &Broker{
|
||||
ctx: ctx,
|
||||
sources: make(map[string]datasource.DataSource),
|
||||
subs: make(map[SignalRef]*signalSub),
|
||||
log: log,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(b)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Register adds a DataSource to the broker. Must be called before Subscribe.
|
||||
@@ -169,7 +190,36 @@ func (b *Broker) unsubscribe(ref SignalRef, ch chan<- Update) {
|
||||
|
||||
// fanOut reads values from rawCh and dispatches them to all registered clients.
|
||||
// It exits when sub.done is closed or rawCh is closed.
|
||||
//
|
||||
// When b.maxInterval > 0, updates that arrive faster than the interval are
|
||||
// coalesced: only the most recent value in each interval window is forwarded,
|
||||
// delivered at the end of the interval by a flush ticker. This ensures the
|
||||
// latest value is always eventually seen even when the source fires rapidly.
|
||||
func (b *Broker) fanOut(ref SignalRef, sub *signalSub, rawCh <-chan datasource.Value) {
|
||||
maxInterval := b.maxInterval
|
||||
|
||||
var lastSent time.Time
|
||||
var pending *Update
|
||||
|
||||
// Only allocate a ticker when rate-limiting is configured.
|
||||
var flushC <-chan time.Time
|
||||
if maxInterval > 0 {
|
||||
t := time.NewTicker(maxInterval)
|
||||
defer t.Stop()
|
||||
flushC = t.C
|
||||
}
|
||||
|
||||
dispatch := func(u Update) {
|
||||
sub.mu.RLock()
|
||||
for ch := range sub.clients {
|
||||
select {
|
||||
case ch <- u:
|
||||
default: // slow consumer: drop rather than block
|
||||
}
|
||||
}
|
||||
sub.mu.RUnlock()
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case v, ok := <-rawCh:
|
||||
@@ -177,15 +227,23 @@ func (b *Broker) fanOut(ref SignalRef, sub *signalSub, rawCh <-chan datasource.V
|
||||
return
|
||||
}
|
||||
update := Update{Ref: ref, Value: v}
|
||||
sub.mu.RLock()
|
||||
for ch := range sub.clients {
|
||||
select {
|
||||
case ch <- update:
|
||||
default:
|
||||
// slow consumer: drop rather than block
|
||||
}
|
||||
// Rate-limit: if an interval is configured and the last delivery
|
||||
// was recent, hold this update as pending (coalesce).
|
||||
if maxInterval > 0 && !lastSent.IsZero() && time.Since(lastSent) < maxInterval {
|
||||
pending = &update
|
||||
continue
|
||||
}
|
||||
lastSent = time.Now()
|
||||
pending = nil
|
||||
dispatch(update)
|
||||
|
||||
case <-flushC:
|
||||
// Deliver the most recent coalesced value, if any.
|
||||
if pending != nil {
|
||||
lastSent = time.Now()
|
||||
dispatch(*pending)
|
||||
pending = nil
|
||||
}
|
||||
sub.mu.RUnlock()
|
||||
|
||||
case <-sub.done:
|
||||
return
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
package broker_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/datasource/stub"
|
||||
)
|
||||
|
||||
// newBrokerN builds a broker backed by a stub with n dynamically generated signals.
|
||||
func newBrokerN(tb testing.TB, n int) (*broker.Broker, context.CancelFunc) {
|
||||
tb.Helper()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
log := slog.Default()
|
||||
b := broker.New(ctx, log)
|
||||
ds := stub.NewN(n)
|
||||
if err := ds.Connect(ctx); err != nil {
|
||||
cancel()
|
||||
tb.Fatal(err)
|
||||
}
|
||||
b.Register(ds)
|
||||
return b, cancel
|
||||
}
|
||||
|
||||
// TestStress_ManySignalsManyClients subscribes 20 clients to 500 signals each,
|
||||
// runs for 2 seconds, then verifies delivery counts and zero subscription leaks.
|
||||
func TestStress_ManySignalsManyClients(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping stress test in short mode")
|
||||
}
|
||||
|
||||
const (
|
||||
nSignals = 500
|
||||
nClients = 20
|
||||
duration = 2 * time.Second
|
||||
)
|
||||
|
||||
goroutinesBefore := runtime.NumGoroutine()
|
||||
|
||||
brk, cancel := newBrokerN(t, nSignals)
|
||||
defer cancel()
|
||||
|
||||
// Each client has its own channel and subscribes to all nSignals signals.
|
||||
type clientState struct {
|
||||
ch chan broker.Update
|
||||
unsub []func()
|
||||
}
|
||||
clients := make([]clientState, nClients)
|
||||
|
||||
for i := range nClients {
|
||||
ch := make(chan broker.Update, 2048)
|
||||
unsubs := make([]func(), nSignals)
|
||||
for j := range nSignals {
|
||||
ref := broker.SignalRef{DS: "stub", Name: fmt.Sprintf("pv_%d", j)}
|
||||
unsub, err := brk.Subscribe(ref, ch)
|
||||
if err != nil {
|
||||
t.Fatalf("client %d subscribe pv_%d: %v", i, j, err)
|
||||
}
|
||||
unsubs[j] = unsub
|
||||
}
|
||||
clients[i] = clientState{ch: ch, unsub: unsubs}
|
||||
}
|
||||
|
||||
if n := brk.ActiveSubscriptions(); n != nSignals {
|
||||
t.Errorf("expected %d active upstream subscriptions, got %d", nSignals, n)
|
||||
}
|
||||
|
||||
// Drain updates concurrently for the test duration.
|
||||
var totalReceived atomic.Int64
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := range nClients {
|
||||
wg.Add(1)
|
||||
ch := clients[i].ch
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var n int64
|
||||
for {
|
||||
select {
|
||||
case <-ch:
|
||||
n++
|
||||
case <-stop:
|
||||
totalReceived.Add(n)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
time.Sleep(duration)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
|
||||
total := totalReceived.Load()
|
||||
// At 10 Hz for duration seconds: nSignals * nClients * duration.Seconds() * 10
|
||||
minExpected := int64(nSignals) * int64(nClients) * int64(duration.Seconds()) * 5 // conservative: 50%
|
||||
t.Logf("received %d updates (%d clients × %d signals × %.0fs @ 10Hz, min=%d)",
|
||||
total, nClients, nSignals, duration.Seconds(), minExpected)
|
||||
if total < minExpected {
|
||||
t.Errorf("too few updates: got %d, want >= %d", total, minExpected)
|
||||
}
|
||||
|
||||
// Unsubscribe all clients.
|
||||
for _, c := range clients {
|
||||
for _, unsub := range c.unsub {
|
||||
unsub()
|
||||
}
|
||||
}
|
||||
|
||||
// Give the broker time to tear down upstream subscriptions.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
if n := brk.ActiveSubscriptions(); n != 0 {
|
||||
t.Errorf("subscription leak: %d active subscriptions remain after all clients unsubscribed", n)
|
||||
}
|
||||
|
||||
cancel()
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
goroutinesAfter := runtime.NumGoroutine()
|
||||
leaked := goroutinesAfter - goroutinesBefore
|
||||
t.Logf("goroutines: before=%d after=%d delta=%d", goroutinesBefore, goroutinesAfter, leaked)
|
||||
if leaked > 20 {
|
||||
t.Errorf("goroutine leak: started with %d, ended with %d (%d extra)", goroutinesBefore, goroutinesAfter, leaked)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStress_RapidSubscribeUnsubscribe hammers subscribe/unsubscribe on a small
|
||||
// signal set from many goroutines concurrently to expose races and deadlocks.
|
||||
func TestStress_RapidSubscribeUnsubscribe(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping stress test in short mode")
|
||||
}
|
||||
|
||||
const (
|
||||
nSignals = 20
|
||||
nGoroutines = 50
|
||||
duration = 2 * time.Second
|
||||
)
|
||||
|
||||
brk, cancel := newBrokerN(t, nSignals)
|
||||
defer cancel()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
stop := make(chan struct{})
|
||||
var ops atomic.Int64
|
||||
|
||||
for range nGoroutines {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ch := make(chan broker.Update, 64)
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
// Pick a signal and rapidly subscribe/unsubscribe.
|
||||
idx := int(ops.Load()) % nSignals
|
||||
ref := broker.SignalRef{DS: "stub", Name: fmt.Sprintf("pv_%d", idx)}
|
||||
unsub, err := brk.Subscribe(ref, ch)
|
||||
if err != nil {
|
||||
t.Errorf("subscribe pv_%d: %v", idx, err)
|
||||
return
|
||||
}
|
||||
ops.Add(1)
|
||||
// Drain a bit before unsubscribing.
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
unsub()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
time.Sleep(duration)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
|
||||
t.Logf("completed %d subscribe/unsubscribe cycles in %s", ops.Load(), duration)
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
if n := brk.ActiveSubscriptions(); n != 0 {
|
||||
t.Errorf("subscription leak after rapid churn: %d remain", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStress_SharedSignalManyClients verifies that a single high-rate signal
|
||||
// fans out correctly to many concurrent clients with no drops for fast consumers.
|
||||
func TestStress_SharedSignalManyClients(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping stress test in short mode")
|
||||
}
|
||||
|
||||
const (
|
||||
nClients = 50
|
||||
duration = 2 * time.Second
|
||||
)
|
||||
|
||||
brk, cancel := newBroker(t) // uses stub.New() which has counter_fast at 1 ms
|
||||
defer cancel()
|
||||
|
||||
ref := broker.SignalRef{DS: "stub", Name: "counter_fast"}
|
||||
channels := make([]chan broker.Update, nClients)
|
||||
unsubs := make([]func(), nClients)
|
||||
|
||||
for i := range nClients {
|
||||
ch := make(chan broker.Update, 4096)
|
||||
channels[i] = ch
|
||||
unsub, err := brk.Subscribe(ref, ch)
|
||||
if err != nil {
|
||||
t.Fatalf("subscribe client %d: %v", i, err)
|
||||
}
|
||||
unsubs[i] = unsub
|
||||
}
|
||||
defer func() {
|
||||
for _, u := range unsubs {
|
||||
u()
|
||||
}
|
||||
}()
|
||||
|
||||
if n := brk.ActiveSubscriptions(); n != 1 {
|
||||
t.Errorf("expected exactly 1 upstream subscription for shared signal, got %d", n)
|
||||
}
|
||||
|
||||
var totalReceived atomic.Int64
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := range nClients {
|
||||
wg.Add(1)
|
||||
ch := channels[i]
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var n int64
|
||||
for {
|
||||
select {
|
||||
case <-ch:
|
||||
n++
|
||||
case <-stop:
|
||||
totalReceived.Add(n)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
time.Sleep(duration)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
|
||||
total := totalReceived.Load()
|
||||
// counter_fast ticks at 1ms → ~1000 Hz → 2000 updates × 50 clients = 100000 expected minimum
|
||||
minExpected := int64(nClients) * int64(duration.Seconds()) * 500 // 50% delivery ok due to buffering
|
||||
t.Logf("shared signal: received %d updates across %d clients (min=%d)", total, nClients, minExpected)
|
||||
if total < minExpected {
|
||||
t.Errorf("too few updates: got %d, want >= %d", total, minExpected)
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,68 @@ func TestUnknownSignal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxUpdateRate(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
const maxHz = 5.0 // 5 Hz → 200 ms interval
|
||||
|
||||
ds := stub.New() // counter_fast fires at 1 kHz
|
||||
if err := ds.Connect(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := broker.New(ctx, slog.Default(), broker.WithMaxUpdateRate(maxHz))
|
||||
b.Register(ds)
|
||||
|
||||
ref := broker.SignalRef{DS: "stub", Name: "counter_fast"}
|
||||
ch := make(chan broker.Update, 512)
|
||||
unsub, err := b.Subscribe(ref, ch)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer unsub()
|
||||
|
||||
// Collect updates for 2 seconds.
|
||||
var count int
|
||||
deadline := time.After(2 * time.Second)
|
||||
loop:
|
||||
for {
|
||||
select {
|
||||
case <-ch:
|
||||
count++
|
||||
case <-deadline:
|
||||
break loop
|
||||
}
|
||||
}
|
||||
|
||||
// At 5 Hz for 2 s we expect ~10 updates (±3 for timer jitter + coalescing).
|
||||
t.Logf("MaxUpdateRate: received %d updates at %.0f Hz limit over 2s (expect ~10)", count, maxHz)
|
||||
if count < 5 || count > 20 {
|
||||
t.Errorf("expected ~10 updates at 5 Hz, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxUpdateRate_UnlimitedByDefault(t *testing.T) {
|
||||
b, cancel := newBroker(t) // no WithMaxUpdateRate → unlimited
|
||||
defer cancel()
|
||||
|
||||
ref := broker.SignalRef{DS: "stub", Name: "counter_fast"}
|
||||
ch := make(chan broker.Update, 4096)
|
||||
unsub, err := b.Subscribe(ref, ch)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer unsub()
|
||||
|
||||
// counter_fast fires at 1 kHz; over 500 ms we should see ≥ 400 updates.
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
got := len(ch)
|
||||
t.Logf("Unlimited: received %d updates in 500ms from 1kHz signal", got)
|
||||
if got < 400 {
|
||||
t.Errorf("expected >= 400 updates with no rate limit, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipleSignals(t *testing.T) {
|
||||
b, cancel := newBroker(t)
|
||||
defer cancel()
|
||||
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
@@ -11,11 +12,51 @@ import (
|
||||
type Config struct {
|
||||
Server ServerConfig `toml:"server"`
|
||||
Datasource DatasourceConfig `toml:"datasource"`
|
||||
// Groups are named sets of users, referenced by panel sharing rules.
|
||||
Groups []GroupDef `toml:"groups"`
|
||||
}
|
||||
|
||||
// GroupDef is a named set of users defined as [[groups]] in the config file.
|
||||
type GroupDef struct {
|
||||
Name string `toml:"name"`
|
||||
Members []string `toml:"members"`
|
||||
}
|
||||
|
||||
// BlacklistEntry downgrades a specific user's global access level. Levels:
|
||||
// "readonly" (no writes) or "noaccess" (denied). Users not listed are trusted
|
||||
// with full access.
|
||||
type BlacklistEntry struct {
|
||||
User string `toml:"user"`
|
||||
Level string `toml:"level"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Listen string `toml:"listen"`
|
||||
StorageDir string `toml:"storage_dir"`
|
||||
Listen string `toml:"listen"`
|
||||
StorageDir string `toml:"storage_dir"`
|
||||
MaxUpdateRateHz float64 `toml:"max_update_rate_hz"` // 0 = unlimited
|
||||
|
||||
// TrustedUserHeader is the HTTP header from which the end-user identity is
|
||||
// read on each WebSocket connection (set by a trusted reverse proxy doing
|
||||
// authentication). The identity is used for EPICS writes so each session
|
||||
// can act as a different user. Empty disables it (writes use the server
|
||||
// identity). MUST only be enabled when a proxy strips any client-supplied
|
||||
// value, otherwise the header can be spoofed.
|
||||
TrustedUserHeader string `toml:"trusted_user_header"`
|
||||
|
||||
// DefaultUser is the identity used when the trusted user header is absent or
|
||||
// empty (e.g. unproxied/dev/LAN deployments). Empty leaves the user anonymous.
|
||||
DefaultUser string `toml:"default_user"`
|
||||
|
||||
// Blacklist downgrades specific users' global access level. Everyone not
|
||||
// listed is trusted with full write access.
|
||||
Blacklist []BlacklistEntry `toml:"blacklist"`
|
||||
|
||||
// LogicEditors optionally restricts who may add or edit panel logic (the
|
||||
// <logic> block of interfaces) and server-side control logic. Entries are
|
||||
// usernames or group names. When empty, no restriction applies (any user
|
||||
// with write access may edit logic). Anonymous/trusted-LAN callers are
|
||||
// always permitted.
|
||||
LogicEditors []string `toml:"logic_editors"`
|
||||
}
|
||||
|
||||
type DatasourceConfig struct {
|
||||
@@ -34,7 +75,9 @@ type EPICSConfig struct {
|
||||
CAAddrList string `toml:"ca_addr_list"`
|
||||
ArchiveURL string `toml:"archive_url"`
|
||||
ChannelFinderURL string `toml:"channel_finder_url"`
|
||||
PVNames []string `toml:"pv_names"`
|
||||
AutoSyncFilter string `toml:"auto_sync_filter"`
|
||||
AutoSyncFromArchiver bool `toml:"auto_sync_from_archiver"`
|
||||
PVNames []string `toml:"pv_names"`
|
||||
}
|
||||
|
||||
type PVAConfig struct {
|
||||
@@ -84,6 +127,20 @@ func applyEnv(cfg *Config) {
|
||||
if v := env("UOPI_SERVER_STORAGE_DIR"); v != "" {
|
||||
cfg.Server.StorageDir = v
|
||||
}
|
||||
if v := env("UOPI_SERVER_MAX_UPDATE_RATE_HZ"); v != "" {
|
||||
if hz, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
cfg.Server.MaxUpdateRateHz = hz
|
||||
}
|
||||
}
|
||||
if v := env("UOPI_SERVER_TRUSTED_USER_HEADER"); v != "" {
|
||||
cfg.Server.TrustedUserHeader = v
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -93,6 +150,12 @@ func applyEnv(cfg *Config) {
|
||||
if v := env("UOPI_EPICS_CHANNEL_FINDER_URL"); v != "" {
|
||||
cfg.Datasource.EPICS.ChannelFinderURL = v
|
||||
}
|
||||
if v := env("UOPI_EPICS_AUTO_SYNC_FILTER"); v != "" {
|
||||
cfg.Datasource.EPICS.AutoSyncFilter = v
|
||||
}
|
||||
if v := env("UOPI_EPICS_AUTO_SYNC_FROM_ARCHIVER"); v != "" {
|
||||
cfg.Datasource.EPICS.AutoSyncFromArchiver = (v == "true" || v == "YES")
|
||||
}
|
||||
if v := env("EPICS_PVA_ADDR_LIST"); v != "" {
|
||||
cfg.Datasource.PVA.AddrList = strings.Fields(v)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// Minimal 5-field cron parser for control-logic cron triggers.
|
||||
//
|
||||
// Fields, in order: minute hour day-of-month month day-of-week.
|
||||
// Each field supports:
|
||||
//
|
||||
// * any value
|
||||
// */n every n (step over the whole range)
|
||||
// a-b inclusive range
|
||||
// a-b/n range with step
|
||||
// a,b,c comma-separated list of the above
|
||||
// N a single value
|
||||
//
|
||||
// Day-of-week is 0-6 with 0 = Sunday (7 is also accepted as Sunday). When both
|
||||
// day-of-month and day-of-week are restricted (neither is "*"), the schedule
|
||||
// matches when EITHER matches, following Vixie cron semantics.
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Schedule is a parsed 5-field cron specification.
|
||||
type Schedule struct {
|
||||
minute uint64 // bitmask 0..59
|
||||
hour uint64 // bitmask 0..23
|
||||
dom uint64 // bitmask 1..31
|
||||
month uint64 // bitmask 1..12
|
||||
dow uint64 // bitmask 0..6
|
||||
domStar bool // day-of-month field was "*"
|
||||
dowStar bool // day-of-week field was "*"
|
||||
}
|
||||
|
||||
// ParseSchedule parses a 5-field cron spec. Extra whitespace is tolerated.
|
||||
func ParseSchedule(spec string) (*Schedule, error) {
|
||||
fields := strings.Fields(spec)
|
||||
if len(fields) != 5 {
|
||||
return nil, fmt.Errorf("cron spec must have 5 fields, got %d", len(fields))
|
||||
}
|
||||
s := &Schedule{}
|
||||
var err error
|
||||
if s.minute, err = parseField(fields[0], 0, 59); err != nil {
|
||||
return nil, fmt.Errorf("minute: %w", err)
|
||||
}
|
||||
if s.hour, err = parseField(fields[1], 0, 23); err != nil {
|
||||
return nil, fmt.Errorf("hour: %w", err)
|
||||
}
|
||||
if s.dom, err = parseField(fields[2], 1, 31); err != nil {
|
||||
return nil, fmt.Errorf("day-of-month: %w", err)
|
||||
}
|
||||
if s.month, err = parseField(fields[3], 1, 12); err != nil {
|
||||
return nil, fmt.Errorf("month: %w", err)
|
||||
}
|
||||
if s.dow, err = parseDOW(fields[4]); err != nil {
|
||||
return nil, fmt.Errorf("day-of-week: %w", err)
|
||||
}
|
||||
s.domStar = strings.TrimSpace(fields[2]) == "*"
|
||||
s.dowStar = strings.TrimSpace(fields[4]) == "*"
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Match reports whether t (truncated to the minute) satisfies the schedule.
|
||||
func (s *Schedule) Match(t time.Time) bool {
|
||||
if s.minute&bit(t.Minute()) == 0 {
|
||||
return false
|
||||
}
|
||||
if s.hour&bit(t.Hour()) == 0 {
|
||||
return false
|
||||
}
|
||||
if s.month&bit(int(t.Month())) == 0 {
|
||||
return false
|
||||
}
|
||||
domMatch := s.dom&bit(t.Day()) != 0
|
||||
dowMatch := s.dow&bit(int(t.Weekday())) != 0
|
||||
// Vixie semantics: when both day fields are restricted, match either.
|
||||
switch {
|
||||
case s.domStar && s.dowStar:
|
||||
return true
|
||||
case s.domStar:
|
||||
return dowMatch
|
||||
case s.dowStar:
|
||||
return domMatch
|
||||
default:
|
||||
return domMatch || dowMatch
|
||||
}
|
||||
}
|
||||
|
||||
func bit(n int) uint64 { return uint64(1) << uint(n) }
|
||||
|
||||
func parseDOW(field string) (uint64, error) {
|
||||
// Normalise 7 → 0 (both mean Sunday) by parsing then folding.
|
||||
mask, err := parseField(field, 0, 7)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if mask&bit(7) != 0 {
|
||||
mask = (mask &^ bit(7)) | bit(0)
|
||||
}
|
||||
return mask, nil
|
||||
}
|
||||
|
||||
func parseField(field string, lo, hi int) (uint64, error) {
|
||||
var mask uint64
|
||||
for _, part := range strings.Split(field, ",") {
|
||||
m, err := parsePart(strings.TrimSpace(part), lo, hi)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
mask |= m
|
||||
}
|
||||
if mask == 0 {
|
||||
return 0, fmt.Errorf("empty field %q", field)
|
||||
}
|
||||
return mask, nil
|
||||
}
|
||||
|
||||
func parsePart(part string, lo, hi int) (uint64, error) {
|
||||
if part == "" {
|
||||
return 0, fmt.Errorf("empty term")
|
||||
}
|
||||
step := 1
|
||||
rangePart := part
|
||||
if idx := strings.IndexByte(part, '/'); idx >= 0 {
|
||||
rangePart = part[:idx]
|
||||
st, err := strconv.Atoi(part[idx+1:])
|
||||
if err != nil || st < 1 {
|
||||
return 0, fmt.Errorf("bad step %q", part[idx+1:])
|
||||
}
|
||||
step = st
|
||||
}
|
||||
|
||||
start, end := lo, hi
|
||||
if rangePart != "*" {
|
||||
if idx := strings.IndexByte(rangePart, '-'); idx >= 0 {
|
||||
a, err1 := strconv.Atoi(rangePart[:idx])
|
||||
b, err2 := strconv.Atoi(rangePart[idx+1:])
|
||||
if err1 != nil || err2 != nil {
|
||||
return 0, fmt.Errorf("bad range %q", rangePart)
|
||||
}
|
||||
start, end = a, b
|
||||
} else {
|
||||
v, err := strconv.Atoi(rangePart)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("bad value %q", rangePart)
|
||||
}
|
||||
start, end = v, v
|
||||
}
|
||||
}
|
||||
|
||||
if start < lo || end > hi || start > end {
|
||||
return 0, fmt.Errorf("value out of range [%d,%d] in %q", lo, hi, part)
|
||||
}
|
||||
|
||||
var mask uint64
|
||||
for v := start; v <= end; v += step {
|
||||
mask |= bit(v)
|
||||
}
|
||||
return mask, nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func mustSched(t *testing.T, spec string) *Schedule {
|
||||
t.Helper()
|
||||
s, err := ParseSchedule(spec)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSchedule(%q): %v", spec, err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestCronEveryMinute(t *testing.T) {
|
||||
s := mustSched(t, "* * * * *")
|
||||
if !s.Match(time.Date(2026, 6, 18, 12, 34, 0, 0, time.UTC)) {
|
||||
t.Error("* * * * * should match any time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronSpecificTime(t *testing.T) {
|
||||
s := mustSched(t, "30 9 * * *")
|
||||
if !s.Match(time.Date(2026, 6, 18, 9, 30, 0, 0, time.UTC)) {
|
||||
t.Error("should match 09:30")
|
||||
}
|
||||
if s.Match(time.Date(2026, 6, 18, 9, 31, 0, 0, time.UTC)) {
|
||||
t.Error("should not match 09:31")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronStep(t *testing.T) {
|
||||
s := mustSched(t, "*/15 * * * *")
|
||||
for _, m := range []int{0, 15, 30, 45} {
|
||||
if !s.Match(time.Date(2026, 6, 18, 1, m, 0, 0, time.UTC)) {
|
||||
t.Errorf("*/15 should match minute %d", m)
|
||||
}
|
||||
}
|
||||
if s.Match(time.Date(2026, 6, 18, 1, 7, 0, 0, time.UTC)) {
|
||||
t.Error("*/15 should not match minute 7")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronRangeAndList(t *testing.T) {
|
||||
s := mustSched(t, "0 9-17 * * 1,2,3,4,5")
|
||||
// 2026-06-18 is a Thursday (weekday 4).
|
||||
if !s.Match(time.Date(2026, 6, 18, 10, 0, 0, 0, time.UTC)) {
|
||||
t.Error("should match Thursday 10:00")
|
||||
}
|
||||
if s.Match(time.Date(2026, 6, 18, 18, 0, 0, 0, time.UTC)) {
|
||||
t.Error("should not match 18:00 (out of 9-17)")
|
||||
}
|
||||
// 2026-06-20 is a Saturday (weekday 6).
|
||||
if s.Match(time.Date(2026, 6, 20, 10, 0, 0, 0, time.UTC)) {
|
||||
t.Error("should not match Saturday")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronDOWSunday7(t *testing.T) {
|
||||
s := mustSched(t, "0 0 * * 7")
|
||||
// 2026-06-21 is a Sunday.
|
||||
if !s.Match(time.Date(2026, 6, 21, 0, 0, 0, 0, time.UTC)) {
|
||||
t.Error("7 should mean Sunday")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronDomOrDow(t *testing.T) {
|
||||
// When both day fields restricted, match either (Vixie semantics).
|
||||
s := mustSched(t, "0 0 1 * 5")
|
||||
// 2026-06-01 is a Monday — matches via day-of-month=1.
|
||||
if !s.Match(time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)) {
|
||||
t.Error("should match day-of-month 1")
|
||||
}
|
||||
// 2026-06-19 is a Friday (weekday 5) — matches via day-of-week.
|
||||
if !s.Match(time.Date(2026, 6, 19, 0, 0, 0, 0, time.UTC)) {
|
||||
t.Error("should match Friday")
|
||||
}
|
||||
// 2026-06-18 Thursday, not the 1st — no match.
|
||||
if s.Match(time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)) {
|
||||
t.Error("should not match Thursday the 18th")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronInvalid(t *testing.T) {
|
||||
for _, bad := range []string{"* * * *", "60 * * * *", "* 24 * * *", "* * 0 * *", "a * * * *", "*/0 * * * *"} {
|
||||
if _, err := ParseSchedule(bad); err == nil {
|
||||
t.Errorf("ParseSchedule(%q) should error", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
)
|
||||
|
||||
// Guards against runaway flows (cycles / pathological loops).
|
||||
const (
|
||||
maxSteps = 100000
|
||||
maxLoop = 100000
|
||||
)
|
||||
|
||||
// Engine runs all enabled control-logic graphs continuously under a root
|
||||
// context. Reload tears down the current generation (subscriptions, timers,
|
||||
// in-flight flows) and rebuilds from the store's enabled graphs.
|
||||
type Engine struct {
|
||||
broker *broker.Broker
|
||||
store *Store
|
||||
log *slog.Logger
|
||||
root context.Context
|
||||
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc // cancels the current generation
|
||||
wg *sync.WaitGroup // tracks the current generation's goroutines
|
||||
|
||||
// Shared live signal cache for the current generation (key "ds\0name").
|
||||
liveMu sync.RWMutex
|
||||
live map[string]float64
|
||||
}
|
||||
|
||||
// NewEngine creates an engine bound to root. Call Reload to start it.
|
||||
func NewEngine(root context.Context, brk *broker.Broker, store *Store, log *slog.Logger) *Engine {
|
||||
return &Engine{
|
||||
broker: brk,
|
||||
store: store,
|
||||
log: log,
|
||||
root: root,
|
||||
live: map[string]float64{},
|
||||
}
|
||||
}
|
||||
|
||||
// ── reference / value helpers ──────────────────────────────────────────────────
|
||||
|
||||
func refKey(ds, name string) string { return ds + "\x00" + name }
|
||||
|
||||
// parseRef splits a "ds:name" target on the FIRST ':' (EPICS PV names contain
|
||||
// ':'). A bare name (no ':') is a graph-local variable in data source "local".
|
||||
func parseRef(target string) (ds, name string, ok bool) {
|
||||
t := strings.TrimSpace(target)
|
||||
if t == "" {
|
||||
return "", "", false
|
||||
}
|
||||
i := strings.IndexByte(t, ':')
|
||||
if i < 0 {
|
||||
return "local", t, true
|
||||
}
|
||||
return t[:i], t[i+1:], true
|
||||
}
|
||||
|
||||
func toNum(v any) float64 {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return x
|
||||
case float32:
|
||||
return float64(x)
|
||||
case int64:
|
||||
return float64(x)
|
||||
case int:
|
||||
return float64(x)
|
||||
case bool:
|
||||
if x {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
case string:
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(x), 64)
|
||||
if err != nil {
|
||||
return math.NaN()
|
||||
}
|
||||
return f
|
||||
default:
|
||||
return math.NaN()
|
||||
}
|
||||
}
|
||||
|
||||
// ── lifecycle ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Reload rebuilds the engine from the store. Safe to call repeatedly (after any
|
||||
// graph mutation). It is a no-op-safe full restart of the running generation.
|
||||
func (e *Engine) Reload() {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
// Tear down the previous generation and wait for its goroutines to exit.
|
||||
if e.cancel != nil {
|
||||
e.cancel()
|
||||
e.cancel = nil
|
||||
}
|
||||
if e.wg != nil {
|
||||
e.wg.Wait()
|
||||
e.wg = nil
|
||||
}
|
||||
|
||||
e.liveMu.Lock()
|
||||
e.live = map[string]float64{}
|
||||
e.liveMu.Unlock()
|
||||
|
||||
graphs := e.store.List()
|
||||
var compiled []*compiledGraph
|
||||
refs := map[string]RefLite{}
|
||||
for i := range graphs {
|
||||
g := graphs[i]
|
||||
if !g.Enabled {
|
||||
continue
|
||||
}
|
||||
cg := compile(g)
|
||||
compiled = append(compiled, cg)
|
||||
for k, r := range cg.refs {
|
||||
refs[k] = r
|
||||
}
|
||||
}
|
||||
if len(compiled) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
genCtx, cancel := context.WithCancel(e.root)
|
||||
wg := &sync.WaitGroup{}
|
||||
e.cancel = cancel
|
||||
e.wg = wg
|
||||
|
||||
for _, cg := range compiled {
|
||||
cg.engine = e
|
||||
cg.genCtx = genCtx
|
||||
cg.wg = wg
|
||||
}
|
||||
|
||||
// One shared updates channel feeds a single dispatch goroutine; every
|
||||
// subscription delivers into it. Subscriptions are released on teardown.
|
||||
updates := make(chan broker.Update, 128)
|
||||
var unsubs []func()
|
||||
for _, r := range refs {
|
||||
unsub, err := e.broker.Subscribe(broker.SignalRef{DS: r.DS, Name: r.Name}, updates)
|
||||
if err != nil {
|
||||
e.log.Warn("control logic: subscribe failed", "ds", r.DS, "signal", r.Name, "err", err)
|
||||
continue
|
||||
}
|
||||
unsubs = append(unsubs, unsub)
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-genCtx.Done()
|
||||
for _, u := range unsubs {
|
||||
u()
|
||||
}
|
||||
}()
|
||||
|
||||
// Dispatch goroutine: keep the live cache fresh and drive level/edge triggers.
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case u := <-updates:
|
||||
val := toNum(u.Value.Data)
|
||||
key := refKey(u.Ref.DS, u.Ref.Name)
|
||||
e.liveMu.Lock()
|
||||
e.live[key] = val
|
||||
e.liveMu.Unlock()
|
||||
for _, cg := range compiled {
|
||||
cg.onSignal(key, val)
|
||||
}
|
||||
case <-genCtx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Start timer and cron triggers.
|
||||
for _, cg := range compiled {
|
||||
cg.startTriggers()
|
||||
}
|
||||
|
||||
e.log.Info("control logic engine reloaded", "graphs", len(compiled), "signals", len(refs))
|
||||
}
|
||||
|
||||
// liveGet reads the current value of a signal from the shared cache.
|
||||
func (e *Engine) liveGet(ds, name string) float64 {
|
||||
if ds == "sys" {
|
||||
if name == "time" {
|
||||
return float64(time.Now().UnixNano()) / 1e9
|
||||
}
|
||||
return math.NaN() // sys:dt handled per-activation
|
||||
}
|
||||
e.liveMu.RLock()
|
||||
defer e.liveMu.RUnlock()
|
||||
v, ok := e.live[refKey(ds, name)]
|
||||
if !ok {
|
||||
return math.NaN()
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// write applies an action.write/lua-set to a target: a bare name updates a
|
||||
// graph-local var; a ds:name target writes to the data source.
|
||||
func (e *Engine) write(cg *compiledGraph, target string, val float64) {
|
||||
ds, name, ok := parseRef(target)
|
||||
if !ok || math.IsNaN(val) {
|
||||
return
|
||||
}
|
||||
if ds == "local" {
|
||||
cg.setLocal(name, val)
|
||||
return
|
||||
}
|
||||
src, ok := e.broker.Source(ds)
|
||||
if !ok {
|
||||
e.log.Warn("control logic: write to unknown data source", "ds", ds, "signal", name)
|
||||
return
|
||||
}
|
||||
if err := src.Write(e.root, name, val); err != nil {
|
||||
e.log.Warn("control logic: write failed", "ds", ds, "signal", name, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── compiled graph ─────────────────────────────────────────────────────────────
|
||||
|
||||
type wireOut struct {
|
||||
to string
|
||||
port string
|
||||
}
|
||||
|
||||
// compiledGraph holds the runtime state for one enabled graph.
|
||||
type compiledGraph struct {
|
||||
engine *Engine
|
||||
genCtx context.Context
|
||||
wg *sync.WaitGroup
|
||||
|
||||
name string
|
||||
byId map[string]Node
|
||||
out map[string][]wireOut
|
||||
inc map[string][]string // incoming source ids per node (for gates)
|
||||
refs map[string]RefLite // unique signals to subscribe (excl. sys/local)
|
||||
|
||||
watchers map[string][]string // signal key → trigger node ids
|
||||
luaNodes map[string]*luaRuntime
|
||||
|
||||
stateMu sync.Mutex
|
||||
levelState map[string]bool // current truth of level triggers (threshold/alarm)
|
||||
prevBool map[string]bool // edge detection for threshold/alarm
|
||||
prevVal map[string]float64 // last value for change triggers
|
||||
hasVal map[string]bool
|
||||
lastFire map[string]int64 // ns wall clock each trigger last fired
|
||||
locals map[string]float64
|
||||
}
|
||||
|
||||
func compile(g Graph) *compiledGraph {
|
||||
cg := &compiledGraph{
|
||||
name: g.Name,
|
||||
byId: map[string]Node{},
|
||||
out: map[string][]wireOut{},
|
||||
inc: map[string][]string{},
|
||||
refs: map[string]RefLite{},
|
||||
watchers: map[string][]string{},
|
||||
luaNodes: map[string]*luaRuntime{},
|
||||
levelState: map[string]bool{},
|
||||
prevBool: map[string]bool{},
|
||||
prevVal: map[string]float64{},
|
||||
hasVal: map[string]bool{},
|
||||
lastFire: map[string]int64{},
|
||||
locals: map[string]float64{},
|
||||
}
|
||||
for _, n := range g.Nodes {
|
||||
cg.byId[n.ID] = n
|
||||
}
|
||||
for _, w := range g.Wires {
|
||||
port := w.FromPort
|
||||
if port == "" {
|
||||
port = "out"
|
||||
}
|
||||
cg.out[w.From] = append(cg.out[w.From], wireOut{to: w.To, port: port})
|
||||
cg.inc[w.To] = append(cg.inc[w.To], w.From)
|
||||
}
|
||||
|
||||
want := func(ds, name string) {
|
||||
if ds == "sys" || ds == "local" {
|
||||
return
|
||||
}
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
cg.refs[refKey(ds, name)] = RefLite{DS: ds, Name: name}
|
||||
}
|
||||
wantExpr := func(expr string) {
|
||||
for _, r := range CollectRefs(expr) {
|
||||
want(r.DS, r.Name)
|
||||
}
|
||||
}
|
||||
|
||||
for _, n := range g.Nodes {
|
||||
switch n.Kind {
|
||||
case "trigger.threshold", "trigger.change", "trigger.alarm":
|
||||
if ds, name, ok := parseRef(n.param("signal")); ok {
|
||||
want(ds, name)
|
||||
key := refKey(ds, name)
|
||||
cg.watchers[key] = append(cg.watchers[key], n.ID)
|
||||
}
|
||||
case "flow.if":
|
||||
wantExpr(n.param("cond"))
|
||||
case "flow.loop":
|
||||
if n.param("mode") == "while" {
|
||||
wantExpr(n.param("cond"))
|
||||
}
|
||||
case "action.write", "action.log":
|
||||
wantExpr(n.param("expr"))
|
||||
case "action.lua":
|
||||
cg.luaNodes[n.ID] = newLuaRuntime(n.param("script"))
|
||||
for _, r := range luaGetRefs(n.param("script")) {
|
||||
want(r.DS, r.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return cg
|
||||
}
|
||||
|
||||
func (cg *compiledGraph) setLocal(name string, v float64) {
|
||||
cg.stateMu.Lock()
|
||||
cg.locals[name] = v
|
||||
cg.stateMu.Unlock()
|
||||
}
|
||||
|
||||
func (cg *compiledGraph) getLocal(name string) float64 {
|
||||
cg.stateMu.Lock()
|
||||
defer cg.stateMu.Unlock()
|
||||
v, ok := cg.locals[name]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// startTriggers launches timer and cron trigger goroutines for the generation.
|
||||
func (cg *compiledGraph) startTriggers() {
|
||||
hasCron := false
|
||||
for _, n := range cg.byId {
|
||||
switch n.Kind {
|
||||
case "trigger.timer":
|
||||
node := n
|
||||
cg.wg.Add(1)
|
||||
go func() {
|
||||
defer cg.wg.Done()
|
||||
d := intervalOf(node)
|
||||
t := time.NewTicker(d)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-t.C:
|
||||
cg.activate(node.ID)
|
||||
case <-cg.genCtx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
case "trigger.cron":
|
||||
hasCron = true
|
||||
}
|
||||
}
|
||||
if hasCron {
|
||||
cg.startCron()
|
||||
}
|
||||
}
|
||||
|
||||
func (cg *compiledGraph) startCron() {
|
||||
type cronNode struct {
|
||||
id string
|
||||
sched *Schedule
|
||||
}
|
||||
var crons []cronNode
|
||||
for _, n := range cg.byId {
|
||||
if n.Kind != "trigger.cron" {
|
||||
continue
|
||||
}
|
||||
sched, err := ParseSchedule(n.param("spec"))
|
||||
if err != nil {
|
||||
cg.engine.log.Warn("control logic: bad cron spec", "graph", cg.name, "spec", n.param("spec"), "err", err)
|
||||
continue
|
||||
}
|
||||
crons = append(crons, cronNode{id: n.ID, sched: sched})
|
||||
}
|
||||
if len(crons) == 0 {
|
||||
return
|
||||
}
|
||||
cg.wg.Add(1)
|
||||
go func() {
|
||||
defer cg.wg.Done()
|
||||
t := time.NewTicker(time.Second)
|
||||
defer t.Stop()
|
||||
lastMinute := -1
|
||||
for {
|
||||
select {
|
||||
case now := <-t.C:
|
||||
minute := now.Hour()*60 + now.Minute()
|
||||
if minute == lastMinute {
|
||||
continue // fire at most once per minute
|
||||
}
|
||||
lastMinute = minute
|
||||
for _, c := range crons {
|
||||
if c.sched.Match(now) {
|
||||
cg.activate(c.id)
|
||||
}
|
||||
}
|
||||
case <-cg.genCtx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func intervalOf(n Node) time.Duration {
|
||||
ms, err := strconv.Atoi(strings.TrimSpace(n.param("interval")))
|
||||
if err != nil || ms < 50 {
|
||||
ms = 1000
|
||||
}
|
||||
return time.Duration(ms) * time.Millisecond
|
||||
}
|
||||
|
||||
// ── trigger evaluation ─────────────────────────────────────────────────────────
|
||||
|
||||
// onSignal drives threshold/alarm (rising edge) and change triggers when a
|
||||
// watched signal updates.
|
||||
func (cg *compiledGraph) onSignal(key string, value float64) {
|
||||
for _, id := range cg.watchers[key] {
|
||||
node, ok := cg.byId[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch node.Kind {
|
||||
case "trigger.threshold":
|
||||
cur := testThreshold(value, node.param("op"), parseFloat(node.param("value")))
|
||||
cg.stateMu.Lock()
|
||||
cg.levelState[id] = cur
|
||||
prev := cg.prevBool[id]
|
||||
cg.prevBool[id] = cur
|
||||
cg.stateMu.Unlock()
|
||||
if cur && !prev {
|
||||
cg.activate(id)
|
||||
}
|
||||
case "trigger.alarm":
|
||||
lo := parseFloat(node.param("min"))
|
||||
hi := parseFloat(node.param("max"))
|
||||
cur := !math.IsNaN(value) && (value < lo || value > hi)
|
||||
cg.stateMu.Lock()
|
||||
cg.levelState[id] = cur
|
||||
prev := cg.prevBool[id]
|
||||
cg.prevBool[id] = cur
|
||||
cg.stateMu.Unlock()
|
||||
if cur && !prev {
|
||||
cg.activate(id)
|
||||
}
|
||||
case "trigger.change":
|
||||
cg.stateMu.Lock()
|
||||
had := cg.hasVal[id]
|
||||
prev := cg.prevVal[id]
|
||||
cg.prevVal[id] = value
|
||||
cg.hasVal[id] = true
|
||||
cg.stateMu.Unlock()
|
||||
if had && value != prev {
|
||||
cg.activate(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testThreshold(val float64, op string, cmp float64) bool {
|
||||
if math.IsNaN(val) {
|
||||
return false
|
||||
}
|
||||
switch op {
|
||||
case "<":
|
||||
return val < cmp
|
||||
case ">=":
|
||||
return val >= cmp
|
||||
case "<=":
|
||||
return val <= cmp
|
||||
case "==":
|
||||
return val == cmp
|
||||
case "!=":
|
||||
return val != cmp
|
||||
default: // ">"
|
||||
return val > cmp
|
||||
}
|
||||
}
|
||||
|
||||
func parseFloat(s string) float64 {
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// ── execution ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type runCtx struct {
|
||||
fired string
|
||||
steps int
|
||||
resolve Resolver
|
||||
}
|
||||
|
||||
// activate spawns a flow run for a trigger on its own goroutine so that
|
||||
// action.delay does not block signal dispatch or other flows.
|
||||
func (cg *compiledGraph) activate(triggerID string) {
|
||||
now := time.Now().UnixNano()
|
||||
cg.stateMu.Lock()
|
||||
last, had := cg.lastFire[triggerID]
|
||||
cg.lastFire[triggerID] = now
|
||||
cg.stateMu.Unlock()
|
||||
dt := 0.0
|
||||
if had {
|
||||
dt = float64(now-last) / 1e9
|
||||
}
|
||||
|
||||
resolve := func(ds, name string) float64 {
|
||||
switch ds {
|
||||
case "sys":
|
||||
if name == "dt" {
|
||||
return dt
|
||||
}
|
||||
return cg.engine.liveGet("sys", name)
|
||||
case "local":
|
||||
return cg.getLocal(name)
|
||||
default:
|
||||
return cg.engine.liveGet(ds, name)
|
||||
}
|
||||
}
|
||||
|
||||
cg.wg.Add(1)
|
||||
go func() {
|
||||
defer cg.wg.Done()
|
||||
ctx := &runCtx{fired: triggerID, resolve: resolve}
|
||||
cg.follow(triggerID, "out", ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
func (cg *compiledGraph) follow(fromID, port string, ctx *runCtx) {
|
||||
for _, w := range cg.out[fromID] {
|
||||
if w.port == port {
|
||||
cg.run(w.to, ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
|
||||
if ctx.steps > maxSteps {
|
||||
return
|
||||
}
|
||||
ctx.steps++
|
||||
node, ok := cg.byId[nodeID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case <-cg.genCtx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
switch node.Kind {
|
||||
case "gate.and":
|
||||
if cg.gateSatisfied(node.ID, ctx.fired) {
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
}
|
||||
|
||||
case "flow.if":
|
||||
branch := "else"
|
||||
if EvalBool(node.param("cond"), ctx.resolve) {
|
||||
branch = "then"
|
||||
}
|
||||
cg.follow(node.ID, branch, ctx)
|
||||
|
||||
case "flow.loop":
|
||||
if node.param("mode") == "while" {
|
||||
for i := 0; i < maxLoop && ctx.steps <= maxSteps && EvalBool(node.param("cond"), ctx.resolve); i++ {
|
||||
cg.follow(node.ID, "body", ctx)
|
||||
}
|
||||
} else {
|
||||
n := int(EvalExpr(node.param("count"), ctx.resolve))
|
||||
if n < 0 {
|
||||
n = 0
|
||||
}
|
||||
if n > maxLoop {
|
||||
n = maxLoop
|
||||
}
|
||||
for i := 0; i < n && ctx.steps <= maxSteps; i++ {
|
||||
cg.follow(node.ID, "body", ctx)
|
||||
}
|
||||
}
|
||||
cg.follow(node.ID, "done", ctx)
|
||||
|
||||
case "action.write":
|
||||
val := EvalExpr(node.param("expr"), ctx.resolve)
|
||||
cg.engine.write(cg, node.param("target"), val)
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
case "action.delay":
|
||||
ms := 0
|
||||
if v, err := strconv.Atoi(strings.TrimSpace(node.param("ms"))); err == nil && v > 0 {
|
||||
ms = v
|
||||
}
|
||||
if ms > 0 {
|
||||
t := time.NewTimer(time.Duration(ms) * time.Millisecond)
|
||||
select {
|
||||
case <-t.C:
|
||||
case <-cg.genCtx.Done():
|
||||
t.Stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
case "action.log":
|
||||
val := EvalExpr(node.param("expr"), ctx.resolve)
|
||||
label := strings.TrimSpace(node.param("label"))
|
||||
cg.engine.log.Info("control logic log", "graph", cg.name, "label", label, "value", val)
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
case "action.lua":
|
||||
cg.runLua(node.ID, ctx)
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
default:
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// gateSatisfied: every incoming trigger must currently be satisfied. The firing
|
||||
// trigger counts as satisfied; level triggers (threshold/alarm) use their truth.
|
||||
func (cg *compiledGraph) gateSatisfied(gateID, fired string) bool {
|
||||
inputs := cg.inc[gateID]
|
||||
if len(inputs) == 0 {
|
||||
return false
|
||||
}
|
||||
cg.stateMu.Lock()
|
||||
defer cg.stateMu.Unlock()
|
||||
for _, src := range inputs {
|
||||
if src == fired || cg.levelState[src] {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// luaGetRefs scans a Lua script for get("ds:name") / get('ds:name') literals so
|
||||
// the engine can subscribe to the signals the script reads.
|
||||
var luaGetRe = regexp.MustCompile(`get\s*\(\s*["']([^"']+)["']`)
|
||||
|
||||
func luaGetRefs(script string) []RefLite {
|
||||
var out []RefLite
|
||||
for _, m := range luaGetRe.FindAllStringSubmatch(script, -1) {
|
||||
if ds, name, ok := parseRef(m[1]); ok {
|
||||
out = append(out, RefLite{DS: ds, Name: name})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (cg *compiledGraph) runLua(nodeID string, ctx *runCtx) {
|
||||
lr := cg.luaNodes[nodeID]
|
||||
if lr == nil {
|
||||
return
|
||||
}
|
||||
lr.run(ctx.resolve, func(target string, val float64) {
|
||||
cg.engine.write(cg, target, val)
|
||||
}, func(msg string) {
|
||||
cg.engine.log.Info("control logic lua", "graph", cg.name, "msg", msg)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
// Small, safe expression evaluator — a Go port of web/src/lib/expr.ts.
|
||||
//
|
||||
// Supports numbers, booleans (true/false → 1/0), arithmetic (+ - * / %),
|
||||
// comparison (< <= > >= == !=), boolean (&& || !), ternary (a ? b : c),
|
||||
// parentheses, and a handful of math functions. Two kinds of variable
|
||||
// reference are resolved live at evaluation time:
|
||||
//
|
||||
// {ds:name} a data-source signal value (the brace content is split on the
|
||||
// FIRST ':' so EPICS PV names like "MY:PV:NAME" work).
|
||||
// bareIdent a graph-local state variable (data source "local").
|
||||
//
|
||||
// Booleans are represented as numbers: comparisons / logical ops yield 1 or 0,
|
||||
// and any nonzero value is truthy. The evaluator never uses reflection or eval;
|
||||
// it walks a parsed AST against a caller-supplied Resolver.
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Resolver returns the current numeric value of a signal/local reference.
|
||||
type Resolver func(ds, name string) float64
|
||||
|
||||
// RefLite identifies one signal/local reference read by an expression.
|
||||
type RefLite struct {
|
||||
DS string
|
||||
Name string
|
||||
}
|
||||
|
||||
// ── AST ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
type exprNode interface{ eval(R Resolver) float64 }
|
||||
|
||||
type numNode struct{ v float64 }
|
||||
type sigNode struct{ ds, name string }
|
||||
type varNode struct{ name string }
|
||||
type unNode struct {
|
||||
op string
|
||||
a exprNode
|
||||
}
|
||||
type binNode struct {
|
||||
op string
|
||||
a, b exprNode
|
||||
}
|
||||
type ternNode struct{ c, a, b exprNode }
|
||||
type callNode struct {
|
||||
fn string
|
||||
args []exprNode
|
||||
}
|
||||
|
||||
func (n numNode) eval(R Resolver) float64 { return n.v }
|
||||
func (n sigNode) eval(R Resolver) float64 { return R(n.ds, n.name) }
|
||||
func (n varNode) eval(R Resolver) float64 { return R("local", n.name) }
|
||||
func (n unNode) eval(R Resolver) float64 {
|
||||
if n.op == "-" {
|
||||
return -n.a.eval(R)
|
||||
}
|
||||
if n.a.eval(R) == 0 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
func (n ternNode) eval(R Resolver) float64 {
|
||||
if n.c.eval(R) != 0 {
|
||||
return n.a.eval(R)
|
||||
}
|
||||
return n.b.eval(R)
|
||||
}
|
||||
func (n callNode) eval(R Resolver) float64 {
|
||||
args := make([]float64, len(n.args))
|
||||
for i, a := range n.args {
|
||||
args[i] = a.eval(R)
|
||||
}
|
||||
return funcs[n.fn](args)
|
||||
}
|
||||
func (n binNode) eval(R Resolver) float64 {
|
||||
a, b := n.a.eval(R), n.b.eval(R)
|
||||
switch n.op {
|
||||
case "+":
|
||||
return a + b
|
||||
case "-":
|
||||
return a - b
|
||||
case "*":
|
||||
return a * b
|
||||
case "/":
|
||||
return a / b
|
||||
case "%":
|
||||
return math.Mod(a, b)
|
||||
case "<":
|
||||
return boolf(a < b)
|
||||
case "<=":
|
||||
return boolf(a <= b)
|
||||
case ">":
|
||||
return boolf(a > b)
|
||||
case ">=":
|
||||
return boolf(a >= b)
|
||||
case "==":
|
||||
return boolf(a == b)
|
||||
case "!=":
|
||||
return boolf(a != b)
|
||||
case "&&":
|
||||
return boolf(a != 0 && b != 0)
|
||||
case "||":
|
||||
return boolf(a != 0 || b != 0)
|
||||
}
|
||||
return math.NaN()
|
||||
}
|
||||
|
||||
func boolf(b bool) float64 {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
var funcs = map[string]func([]float64) float64{
|
||||
"abs": func(a []float64) float64 { return math.Abs(a[0]) },
|
||||
"min": func(a []float64) float64 { return minSlice(a) },
|
||||
"max": func(a []float64) float64 { return maxSlice(a) },
|
||||
"sqrt": func(a []float64) float64 { return math.Sqrt(a[0]) },
|
||||
"floor": func(a []float64) float64 { return math.Floor(a[0]) },
|
||||
"ceil": func(a []float64) float64 { return math.Ceil(a[0]) },
|
||||
"round": func(a []float64) float64 { return math.Round(a[0]) },
|
||||
"sign": func(a []float64) float64 { return float64(sign(a[0])) },
|
||||
"pow": func(a []float64) float64 { return math.Pow(a[0], a[1]) },
|
||||
"log": func(a []float64) float64 { return math.Log(a[0]) },
|
||||
"exp": func(a []float64) float64 { return math.Exp(a[0]) },
|
||||
"sin": func(a []float64) float64 { return math.Sin(a[0]) },
|
||||
"cos": func(a []float64) float64 { return math.Cos(a[0]) },
|
||||
}
|
||||
|
||||
func minSlice(a []float64) float64 {
|
||||
if len(a) == 0 {
|
||||
return math.Inf(1)
|
||||
}
|
||||
m := a[0]
|
||||
for _, x := range a[1:] {
|
||||
m = math.Min(m, x)
|
||||
}
|
||||
return m
|
||||
}
|
||||
func maxSlice(a []float64) float64 {
|
||||
if len(a) == 0 {
|
||||
return math.Inf(-1)
|
||||
}
|
||||
m := a[0]
|
||||
for _, x := range a[1:] {
|
||||
m = math.Max(m, x)
|
||||
}
|
||||
return m
|
||||
}
|
||||
func sign(x float64) int {
|
||||
switch {
|
||||
case x > 0:
|
||||
return 1
|
||||
case x < 0:
|
||||
return -1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tokenizer ────────────────────────────────────────────────────────────────
|
||||
|
||||
type tok struct {
|
||||
k string
|
||||
v string
|
||||
}
|
||||
|
||||
func tokenize(src string) ([]tok, error) {
|
||||
var toks []tok
|
||||
two := map[string]bool{"<=": true, ">=": true, "==": true, "!=": true, "&&": true, "||": true}
|
||||
r := []rune(src)
|
||||
i := 0
|
||||
for i < len(r) {
|
||||
c := r[i]
|
||||
switch {
|
||||
case c == ' ' || c == '\t' || c == '\n' || c == '\r':
|
||||
i++
|
||||
continue
|
||||
case c == '{':
|
||||
end := -1
|
||||
for j := i + 1; j < len(r); j++ {
|
||||
if r[j] == '}' {
|
||||
end = j
|
||||
break
|
||||
}
|
||||
}
|
||||
if end < 0 {
|
||||
return nil, fmt.Errorf("unterminated { in expression")
|
||||
}
|
||||
toks = append(toks, tok{k: "sig", v: string(r[i+1 : end])})
|
||||
i = end + 1
|
||||
continue
|
||||
}
|
||||
if isDigit(c) || (c == '.' && i+1 < len(r) && isDigit(r[i+1])) {
|
||||
j := i + 1
|
||||
for j < len(r) && (isDigit(r[j]) || r[j] == '.') {
|
||||
j++
|
||||
}
|
||||
toks = append(toks, tok{k: "num", v: string(r[i:j])})
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
if isIdentStart(c) {
|
||||
j := i + 1
|
||||
for j < len(r) && isIdentPart(r[j]) {
|
||||
j++
|
||||
}
|
||||
toks = append(toks, tok{k: "ident", v: string(r[i:j])})
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
if i+1 < len(r) {
|
||||
pair := string(r[i : i+2])
|
||||
if two[pair] {
|
||||
toks = append(toks, tok{k: pair})
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
if strings.ContainsRune("+-*/%<>!()?:,", c) {
|
||||
toks = append(toks, tok{k: string(c)})
|
||||
i++
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected character %q in expression", string(c))
|
||||
}
|
||||
return toks, nil
|
||||
}
|
||||
|
||||
func isDigit(c rune) bool { return c >= '0' && c <= '9' }
|
||||
func isIdentStart(c rune) bool { return c == '_' || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') }
|
||||
func isIdentPart(c rune) bool { return isIdentStart(c) || isDigit(c) }
|
||||
|
||||
// ── Parser (recursive descent) ────────────────────────────────────────────────
|
||||
|
||||
type parser struct {
|
||||
toks []tok
|
||||
p int
|
||||
}
|
||||
|
||||
func (ps *parser) peek() (tok, bool) {
|
||||
if ps.p < len(ps.toks) {
|
||||
return ps.toks[ps.p], true
|
||||
}
|
||||
return tok{}, false
|
||||
}
|
||||
|
||||
func (ps *parser) eat(k string) (tok, error) {
|
||||
if ps.p >= len(ps.toks) {
|
||||
return tok{}, fmt.Errorf("unexpected end of expression")
|
||||
}
|
||||
t := ps.toks[ps.p]
|
||||
if k != "" && t.k != k {
|
||||
return tok{}, fmt.Errorf("expected %q in expression", k)
|
||||
}
|
||||
ps.p++
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func parse(src string) (exprNode, error) {
|
||||
toks, err := tokenize(src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ps := &parser{toks: toks}
|
||||
root, err := ps.ternary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ps.p < len(ps.toks) {
|
||||
return nil, fmt.Errorf("trailing tokens in expression")
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
func (ps *parser) primary() (exprNode, error) {
|
||||
t, ok := ps.peek()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected end of expression")
|
||||
}
|
||||
switch t.k {
|
||||
case "num":
|
||||
ps.eat("")
|
||||
v, err := strconv.ParseFloat(t.v, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bad number %q", t.v)
|
||||
}
|
||||
return numNode{v: v}, nil
|
||||
case "sig":
|
||||
ps.eat("")
|
||||
idx := strings.IndexByte(t.v, ':')
|
||||
if idx < 0 {
|
||||
return sigNode{ds: t.v, name: ""}, nil
|
||||
}
|
||||
return sigNode{ds: t.v[:idx], name: t.v[idx+1:]}, nil
|
||||
case "ident":
|
||||
ps.eat("")
|
||||
id := t.v
|
||||
if id == "true" {
|
||||
return numNode{v: 1}, nil
|
||||
}
|
||||
if id == "false" {
|
||||
return numNode{v: 0}, nil
|
||||
}
|
||||
if nx, ok := ps.peek(); ok && nx.k == "(" {
|
||||
ps.eat("(")
|
||||
var args []exprNode
|
||||
if nx2, ok := ps.peek(); ok && nx2.k != ")" {
|
||||
a, err := ps.ternary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = append(args, a)
|
||||
for {
|
||||
nx3, ok := ps.peek()
|
||||
if !ok || nx3.k != "," {
|
||||
break
|
||||
}
|
||||
ps.eat(",")
|
||||
a, err := ps.ternary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = append(args, a)
|
||||
}
|
||||
}
|
||||
if _, err := ps.eat(")"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := funcs[id]; !ok {
|
||||
return nil, fmt.Errorf("unknown function %q", id)
|
||||
}
|
||||
return callNode{fn: id, args: args}, nil
|
||||
}
|
||||
return varNode{name: id}, nil
|
||||
case "(":
|
||||
ps.eat("(")
|
||||
e, err := ps.ternary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := ps.eat(")"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected token %q in expression", t.k)
|
||||
}
|
||||
|
||||
func (ps *parser) unary() (exprNode, error) {
|
||||
if t, ok := ps.peek(); ok && (t.k == "-" || t.k == "!") {
|
||||
ps.eat("")
|
||||
a, err := ps.unary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return unNode{op: t.k, a: a}, nil
|
||||
}
|
||||
return ps.primary()
|
||||
}
|
||||
|
||||
func (ps *parser) binLevel(next func() (exprNode, error), ops ...string) (exprNode, error) {
|
||||
a, err := next()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for {
|
||||
t, ok := ps.peek()
|
||||
if !ok || !contains(ops, t.k) {
|
||||
return a, nil
|
||||
}
|
||||
op, _ := ps.eat("")
|
||||
b, err := next()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a = binNode{op: op.k, a: a, b: b}
|
||||
}
|
||||
}
|
||||
|
||||
func (ps *parser) mul() (exprNode, error) { return ps.binLevel(ps.unary, "*", "/", "%") }
|
||||
func (ps *parser) add() (exprNode, error) { return ps.binLevel(ps.mul, "+", "-") }
|
||||
func (ps *parser) cmp() (exprNode, error) { return ps.binLevel(ps.add, "<", "<=", ">", ">=") }
|
||||
func (ps *parser) eq() (exprNode, error) { return ps.binLevel(ps.cmp, "==", "!=") }
|
||||
func (ps *parser) and() (exprNode, error) { return ps.binLevel(ps.eq, "&&") }
|
||||
func (ps *parser) or() (exprNode, error) { return ps.binLevel(ps.and, "||") }
|
||||
|
||||
func (ps *parser) ternary() (exprNode, error) {
|
||||
c, err := ps.or()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t, ok := ps.peek(); ok && t.k == "?" {
|
||||
ps.eat("?")
|
||||
a, err := ps.ternary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := ps.eat(":"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := ps.ternary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ternNode{c: c, a: a, b: b}, nil
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func contains(s []string, v string) bool {
|
||||
for _, x := range s {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Cache + public API ─────────────────────────────────────────────────────────
|
||||
|
||||
type cacheEntry struct {
|
||||
node exprNode
|
||||
err error
|
||||
}
|
||||
|
||||
var (
|
||||
cacheMu sync.Mutex
|
||||
cache = map[string]cacheEntry{}
|
||||
)
|
||||
|
||||
func parseCached(src string) (exprNode, error) {
|
||||
cacheMu.Lock()
|
||||
e, ok := cache[src]
|
||||
cacheMu.Unlock()
|
||||
if ok {
|
||||
return e.node, e.err
|
||||
}
|
||||
n, err := parse(src)
|
||||
cacheMu.Lock()
|
||||
cache[src] = cacheEntry{node: n, err: err}
|
||||
cacheMu.Unlock()
|
||||
return n, err
|
||||
}
|
||||
|
||||
// EvalExpr evaluates an expression string, returning NaN on parse/eval failure.
|
||||
func EvalExpr(src string, resolve Resolver) float64 {
|
||||
n, err := parseCached(src)
|
||||
if err != nil {
|
||||
return math.NaN()
|
||||
}
|
||||
return safeEval(n, resolve)
|
||||
}
|
||||
|
||||
func safeEval(n exprNode, resolve Resolver) (out float64) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
out = math.NaN()
|
||||
}
|
||||
}()
|
||||
return n.eval(resolve)
|
||||
}
|
||||
|
||||
// EvalBool reports whether the expression evaluates to a nonzero, non-NaN value.
|
||||
func EvalBool(src string, resolve Resolver) bool {
|
||||
v := EvalExpr(src, resolve)
|
||||
return !math.IsNaN(v) && v != 0
|
||||
}
|
||||
|
||||
// CollectRefs returns every signal/local reference an expression reads, for
|
||||
// subscription. Returns nil for an unparseable expression.
|
||||
func CollectRefs(src string) []RefLite {
|
||||
root, err := parseCached(src)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []RefLite
|
||||
seen := map[string]bool{}
|
||||
add := func(ds, name string) {
|
||||
k := ds + "\x00" + name
|
||||
if !seen[k] {
|
||||
seen[k] = true
|
||||
out = append(out, RefLite{DS: ds, Name: name})
|
||||
}
|
||||
}
|
||||
var walk func(n exprNode)
|
||||
walk = func(n exprNode) {
|
||||
switch t := n.(type) {
|
||||
case sigNode:
|
||||
add(t.ds, t.name)
|
||||
case varNode:
|
||||
add("local", t.name)
|
||||
case unNode:
|
||||
walk(t.a)
|
||||
case binNode:
|
||||
walk(t.a)
|
||||
walk(t.b)
|
||||
case ternNode:
|
||||
walk(t.c)
|
||||
walk(t.a)
|
||||
walk(t.b)
|
||||
case callNode:
|
||||
for _, a := range t.args {
|
||||
walk(a)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
return out
|
||||
}
|
||||
|
||||
// CheckExpr validates an expression; returns an error message or "" if it parses.
|
||||
func CheckExpr(src string) string {
|
||||
if strings.TrimSpace(src) == "" {
|
||||
return ""
|
||||
}
|
||||
if _, err := parse(src); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEvalExpr(t *testing.T) {
|
||||
resolve := func(ds, name string) float64 {
|
||||
switch {
|
||||
case ds == "stub" && name == "x":
|
||||
return 10
|
||||
case ds == "local" && name == "y":
|
||||
return 3
|
||||
case ds == "sys" && name == "dt":
|
||||
return 0.5
|
||||
}
|
||||
return math.NaN()
|
||||
}
|
||||
cases := []struct {
|
||||
expr string
|
||||
want float64
|
||||
}{
|
||||
{"1 + 2 * 3", 7},
|
||||
{"(1 + 2) * 3", 9},
|
||||
{"10 % 3", 1},
|
||||
{"{stub:x} + y", 13},
|
||||
{"{stub:x} > 5 ? 1 : 0", 1},
|
||||
{"min(4, 2, 8)", 2},
|
||||
{"max(4, 2, 8)", 8},
|
||||
{"abs(-5)", 5},
|
||||
{"sqrt(16)", 4},
|
||||
{"2 < 3 && 3 < 4", 1},
|
||||
{"!0", 1},
|
||||
{"-{stub:x}", -10},
|
||||
{"{sys:dt} * 2", 1},
|
||||
{"true + false", 1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := EvalExpr(c.expr, resolve)
|
||||
if math.Abs(got-c.want) > 1e-9 {
|
||||
t.Errorf("EvalExpr(%q) = %v, want %v", c.expr, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalExprErrors(t *testing.T) {
|
||||
r := func(ds, name string) float64 { return 0 }
|
||||
for _, bad := range []string{"1 +", "(1", "1 2", "{unterminated"} {
|
||||
if v := EvalExpr(bad, r); !math.IsNaN(v) {
|
||||
t.Errorf("EvalExpr(%q) = %v, want NaN", bad, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectRefs(t *testing.T) {
|
||||
refs := CollectRefs("{stub:x} + y + {epics:MY:PV} * z")
|
||||
got := map[string]bool{}
|
||||
for _, r := range refs {
|
||||
got[r.DS+":"+r.Name] = true
|
||||
}
|
||||
for _, want := range []string{"stub:x", "local:y", "epics:MY:PV", "local:z"} {
|
||||
if !got[want] {
|
||||
t.Errorf("CollectRefs missing %q (got %v)", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckExpr(t *testing.T) {
|
||||
if msg := CheckExpr("1 + 2"); msg != "" {
|
||||
t.Errorf("CheckExpr valid returned %q", msg)
|
||||
}
|
||||
if msg := CheckExpr("1 +"); msg == "" {
|
||||
t.Errorf("CheckExpr invalid returned empty")
|
||||
}
|
||||
if msg := CheckExpr(""); msg != "" {
|
||||
t.Errorf("CheckExpr empty returned %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpicsRefSplitFirstColon(t *testing.T) {
|
||||
refs := CollectRefs("{epics:SR:BPM:01:X}")
|
||||
if len(refs) != 1 || refs[0].DS != "epics" || refs[0].Name != "SR:BPM:01:X" {
|
||||
t.Errorf("got %+v, want epics / SR:BPM:01:X", refs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// luaRuntime holds a sandboxed Lua VM for one action.lua node. The VM is created
|
||||
// lazily and reused; access is serialised so concurrent flow activations of the
|
||||
// same node don't share VM state unsafely. Host functions exposed to scripts:
|
||||
//
|
||||
// get("ds:name") → number read a signal / sys / local value (NaN if absent)
|
||||
// set("ds:name", v) write a value (bare name → graph-local var)
|
||||
// log(msg) append to the server log
|
||||
//
|
||||
// The os, io, package, debug and code-loading globals are removed, mirroring the
|
||||
// dsp LuaNode sandbox.
|
||||
type luaRuntime struct {
|
||||
script string
|
||||
|
||||
mu sync.Mutex
|
||||
L *lua.LState
|
||||
|
||||
// Bound to the current activation while run() holds mu.
|
||||
curResolve Resolver
|
||||
curSet func(target string, val float64)
|
||||
curLog func(msg string)
|
||||
}
|
||||
|
||||
func newLuaRuntime(script string) *luaRuntime {
|
||||
return &luaRuntime{script: script}
|
||||
}
|
||||
|
||||
func (lr *luaRuntime) ensure() error {
|
||||
if lr.L != nil {
|
||||
return nil
|
||||
}
|
||||
L := lua.NewState(lua.Options{SkipOpenLibs: true})
|
||||
for _, pair := range []struct {
|
||||
name string
|
||||
fn lua.LGFunction
|
||||
}{
|
||||
{lua.LoadLibName, lua.OpenPackage},
|
||||
{lua.BaseLibName, lua.OpenBase},
|
||||
{lua.MathLibName, lua.OpenMath},
|
||||
{lua.StringLibName, lua.OpenString},
|
||||
{lua.TabLibName, lua.OpenTable},
|
||||
} {
|
||||
if err := L.CallByParam(lua.P{Fn: L.NewFunction(pair.fn), NRet: 0, Protect: true}, lua.LString(pair.name)); err != nil {
|
||||
L.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
L.SetGlobal("load", lua.LNil)
|
||||
L.SetGlobal("loadfile", lua.LNil)
|
||||
L.SetGlobal("dofile", lua.LNil)
|
||||
L.SetGlobal("require", lua.LNil)
|
||||
|
||||
L.SetGlobal("get", L.NewFunction(func(s *lua.LState) int {
|
||||
target := s.CheckString(1)
|
||||
ds, name, ok := parseRef(target)
|
||||
var v float64
|
||||
if ok && lr.curResolve != nil {
|
||||
v = lr.curResolve(ds, name)
|
||||
}
|
||||
s.Push(lua.LNumber(v))
|
||||
return 1
|
||||
}))
|
||||
L.SetGlobal("set", L.NewFunction(func(s *lua.LState) int {
|
||||
target := s.CheckString(1)
|
||||
val := float64(s.CheckNumber(2))
|
||||
if lr.curSet != nil {
|
||||
lr.curSet(target, val)
|
||||
}
|
||||
return 0
|
||||
}))
|
||||
L.SetGlobal("log", L.NewFunction(func(s *lua.LState) int {
|
||||
if lr.curLog != nil {
|
||||
lr.curLog(s.CheckString(1))
|
||||
}
|
||||
return 0
|
||||
}))
|
||||
|
||||
lr.L = L
|
||||
return nil
|
||||
}
|
||||
|
||||
// run executes the script once with the given host hooks bound.
|
||||
func (lr *luaRuntime) run(resolve Resolver, set func(string, float64), logf func(string)) {
|
||||
lr.mu.Lock()
|
||||
defer lr.mu.Unlock()
|
||||
|
||||
if err := lr.ensure(); err != nil {
|
||||
logf("lua init error: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
lr.curResolve = resolve
|
||||
lr.curSet = set
|
||||
lr.curLog = logf
|
||||
defer func() {
|
||||
lr.curResolve = nil
|
||||
lr.curSet = nil
|
||||
lr.curLog = nil
|
||||
if r := recover(); r != nil {
|
||||
logf("lua panic")
|
||||
}
|
||||
}()
|
||||
|
||||
lr.L.SetTop(0)
|
||||
if err := lr.L.DoString(lr.script); err != nil {
|
||||
logf("lua error: " + err.Error())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Package controllogic implements a server-side flow-graph engine. It mirrors
|
||||
// the client-side panel logic engine (web/src/lib/logic.ts) but runs
|
||||
// continuously on the server under the root context, independent of any panel.
|
||||
//
|
||||
// A control-logic graph is a Node-RED-style flow of trigger, gate, control-flow
|
||||
// and action nodes connected by wires. Triggers are flow entry points; when one
|
||||
// activates the engine follows the outgoing wires executing downstream nodes.
|
||||
//
|
||||
// Compared with the panel engine, control logic has no button trigger (buttons
|
||||
// are panel-UI driven) and gains two headless triggers — cron (5-field schedule)
|
||||
// and alarm (signal out of an allowed range) — plus a Lua script action node
|
||||
// with get/set/log host functions.
|
||||
//
|
||||
// Graphs are persisted as JSON in {storageDir}/controllogic.json and CRUD'd via
|
||||
// the REST API; the engine rebuilds itself (Reload) whenever they change.
|
||||
package controllogic
|
||||
|
||||
// Node kinds. Triggers begin flows; the rest execute downstream.
|
||||
//
|
||||
// trigger.threshold — fires on the rising edge of cmp(signal, value).
|
||||
// trigger.change — fires whenever a watched signal's value changes.
|
||||
// trigger.timer — fires every `interval` ms.
|
||||
// trigger.cron — fires when the wall clock matches a 5-field cron `spec`.
|
||||
// trigger.alarm — fires on the rising edge of signal leaving [min,max].
|
||||
// gate.and — passes only when all incoming triggers are satisfied.
|
||||
// flow.if — evaluates `cond`, continues on the 'then'/'else' port.
|
||||
// flow.loop — repeats the 'body' port (count / while, capped) then 'done'.
|
||||
// action.write — evaluates `expr`, writes the result to `target`.
|
||||
// action.delay — waits `ms` before continuing.
|
||||
// action.log — logs an expression value to the server log.
|
||||
// action.lua — runs a sandboxed Lua script with get/set/log host funcs.
|
||||
|
||||
// Node is a single node in a control-logic graph. Params are stored as strings
|
||||
// (matching the panel logic model) and parsed per-kind by the engine.
|
||||
type Node struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Params map[string]string `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
// Wire connects an output port of one node to the input of another.
|
||||
// FromPort defaults to "out" when empty.
|
||||
type Wire struct {
|
||||
From string `json:"from"`
|
||||
FromPort string `json:"fromPort,omitempty"`
|
||||
To string `json:"to"`
|
||||
}
|
||||
|
||||
// Graph is a named, independently-enableable control-logic flow.
|
||||
type Graph struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Nodes []Node `json:"nodes"`
|
||||
Wires []Wire `json:"wires"`
|
||||
}
|
||||
|
||||
func (n Node) param(key string) string {
|
||||
if n.Params == nil {
|
||||
return ""
|
||||
}
|
||||
return n.Params[key]
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const definitionsFile = "controllogic.json"
|
||||
|
||||
// ErrNotFound is returned when a graph id does not exist.
|
||||
var ErrNotFound = errors.New("control logic graph not found")
|
||||
|
||||
// Store persists control-logic graphs as a single JSON file in the storage dir.
|
||||
// Writes are atomic (tmp file + rename); all access is mutex-guarded.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
items map[string]Graph
|
||||
}
|
||||
|
||||
// NewStore opens (or initialises) the control-logic store under storageDir.
|
||||
func NewStore(storageDir string) (*Store, error) {
|
||||
s := &Store{
|
||||
path: filepath.Join(storageDir, definitionsFile),
|
||||
items: map[string]Graph{},
|
||||
}
|
||||
if err := s.load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) load() error {
|
||||
data, err := os.ReadFile(s.path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var graphs []Graph
|
||||
if err := json.Unmarshal(data, &graphs); err != nil {
|
||||
return fmt.Errorf("parse %s: %w", s.path, err)
|
||||
}
|
||||
for _, g := range graphs {
|
||||
s.items[g.ID] = g
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveLocked writes the current set atomically. Caller must hold s.mu.
|
||||
func (s *Store) saveLocked() error {
|
||||
graphs := make([]Graph, 0, len(s.items))
|
||||
for _, g := range s.items {
|
||||
graphs = append(graphs, g)
|
||||
}
|
||||
data, err := json.MarshalIndent(graphs, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, s.path)
|
||||
}
|
||||
|
||||
// List returns all graphs.
|
||||
func (s *Store) List() []Graph {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]Graph, 0, len(s.items))
|
||||
for _, g := range s.items {
|
||||
out = append(out, g)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Get returns a single graph by id.
|
||||
func (s *Store) Get(id string) (Graph, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
g, ok := s.items[id]
|
||||
if !ok {
|
||||
return Graph{}, ErrNotFound
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// Save inserts or replaces a graph and persists the store.
|
||||
func (s *Store) Save(g Graph) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.items[g.ID] = g
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
// Delete removes a graph by id.
|
||||
func (s *Store) Delete(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.items[id]; !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
delete(s.items, id)
|
||||
return s.saveLocked()
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
@@ -117,6 +118,55 @@ func fetchArchiveHistory(ctx context.Context, archiveURL, signal string, start,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// searchArchivePVs queries the Archive Appliance management API for PV names
|
||||
// matching the given pattern (glob style, e.g. "PV:*").
|
||||
func searchArchivePVs(ctx context.Context, archiveURL, pattern string) ([]string, error) {
|
||||
if archiveURL == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Build the request URL.
|
||||
// Format: GET {archiveURL}/mgmt/bpl/getAllPVs?pv={pattern}
|
||||
reqURL, err := url.Parse(archiveURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("epics archive: invalid archive URL %q: %w", archiveURL, err)
|
||||
}
|
||||
reqURL = reqURL.JoinPath("mgmt", "bpl", "getAllPVs")
|
||||
|
||||
q := reqURL.Query()
|
||||
if pattern == "" {
|
||||
pattern = "*"
|
||||
}
|
||||
// AA expects glob patterns. If no glob characters, wrap in stars.
|
||||
if !strings.ContainsAny(pattern, "*?[]") {
|
||||
pattern = "*" + pattern + "*"
|
||||
}
|
||||
q.Set("pv", pattern)
|
||||
reqURL.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("epics archive: building request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("epics archive: HTTP request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("epics archive: unexpected status %d for search %q", resp.StatusCode, pattern)
|
||||
}
|
||||
|
||||
var pvs []string
|
||||
if err := json.NewDecoder(resp.Body).Decode(&pvs); err != nil {
|
||||
return nil, fmt.Errorf("epics archive: decoding response: %w", err)
|
||||
}
|
||||
|
||||
return pvs, nil
|
||||
}
|
||||
|
||||
// coerceArchiveValue converts the raw JSON value (which json.Unmarshal decodes
|
||||
// as float64, string, bool, []interface{}, or nil) into one of the types
|
||||
// accepted by datasource.Value.Data.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package epics
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// cfChannel is the JSON structure returned by the Channel Finder Service.
|
||||
type cfChannel struct {
|
||||
Name string `json:"name"`
|
||||
Owner string `json:"owner"`
|
||||
Properties []cfProperty `json:"properties"`
|
||||
Tags []cfTag `json:"tags"`
|
||||
}
|
||||
|
||||
type cfProperty struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Owner string `json:"owner"`
|
||||
}
|
||||
|
||||
type cfTag struct {
|
||||
Name string `json:"name"`
|
||||
Owner string `json:"owner"`
|
||||
}
|
||||
|
||||
// queryChannelFinder fetches channels from CFS matching the given query string.
|
||||
func queryChannelFinder(cfURL, query string) ([]cfChannel, error) {
|
||||
if cfURL == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
url := strings.TrimRight(cfURL, "/") + "/resources/channels"
|
||||
if query != "" {
|
||||
if strings.Contains(query, "=") || strings.Contains(query, "~") {
|
||||
// Query is already a structured filter (e.g. area=L1)
|
||||
url += "?" + query
|
||||
} else {
|
||||
// Query is just a PV name pattern
|
||||
url += "?~name=*" + query + "*"
|
||||
}
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
slog.Info("CFS query", "url", url)
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CFS request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("CFS returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var channels []cfChannel
|
||||
if err := json.NewDecoder(resp.Body).Decode(&channels); err != nil {
|
||||
return nil, fmt.Errorf("CFS response parse error: %w", err)
|
||||
}
|
||||
slog.Info("CFS results", "count", len(channels))
|
||||
|
||||
return channels, nil
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package epics_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/goca/proto"
|
||||
"github.com/uopi/goca/testca"
|
||||
"github.com/uopi/uopi/internal/datasource/epics"
|
||||
)
|
||||
|
||||
func TestDiscovery_EnvironmentVariables(t *testing.T) {
|
||||
// 1. Setup a fake CA server.
|
||||
srv, err := testca.New([]testca.PVSpec{
|
||||
{Name: "DISCO:PV", DBFType: proto.DBFDouble, Count: 1, Value: 123.45},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("testca.New: %v", err)
|
||||
}
|
||||
defer srv.Close()
|
||||
|
||||
// 2. Set the environment variable to point to this server.
|
||||
os.Setenv("EPICS_CA_ADDR_LIST", srv.UDPAddr())
|
||||
os.Setenv("EPICS_CA_AUTO_ADDR_LIST", "NO")
|
||||
defer os.Unsetenv("EPICS_CA_ADDR_LIST")
|
||||
defer os.Unsetenv("EPICS_CA_AUTO_ADDR_LIST")
|
||||
|
||||
// 3. Create datasource with NO explicit address (should pick up from env).
|
||||
ds := epics.New("", "", "", "", false, nil)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := ds.Connect(ctx); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
|
||||
// 4. Verify discovery works.
|
||||
meta, err := ds.GetMetadata(ctx, "DISCO:PV")
|
||||
if err != nil {
|
||||
t.Fatalf("GetMetadata failed (discovery failed): %v", err)
|
||||
}
|
||||
if meta.Name != "DISCO:PV" {
|
||||
t.Errorf("got %q, want DISCO:PV", meta.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscovery_ConfigOverride(t *testing.T) {
|
||||
// 1. Setup TWO fake CA servers.
|
||||
srv1, _ := testca.New([]testca.PVSpec{{Name: "PV:1", DBFType: proto.DBFDouble, Value: 1.0}})
|
||||
defer srv1.Close()
|
||||
srv2, _ := testca.New([]testca.PVSpec{{Name: "PV:2", DBFType: proto.DBFDouble, Value: 2.0}})
|
||||
defer srv2.Close()
|
||||
|
||||
// 2. Point env to srv1.
|
||||
os.Setenv("EPICS_CA_ADDR_LIST", srv1.UDPAddr())
|
||||
os.Setenv("EPICS_CA_AUTO_ADDR_LIST", "NO")
|
||||
defer os.Unsetenv("EPICS_CA_ADDR_LIST")
|
||||
defer os.Unsetenv("EPICS_CA_AUTO_ADDR_LIST")
|
||||
|
||||
// 3. Create datasource pointing explicitly to srv2 (should OVERRIDE env).
|
||||
ds := epics.New(srv2.UDPAddr(), "", "", "", false, nil)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := ds.Connect(ctx); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
|
||||
// 4. PV from srv2 should be found.
|
||||
if _, err := ds.GetMetadata(ctx, "PV:2"); err != nil {
|
||||
t.Errorf("PV:2 from config address not found: %v", err)
|
||||
}
|
||||
|
||||
// 5. PV from srv1 should NOT be found (since AutoAddrList is disabled by config override).
|
||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel2()
|
||||
if _, err := ds.GetMetadata(ctx2, "PV:1"); err == nil {
|
||||
t.Error("PV:1 from env address was found, but config should have overridden it")
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import "C"
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -66,10 +67,14 @@ type caChannel struct {
|
||||
|
||||
// EPICS is the Channel Access data source.
|
||||
type EPICS struct {
|
||||
caAddrList string
|
||||
archiveURL string
|
||||
caAddrList string
|
||||
archiveURL string
|
||||
cfURL string
|
||||
autoSyncFilter string
|
||||
autoSyncFromArchiver bool
|
||||
|
||||
// caCtx is the CA context created in Connect(). Stored as unsafe.Pointer
|
||||
// caCtx is the CA context created in Connect().
|
||||
Stored as unsafe.Pointer
|
||||
// because the C type (ca_client_context *) is opaque. Every goroutine
|
||||
// that calls CA functions must call caAttachContext(caCtx) first, because
|
||||
// Go goroutines can run on any OS thread and CA contexts are thread-local.
|
||||
@@ -87,12 +92,15 @@ type EPICS struct {
|
||||
// Appliance instance for history queries (may be empty).
|
||||
// pvNames is accepted for API compatibility with the pure-Go build but ignored
|
||||
// in the CGo build (the CGo implementation populates ListSignals via callbacks).
|
||||
func New(caAddrList, archiveURL string, pvNames []string) datasource.DataSource {
|
||||
func New(caAddrList, archiveURL, cfURL, autoSyncFilter string, autoSyncFromArchiver bool, pvNames []string) datasource.DataSource {
|
||||
return &EPICS{
|
||||
caAddrList: caAddrList,
|
||||
archiveURL: archiveURL,
|
||||
channels: make(map[string]*caChannel),
|
||||
metadata: make(map[string]datasource.Metadata),
|
||||
caAddrList: caAddrList,
|
||||
archiveURL: archiveURL,
|
||||
cfURL: cfURL,
|
||||
autoSyncFilter: autoSyncFilter,
|
||||
autoSyncFromArchiver: autoSyncFromArchiver,
|
||||
channels: make(map[string]*caChannel),
|
||||
metadata: make(map[string]datasource.Metadata),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,9 +130,62 @@ func (e *EPICS) Connect(_ context.Context) error {
|
||||
}
|
||||
// Save the context so goroutines on other OS threads can attach to it.
|
||||
e.caCtx = C.caCurrentContext()
|
||||
|
||||
// Perform background sync from Channel Finder if configured.
|
||||
if e.cfURL != "" && e.autoSyncFilter != "" {
|
||||
go e.syncFromChannelFinder(context.Background())
|
||||
}
|
||||
|
||||
// Perform background sync from Archive Appliance if configured.
|
||||
if e.archiveURL != "" && e.autoSyncFromArchiver {
|
||||
go e.syncFromArchiver(context.Background())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *EPICS) syncFromChannelFinder(ctx context.Context) {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
slog.Info("epics: syncing signals from Channel Finder", "url", e.cfURL, "filter", e.autoSyncFilter)
|
||||
channels, err := queryChannelFinder(e.cfURL, e.autoSyncFilter)
|
||||
if err != nil {
|
||||
slog.Error("epics: Channel Finder sync failed", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, ch := range channels {
|
||||
// Convert CFS properties and tags
|
||||
props := make(map[string]string)
|
||||
for _, p := range ch.Properties {
|
||||
props[p.Name] = p.Value
|
||||
}
|
||||
tags := make([]string, len(ch.Tags))
|
||||
for i, t := range ch.Tags {
|
||||
tags[i] = t.Name
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
if _, exists := e.metadata[ch.Name]; !exists {
|
||||
e.metadata[ch.Name] = datasource.Metadata{
|
||||
Name: ch.Name,
|
||||
Properties: props,
|
||||
Tags: tags,
|
||||
}
|
||||
}
|
||||
e.mu.Unlock()
|
||||
|
||||
// Background pre-fetch of actual record info
|
||||
go e.prefetchPV(ctx, ch.Name)
|
||||
}
|
||||
slog.Info("epics: synced signals from Channel Finder", "count", len(channels))
|
||||
}
|
||||
|
||||
func (e *EPICS) prefetchPV(ctx context.Context, pv string) {
|
||||
mctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
_, _ = e.GetMetadata(mctx, pv)
|
||||
}
|
||||
|
||||
// attachCAContext attaches the saved CA context to the calling OS thread.
|
||||
// Must be called at the top of every goroutine that uses CA functions,
|
||||
// because Go goroutines can be scheduled onto any OS thread.
|
||||
@@ -342,10 +403,10 @@ func (e *EPICS) GetMetadata(ctx context.Context, signal string) (datasource.Meta
|
||||
|
||||
// fetchMetadata retrieves metadata from a connected chid using ca_get.
|
||||
func (e *EPICS) fetchMetadata(chid C.chid, name string) datasource.Metadata {
|
||||
// In EPICS, write access is governed by CA security (host/user rules), not
|
||||
// by the field type. Default to writable=true; the IOC will reject puts
|
||||
// that violate its security policy.
|
||||
meta := datasource.Metadata{Name: name, Writable: true}
|
||||
// ca_write_access returns 1 if CA security permits puts from this client.
|
||||
// This reflects the IOC's actual security policy (host/user ACLs), so we
|
||||
// use it directly rather than defaulting to true and relying on put failures.
|
||||
meta := datasource.Metadata{Name: name, Writable: C.ca_write_access(chid) != 0}
|
||||
|
||||
fieldType := C.ca_field_type(chid)
|
||||
count := C.ca_element_count(chid)
|
||||
@@ -422,13 +483,12 @@ func (e *EPICS) fetchMetadata(chid C.chid, name string) datasource.Metadata {
|
||||
}
|
||||
|
||||
// ListSignals returns metadata for all currently tracked channels.
|
||||
// Channels with cached metadata return full info; others return a stub entry
|
||||
// with the PV name so the signal tree can display them immediately after
|
||||
// a manual add, before GetMetadata has been called.
|
||||
func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
slog.Debug("epics: listing signals", "count", len(e.metadata))
|
||||
|
||||
// Start with all fully-fetched metadata entries.
|
||||
out := make([]datasource.Metadata, 0, len(e.channels))
|
||||
seen := make(map[string]bool, len(e.metadata))
|
||||
@@ -448,6 +508,11 @@ func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
|
||||
// Write puts a new value onto a CA channel.
|
||||
// If the signal is not currently subscribed (e.g. a button in oneshot mode),
|
||||
// a temporary CA channel is created, used for the put, then torn down.
|
||||
//
|
||||
// NOTE: per-session end-user identity (datasource.WithUser) is NOT honoured in
|
||||
// the CGo/libca build: libca uses a single process-wide CA context whose client
|
||||
// name is fixed at startup. Writes therefore use the server identity here. Use
|
||||
// the default pure-Go build for per-user write attribution.
|
||||
func (e *EPICS) Write(ctx context.Context, signal string, value any) error {
|
||||
e.attachCAContext()
|
||||
|
||||
@@ -583,3 +648,27 @@ func nativeTimeType(chid C.chid) C.chtype {
|
||||
return C.DBR_TIME_DOUBLE
|
||||
}
|
||||
}
|
||||
|
||||
func (e *EPICS) syncFromArchiver(ctx context.Context) {
|
||||
time.Sleep(1 * time.Second) // wait for network
|
||||
slog.Info("epics: syncing signals from Archiver", "url", e.archiveURL)
|
||||
pvs, err := searchArchivePVs(ctx, e.archiveURL, "*")
|
||||
if err != nil {
|
||||
slog.Error("epics: Archiver sync failed", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, name := range pvs {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
e.mu.Lock()
|
||||
if _, exists := e.metadata[name]; !exists {
|
||||
e.metadata[name] = datasource.Metadata{Name: name}
|
||||
}
|
||||
e.mu.Unlock()
|
||||
// Background pre-fetch of record info
|
||||
go e.prefetchPV(ctx, name)
|
||||
}
|
||||
slog.Info("epics: synced signals from Archiver", "count", len(pvs))
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ func newTestDS(t *testing.T, pvs []testca.PVSpec) (datasource.DataSource, *testc
|
||||
}
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
ds := epics.New(srv.UDPAddr(), "", nil)
|
||||
ds := epics.New(srv.UDPAddr(), "", "", "", false, nil)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
if err := ds.Connect(ctx); err != nil {
|
||||
|
||||
@@ -28,16 +28,40 @@ func Available() bool { return true }
|
||||
|
||||
// EPICS is the pure-Go Channel Access data source.
|
||||
type EPICS struct {
|
||||
caAddrList string
|
||||
archiveURL string
|
||||
pvNames []string // pre-fetched at connect time for ListSignals
|
||||
caAddrList string
|
||||
archiveURL string
|
||||
cfURL string
|
||||
autoSyncFilter string
|
||||
autoSyncFromArchiver bool
|
||||
pvNames []string // pre-fetched at connect time for ListSignals
|
||||
|
||||
client *ca.Client
|
||||
|
||||
mu sync.RWMutex
|
||||
metadata map[string]datasource.Metadata
|
||||
|
||||
// Per-user CA clients for writes. EPICS Channel Access carries the client
|
||||
// username at the circuit (TCP) level, so attributing a write to a specific
|
||||
// end-user requires a dedicated client whose ClientName is that user. These
|
||||
// are created lazily on first write by each user and evicted when idle.
|
||||
ctx context.Context // datasource lifetime; governs per-user clients
|
||||
baseCfg ca.Config // template config for per-user clients
|
||||
selfName string // the server's own CA client name
|
||||
userMu sync.Mutex
|
||||
userClients map[string]*userClient
|
||||
}
|
||||
|
||||
// userClient is a per-end-user CA client and its last-use time for idle eviction.
|
||||
type userClient struct {
|
||||
cli *ca.Client
|
||||
lastUsed time.Time
|
||||
}
|
||||
|
||||
// userClientIdleTTL is how long an idle per-user CA client is kept before being
|
||||
// closed. Writes are bursty, so a few minutes avoids reconnecting on every put
|
||||
// without holding circuits open indefinitely.
|
||||
const userClientIdleTTL = 10 * time.Minute
|
||||
|
||||
// New creates a new EPICS Channel Access data source.
|
||||
//
|
||||
// caAddrList is the EPICS_CA_ADDR_LIST value (space-separated addresses).
|
||||
@@ -47,12 +71,15 @@ type EPICS struct {
|
||||
// pvNames is an optional list of PV names whose metadata will be pre-fetched
|
||||
// at connect time so they appear in ListSignals immediately (useful for the
|
||||
// edit-mode signal tree).
|
||||
func New(caAddrList, archiveURL string, pvNames []string) datasource.DataSource {
|
||||
func New(caAddrList, archiveURL, cfURL, autoSyncFilter string, autoSyncFromArchiver bool, pvNames []string) datasource.DataSource {
|
||||
return &EPICS{
|
||||
caAddrList: caAddrList,
|
||||
archiveURL: archiveURL,
|
||||
pvNames: pvNames,
|
||||
metadata: make(map[string]datasource.Metadata),
|
||||
caAddrList: caAddrList,
|
||||
archiveURL: archiveURL,
|
||||
cfURL: cfURL,
|
||||
autoSyncFilter: autoSyncFilter,
|
||||
autoSyncFromArchiver: autoSyncFromArchiver,
|
||||
pvNames: pvNames,
|
||||
metadata: make(map[string]datasource.Metadata),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,31 +100,31 @@ func (e *EPICS) Connect(ctx context.Context) error {
|
||||
return fmt.Errorf("epics: %w", err)
|
||||
}
|
||||
e.client = cli
|
||||
slog.Info("epics: CA client started", "addrs", cfg.AddrList)
|
||||
e.ctx = ctx
|
||||
e.baseCfg = cfg
|
||||
e.selfName = cfg.ClientName
|
||||
e.userClients = make(map[string]*userClient)
|
||||
go e.evictIdleUserClients(ctx)
|
||||
slog.Info("epics: CA client started", "addrs", cfg.AddrList, "client_name", cfg.ClientName)
|
||||
|
||||
// Pre-fetch metadata for any configured PV names so they appear in
|
||||
// ListSignals as soon as the datasource is ready. We open a short-lived
|
||||
// subscription (which drives channel connection) and then fetch CTRL info.
|
||||
// Perform background sync from Channel Finder if configured.
|
||||
if e.cfURL != "" && e.autoSyncFilter != "" {
|
||||
go e.syncFromChannelFinder(ctx)
|
||||
}
|
||||
|
||||
// Perform background sync from Archive Appliance if configured.
|
||||
if e.archiveURL != "" && e.autoSyncFromArchiver {
|
||||
go e.syncFromArchiver(ctx)
|
||||
}
|
||||
|
||||
// Pre-fetch metadata for any configured PV names.
|
||||
if len(e.pvNames) > 0 {
|
||||
go func() {
|
||||
for _, pv := range e.pvNames {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
func() {
|
||||
tvCh := make(chan proto.TimeValue, 1)
|
||||
subCtx, subCancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer subCancel()
|
||||
cancel, err := e.client.Subscribe(subCtx, pv, tvCh)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer cancel()
|
||||
// Channel is now connected; fetch CTRL metadata.
|
||||
mctx, mcancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer mcancel()
|
||||
_, _ = e.GetMetadata(mctx, pv)
|
||||
}()
|
||||
e.prefetchPV(ctx, pv)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -105,6 +132,62 @@ func (e *EPICS) Connect(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *EPICS) syncFromChannelFinder(ctx context.Context) {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
slog.Info("epics: syncing signals from Channel Finder", "url", e.cfURL, "filter", e.autoSyncFilter)
|
||||
channels, err := queryChannelFinder(e.cfURL, e.autoSyncFilter)
|
||||
if err != nil {
|
||||
slog.Error("epics: Channel Finder sync failed", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, ch := range channels {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
// Convert CFS properties and tags to datasource.Metadata
|
||||
props := make(map[string]string)
|
||||
for _, p := range ch.Properties {
|
||||
props[p.Name] = p.Value
|
||||
}
|
||||
tags := make([]string, len(ch.Tags))
|
||||
for i, t := range ch.Tags {
|
||||
tags[i] = t.Name
|
||||
}
|
||||
|
||||
// Initialize metadata with CFS info. Full metadata (type, units, etc.)
|
||||
// will be populated on first access via DBR_CTRL GET.
|
||||
e.mu.Lock()
|
||||
if _, exists := e.metadata[ch.Name]; !exists {
|
||||
e.metadata[ch.Name] = datasource.Metadata{
|
||||
Name: ch.Name,
|
||||
Properties: props,
|
||||
Tags: tags,
|
||||
}
|
||||
}
|
||||
e.mu.Unlock()
|
||||
|
||||
// Background pre-fetch of actual record info (type, etc.)
|
||||
go e.prefetchPV(ctx, ch.Name)
|
||||
}
|
||||
slog.Info("epics: synced signals from Channel Finder", "count", len(channels))
|
||||
}
|
||||
|
||||
func (e *EPICS) prefetchPV(ctx context.Context, pv string) {
|
||||
tvCh := make(chan proto.TimeValue, 1)
|
||||
subCtx, subCancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer subCancel()
|
||||
cancel, err := e.client.Subscribe(subCtx, pv, tvCh)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer cancel()
|
||||
// Channel is now connected; fetch CTRL metadata.
|
||||
mctx, mcancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer mcancel()
|
||||
_, _ = e.GetMetadata(mctx, pv)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Subscribe //
|
||||
// -------------------------------------------------------------------------- //
|
||||
@@ -273,6 +356,7 @@ func (e *EPICS) ctrlToMeta(name string, ci ca.CtrlInfo) datasource.Metadata {
|
||||
func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
slog.Debug("epics: listing signals", "count", len(e.metadata))
|
||||
out := make([]datasource.Metadata, 0, len(e.metadata))
|
||||
for _, m := range e.metadata {
|
||||
out = append(out, m)
|
||||
@@ -286,13 +370,70 @@ func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
|
||||
|
||||
// Write puts a new value onto the named CA channel.
|
||||
// value must be one of: float64, float32, int64, int32, int, int16, string, bool.
|
||||
//
|
||||
// When the request context carries an end-user identity (see datasource.WithUser)
|
||||
// that differs from the server's own CA client name, the put is performed on a
|
||||
// dedicated CA client whose ClientName is that user, so the IOC's access-security
|
||||
// rules see the actual end-user rather than the server process.
|
||||
func (e *EPICS) Write(ctx context.Context, signal string, value any) error {
|
||||
if err := e.client.Put(ctx, signal, value); err != nil {
|
||||
cli := e.client
|
||||
if user, ok := datasource.UserFrom(ctx); ok && user != e.selfName {
|
||||
uc, err := e.clientForUser(user)
|
||||
if err != nil {
|
||||
return fmt.Errorf("epics: write %q as %q: %w", signal, user, err)
|
||||
}
|
||||
cli = uc
|
||||
}
|
||||
if err := cli.Put(ctx, signal, value); err != nil {
|
||||
return fmt.Errorf("epics: write %q: %w", signal, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clientForUser returns a CA client whose ClientName is user, creating and
|
||||
// caching one on first use. The client is bound to the datasource lifetime ctx,
|
||||
// not the per-request ctx, so it survives across writes until evicted when idle.
|
||||
func (e *EPICS) clientForUser(user string) (*ca.Client, error) {
|
||||
e.userMu.Lock()
|
||||
defer e.userMu.Unlock()
|
||||
if uc, ok := e.userClients[user]; ok {
|
||||
uc.lastUsed = time.Now()
|
||||
return uc.cli, nil
|
||||
}
|
||||
cfg := e.baseCfg
|
||||
cfg.ClientName = user
|
||||
cli, err := ca.NewClient(e.ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.userClients[user] = &userClient{cli: cli, lastUsed: time.Now()}
|
||||
slog.Info("epics: created per-user CA client", "user", user)
|
||||
return cli, nil
|
||||
}
|
||||
|
||||
// evictIdleUserClients closes per-user CA clients that have not been used within
|
||||
// userClientIdleTTL. It runs until ctx is cancelled.
|
||||
func (e *EPICS) evictIdleUserClients(ctx context.Context) {
|
||||
t := time.NewTicker(time.Minute)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case now := <-t.C:
|
||||
e.userMu.Lock()
|
||||
for user, uc := range e.userClients {
|
||||
if now.Sub(uc.lastUsed) > userClientIdleTTL {
|
||||
uc.cli.Close()
|
||||
delete(e.userClients, user)
|
||||
slog.Info("epics: evicted idle per-user CA client", "user", user)
|
||||
}
|
||||
}
|
||||
e.userMu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// History //
|
||||
// -------------------------------------------------------------------------- //
|
||||
@@ -305,3 +446,27 @@ func (e *EPICS) History(ctx context.Context, signal string, start, end time.Time
|
||||
}
|
||||
return fetchArchiveHistory(ctx, e.archiveURL, signal, start, end, maxPoints)
|
||||
}
|
||||
|
||||
func (e *EPICS) syncFromArchiver(ctx context.Context) {
|
||||
time.Sleep(1 * time.Second) // wait for network
|
||||
slog.Info("epics: syncing signals from Archiver", "url", e.archiveURL)
|
||||
pvs, err := searchArchivePVs(ctx, e.archiveURL, "*")
|
||||
if err != nil {
|
||||
slog.Error("epics: Archiver sync failed", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, name := range pvs {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
e.mu.Lock()
|
||||
if _, exists := e.metadata[name]; !exists {
|
||||
e.metadata[name] = datasource.Metadata{Name: name}
|
||||
}
|
||||
e.mu.Unlock()
|
||||
// Background pre-fetch of record info
|
||||
go e.prefetchPV(ctx, name)
|
||||
}
|
||||
slog.Info("epics: synced signals from Archiver", "count", len(pvs))
|
||||
}
|
||||
|
||||
@@ -60,6 +60,8 @@ type Metadata struct {
|
||||
DriveHigh float64
|
||||
EnumStrings []string // non-nil only for TypeEnum
|
||||
Writable bool
|
||||
Properties map[string]string // Channel Finder properties
|
||||
Tags []string // Channel Finder tags
|
||||
}
|
||||
|
||||
// CancelFunc cancels a subscription started by DataSource.Subscribe.
|
||||
|
||||
@@ -5,6 +5,7 @@ package stub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"sync"
|
||||
@@ -13,11 +14,12 @@ import (
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
const updateInterval = 100 * time.Millisecond // 10 Hz
|
||||
const updateInterval = 100 * time.Millisecond // 10 Hz default
|
||||
|
||||
type signalDef struct {
|
||||
meta datasource.Metadata
|
||||
fn func(t time.Time) any
|
||||
meta datasource.Metadata
|
||||
interval time.Duration // 0 → updateInterval
|
||||
fn func(t time.Time) any
|
||||
}
|
||||
|
||||
// Stub is a data source that emits canned signals for development and testing.
|
||||
@@ -96,6 +98,41 @@ func New() *Stub {
|
||||
fn: func(t time.Time) any { return 25.0 }, // default; overwritten by Write
|
||||
})
|
||||
s.values["setpoint"] = 25.0
|
||||
s.register(signalDef{
|
||||
meta: datasource.Metadata{
|
||||
Name: "counter_fast", Type: datasource.TypeFloat64,
|
||||
Description: "Fast monotonic counter (1 ms tick) for benchmarks",
|
||||
},
|
||||
interval: time.Millisecond,
|
||||
fn: func(t time.Time) any { return float64(t.UnixMilli()) },
|
||||
})
|
||||
return s
|
||||
}
|
||||
|
||||
// NewN creates a Stub with n dynamically generated signals named "pv_0" … "pv_{n-1}".
|
||||
// Each signal is a sine wave at a distinct frequency, emitted at 10 Hz.
|
||||
// Use this for stress and scale testing.
|
||||
func NewN(n int) *Stub {
|
||||
s := &Stub{
|
||||
signals: make(map[string]signalDef),
|
||||
values: make(map[string]any),
|
||||
}
|
||||
for i := range n {
|
||||
freq := 0.1 + float64(i)*0.01 // spread frequencies so signals differ
|
||||
s.register(signalDef{
|
||||
meta: datasource.Metadata{
|
||||
Name: fmt.Sprintf("pv_%d", i),
|
||||
Type: datasource.TypeFloat64,
|
||||
Unit: "V",
|
||||
DisplayLow: -1,
|
||||
DisplayHigh: 1,
|
||||
Description: fmt.Sprintf("Synthetic PV #%d (%.2f Hz sine)", i, freq),
|
||||
},
|
||||
fn: func(t time.Time) any {
|
||||
return math.Sin(2 * math.Pi * freq * float64(t.UnixNano()) / 1e9)
|
||||
},
|
||||
})
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -127,16 +164,22 @@ func (s *Stub) GetMetadata(_ context.Context, signal string) (datasource.Metadat
|
||||
return d.meta, nil
|
||||
}
|
||||
|
||||
// Subscribe starts a 10 Hz ticker that pushes generated values into ch.
|
||||
// Subscribe starts a ticker that pushes generated values into ch.
|
||||
// The tick interval is the signal's configured interval (default 10 Hz).
|
||||
func (s *Stub) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
|
||||
def, ok := s.signals[signal]
|
||||
if !ok {
|
||||
return nil, datasource.ErrNotFound
|
||||
}
|
||||
|
||||
interval := def.interval
|
||||
if interval == 0 {
|
||||
interval = updateInterval
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
go func() {
|
||||
ticker := time.NewTicker(updateInterval)
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
|
||||
@@ -10,6 +10,16 @@ type SignalDef struct {
|
||||
Inputs []InputRef `json:"inputs"` // alternative multi-input format
|
||||
Pipeline []NodeDef `json:"pipeline"` // ordered list of DSP nodes
|
||||
Meta MetaOverride `json:"meta"` // optional metadata overrides
|
||||
|
||||
// Visibility controls who sees this signal in the signal tree:
|
||||
// "global" — listed in every panel's edit mode
|
||||
// "user" — listed in every panel owned by Owner
|
||||
// "panel" — listed only when editing the bound Panel
|
||||
// An empty value is treated as "global" for backward compatibility with
|
||||
// definitions created before this field existed.
|
||||
Visibility string `json:"visibility,omitempty"`
|
||||
Owner string `json:"owner,omitempty"` // creator identity (stamped server-side)
|
||||
Panel string `json:"panel,omitempty"` // bound interface id for "panel" visibility
|
||||
}
|
||||
|
||||
// InputRef names one upstream signal used as input to the pipeline.
|
||||
|
||||
@@ -83,6 +83,9 @@ func buildNode(d NodeDef) (dsp.Node, error) {
|
||||
case "derivative":
|
||||
return &dsp.DerivativeNode{}, nil
|
||||
|
||||
case "integrate":
|
||||
return &dsp.IntegrateNode{}, nil
|
||||
|
||||
case "clamp":
|
||||
return &dsp.ClampNode{
|
||||
Min: floatParam(p, "min"),
|
||||
@@ -99,6 +102,16 @@ func buildNode(d NodeDef) (dsp.Node, error) {
|
||||
case "expr":
|
||||
return &dsp.ExprNode{Expr: stringParam(p, "expr")}, nil
|
||||
|
||||
case "lowpass":
|
||||
order := int(floatParam(p, "order"))
|
||||
if order < 1 {
|
||||
order = 1
|
||||
}
|
||||
return &dsp.LowPassNode{
|
||||
Freq: floatParam(p, "freq"),
|
||||
Order: order,
|
||||
}, nil
|
||||
|
||||
case "lua":
|
||||
return &dsp.LuaNode{Script: stringParam(p, "script")}, nil
|
||||
|
||||
|
||||
@@ -85,6 +85,22 @@ func (s *Synthetic) ListSignals(_ context.Context) ([]datasource.Metadata, error
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FilteredMetadata returns metadata for every defined synthetic signal for
|
||||
// which keep returns true. It lets the API layer apply per-caller visibility
|
||||
// rules (which depend on SignalDef fields not present in datasource.Metadata).
|
||||
func (s *Synthetic) FilteredMetadata(keep func(SignalDef) bool) []datasource.Metadata {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
out := make([]datasource.Metadata, 0, len(s.signals))
|
||||
for _, st := range s.signals {
|
||||
if keep(st.def) {
|
||||
out = append(out, defToMetadata(st.def))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetMetadata returns metadata for a single named signal.
|
||||
func (s *Synthetic) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
|
||||
s.mu.RLock()
|
||||
@@ -286,6 +302,52 @@ func (s *Synthetic) RemoveSignal(name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSignal returns the definition of a single named signal.
|
||||
func (s *Synthetic) GetSignal(name string) (SignalDef, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
st, ok := s.signals[name]
|
||||
if !ok {
|
||||
return SignalDef{}, datasource.ErrNotFound
|
||||
}
|
||||
return st.def, nil
|
||||
}
|
||||
|
||||
// UpdateSignal replaces the pipeline of an existing synthetic signal at runtime
|
||||
// and persists the change. The signal must already exist.
|
||||
func (s *Synthetic) UpdateSignal(def SignalDef) error {
|
||||
if def.Name == "" {
|
||||
return errors.New("signal name must not be empty")
|
||||
}
|
||||
|
||||
nodes, err := BuildPipeline(def.Pipeline)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build pipeline: %w", err)
|
||||
}
|
||||
|
||||
states := make([]map[string]any, len(nodes))
|
||||
for i := range states {
|
||||
states[i] = make(map[string]any)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
old, exists := s.signals[def.Name]
|
||||
if !exists {
|
||||
s.mu.Unlock()
|
||||
return datasource.ErrNotFound
|
||||
}
|
||||
if old.cancel != nil {
|
||||
old.cancel()
|
||||
}
|
||||
s.signals[def.Name] = &signalState{def: def, nodes: nodes, states: states}
|
||||
s.mu.Unlock()
|
||||
|
||||
if err := s.saveDefs(); err != nil {
|
||||
return fmt.Errorf("persist definitions: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDefs returns a copy of all current signal definitions (for the REST API).
|
||||
func (s *Synthetic) GetDefs() []SignalDef {
|
||||
s.mu.RLock()
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package datasource
|
||||
|
||||
import "context"
|
||||
|
||||
// userKey is the unexported context key under which the end-user identity is
|
||||
// stored. Using a private type prevents collisions with other packages.
|
||||
type userKey struct{}
|
||||
|
||||
// WithUser returns a copy of ctx carrying the end-user identity associated with
|
||||
// the request (e.g. the authenticated web client). Data sources may use this to
|
||||
// attribute operations to the actual user rather than the server process — for
|
||||
// example EPICS Channel Access access-security rules match on the client
|
||||
// username. An empty user is treated as "no client identity".
|
||||
func WithUser(ctx context.Context, user string) context.Context {
|
||||
if user == "" {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, userKey{}, user)
|
||||
}
|
||||
|
||||
// UserFrom returns the end-user identity stored in ctx by WithUser. The boolean
|
||||
// is false when no (non-empty) identity is present, in which case callers
|
||||
// should fall back to the server's own identity.
|
||||
func UserFrom(ctx context.Context) (string, bool) {
|
||||
u, ok := ctx.Value(userKey{}).(string)
|
||||
return u, ok && u != ""
|
||||
}
|
||||
+240
-33
@@ -204,6 +204,34 @@ func (n *DerivativeNode) Process(inputs []float64, state map[string]any) (float6
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// ── IntegrateNode ─────────────────────────────────────────────────────────────
|
||||
|
||||
// IntegrateNode accumulates the time-integral of input[0] using the trapezoidal
|
||||
// rule, where dt is in seconds. On the first call it returns 0 (no interval yet).
|
||||
type IntegrateNode struct{}
|
||||
|
||||
func (n *IntegrateNode) Type() string { return "integrate" }
|
||||
func (n *IntegrateNode) Process(inputs []float64, state map[string]any) (float64, error) {
|
||||
if len(inputs) == 0 {
|
||||
return 0, errors.New("integrate: no inputs")
|
||||
}
|
||||
now := time.Now()
|
||||
acc, _ := state["acc"].(float64)
|
||||
if prevVal, ok := state["prev_val"].(float64); ok {
|
||||
if prevTime, ok := state["prev_time"].(time.Time); ok {
|
||||
dt := now.Sub(prevTime).Seconds()
|
||||
if dt < 0 {
|
||||
dt = 0
|
||||
}
|
||||
acc += (inputs[0] + prevVal) * 0.5 * dt
|
||||
}
|
||||
}
|
||||
state["acc"] = acc
|
||||
state["prev_val"] = inputs[0]
|
||||
state["prev_time"] = now
|
||||
return acc, nil
|
||||
}
|
||||
|
||||
// ── ClampNode ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// ClampNode clamps the output to [Min, Max].
|
||||
@@ -277,12 +305,21 @@ func (n *ExprNode) Process(inputs []float64, _ map[string]any) (float64, error)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// exprParser is a recursive-descent parser for simple arithmetic expressions.
|
||||
// exprParser is a recursive-descent parser for arithmetic expressions.
|
||||
// Grammar:
|
||||
//
|
||||
// expr = term (('+' | '-') term)*
|
||||
// term = factor (('*' | '/') factor)*
|
||||
// expr = term (('+' | '-') term)*
|
||||
// term = power (('*' | '/') power)*
|
||||
// power = unary ('^' power)* — right-associative exponentiation
|
||||
// unary = '-' unary | call
|
||||
// call = ident '(' expr ')' | factor
|
||||
// factor = '(' expr ')' | number | variable
|
||||
//
|
||||
// Supported functions: exp, log, log2, log10, sqrt, abs, sin, cos, tan,
|
||||
//
|
||||
// asin, acos, atan, floor, ceil, round, pow
|
||||
//
|
||||
// Variables: a, b, c, d (bound to inputs[0..3]).
|
||||
type exprParser struct {
|
||||
src string
|
||||
pos int
|
||||
@@ -328,7 +365,7 @@ func (p *exprParser) parseExpr() (float64, error) {
|
||||
}
|
||||
|
||||
func (p *exprParser) parseTerm() (float64, error) {
|
||||
left, err := p.parseFactor()
|
||||
left, err := p.parsePower()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -338,7 +375,7 @@ func (p *exprParser) parseTerm() (float64, error) {
|
||||
break
|
||||
}
|
||||
p.pos++
|
||||
right, err := p.parseFactor()
|
||||
right, err := p.parsePower()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -354,6 +391,136 @@ func (p *exprParser) parseTerm() (float64, error) {
|
||||
return left, nil
|
||||
}
|
||||
|
||||
// parsePower handles right-associative exponentiation: a^b^c = a^(b^c).
|
||||
// Unary minus is consumed here so that -a^2 = -(a^2), not (-a)^2 — matching
|
||||
// standard mathematical and Python/Julia precedence rules.
|
||||
func (p *exprParser) parsePower() (float64, error) {
|
||||
// Collect leading unary minuses; an even count cancels out.
|
||||
sign := 1.0
|
||||
p.skipSpaces()
|
||||
for p.pos < len(p.src) && p.src[p.pos] == '-' {
|
||||
p.pos++
|
||||
sign = -sign
|
||||
p.skipSpaces()
|
||||
}
|
||||
|
||||
base, err := p.parseCall()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
p.skipSpaces()
|
||||
if p.pos < len(p.src) && p.src[p.pos] == '^' {
|
||||
p.pos++
|
||||
exp, err := p.parsePower() // right-associative recursion
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return sign * math.Pow(base, exp), nil
|
||||
}
|
||||
return sign * base, nil
|
||||
}
|
||||
|
||||
// parseCall handles function calls like exp(a) or pow(a, 2).
|
||||
func (p *exprParser) parseCall() (float64, error) {
|
||||
p.skipSpaces()
|
||||
if p.pos >= len(p.src) {
|
||||
return 0, errors.New("unexpected end of expression")
|
||||
}
|
||||
|
||||
// Try to read an identifier (function name or variable).
|
||||
if p.src[p.pos] >= 'a' && p.src[p.pos] <= 'z' || p.src[p.pos] >= 'A' && p.src[p.pos] <= 'Z' {
|
||||
start := p.pos
|
||||
for p.pos < len(p.src) && (p.src[p.pos] >= 'a' && p.src[p.pos] <= 'z' ||
|
||||
p.src[p.pos] >= 'A' && p.src[p.pos] <= 'Z' ||
|
||||
p.src[p.pos] >= '0' && p.src[p.pos] <= '9' ||
|
||||
p.src[p.pos] == '_') {
|
||||
p.pos++
|
||||
}
|
||||
name := p.src[start:p.pos]
|
||||
|
||||
// Check for function call syntax: name(...)
|
||||
p.skipSpaces()
|
||||
if p.pos < len(p.src) && p.src[p.pos] == '(' {
|
||||
p.pos++ // consume '('
|
||||
arg1, err := p.parseExpr()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Optional second argument for two-argument functions.
|
||||
var arg2 float64
|
||||
p.skipSpaces()
|
||||
if p.pos < len(p.src) && p.src[p.pos] == ',' {
|
||||
p.pos++
|
||||
arg2, err = p.parseExpr()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
p.skipSpaces()
|
||||
}
|
||||
if p.pos >= len(p.src) || p.src[p.pos] != ')' {
|
||||
return 0, fmt.Errorf("missing ')' after %s(...)", name)
|
||||
}
|
||||
p.pos++ // consume ')'
|
||||
|
||||
switch name {
|
||||
case "exp":
|
||||
return math.Exp(arg1), nil
|
||||
case "log", "ln":
|
||||
return math.Log(arg1), nil
|
||||
case "log2":
|
||||
return math.Log2(arg1), nil
|
||||
case "log10":
|
||||
return math.Log10(arg1), nil
|
||||
case "sqrt":
|
||||
return math.Sqrt(arg1), nil
|
||||
case "abs":
|
||||
return math.Abs(arg1), nil
|
||||
case "sin":
|
||||
return math.Sin(arg1), nil
|
||||
case "cos":
|
||||
return math.Cos(arg1), nil
|
||||
case "tan":
|
||||
return math.Tan(arg1), nil
|
||||
case "asin":
|
||||
return math.Asin(arg1), nil
|
||||
case "acos":
|
||||
return math.Acos(arg1), nil
|
||||
case "atan":
|
||||
return math.Atan(arg1), nil
|
||||
case "atan2":
|
||||
return math.Atan2(arg1, arg2), nil
|
||||
case "pow":
|
||||
return math.Pow(arg1, arg2), nil
|
||||
case "floor":
|
||||
return math.Floor(arg1), nil
|
||||
case "ceil":
|
||||
return math.Ceil(arg1), nil
|
||||
case "round":
|
||||
return math.Round(arg1), nil
|
||||
case "min":
|
||||
return math.Min(arg1, arg2), nil
|
||||
case "max":
|
||||
return math.Max(arg1, arg2), nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown function %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Not a function call — must be a single-letter variable.
|
||||
if len(name) != 1 {
|
||||
return 0, fmt.Errorf("unknown identifier %q (use a–d for variables, or a known function name)", name)
|
||||
}
|
||||
val, ok := p.vars[name]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unknown variable %q (allowed: a, b, c, d)", name)
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
return p.parseFactor()
|
||||
}
|
||||
|
||||
func (p *exprParser) parseFactor() (float64, error) {
|
||||
p.skipSpaces()
|
||||
if p.pos >= len(p.src) {
|
||||
@@ -362,7 +529,7 @@ func (p *exprParser) parseFactor() (float64, error) {
|
||||
|
||||
ch := p.src[p.pos]
|
||||
|
||||
// Parenthesised subexpression
|
||||
// Parenthesised subexpression.
|
||||
if ch == '(' {
|
||||
p.pos++
|
||||
val, err := p.parseExpr()
|
||||
@@ -377,35 +544,13 @@ func (p *exprParser) parseFactor() (float64, error) {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// Unary minus
|
||||
if ch == '-' {
|
||||
p.pos++
|
||||
val, err := p.parseFactor()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return -val, nil
|
||||
}
|
||||
|
||||
// Variable (a, b, c, d only)
|
||||
if ch >= 'a' && ch <= 'z' {
|
||||
name := string(ch)
|
||||
p.pos++
|
||||
// Make sure we only accept single-letter variables
|
||||
if p.pos < len(p.src) && (p.src[p.pos] >= 'a' && p.src[p.pos] <= 'z' || p.src[p.pos] >= 'A' && p.src[p.pos] <= 'Z' || p.src[p.pos] == '_') {
|
||||
return 0, fmt.Errorf("unknown identifier starting with %q", name)
|
||||
}
|
||||
val, ok := p.vars[name]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unknown variable %q (allowed: a, b, c, d)", name)
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// Number (including optional decimal point and exponent)
|
||||
// Number (including optional decimal point and exponent notation).
|
||||
if ch >= '0' && ch <= '9' || ch == '.' {
|
||||
start := p.pos
|
||||
for p.pos < len(p.src) && (p.src[p.pos] >= '0' && p.src[p.pos] <= '9' || p.src[p.pos] == '.' || p.src[p.pos] == 'e' || p.src[p.pos] == 'E' || ((p.src[p.pos] == '+' || p.src[p.pos] == '-') && p.pos > start && (p.src[p.pos-1] == 'e' || p.src[p.pos-1] == 'E'))) {
|
||||
for p.pos < len(p.src) && (p.src[p.pos] >= '0' && p.src[p.pos] <= '9' ||
|
||||
p.src[p.pos] == '.' || p.src[p.pos] == 'e' || p.src[p.pos] == 'E' ||
|
||||
((p.src[p.pos] == '+' || p.src[p.pos] == '-') && p.pos > start &&
|
||||
(p.src[p.pos-1] == 'e' || p.src[p.pos-1] == 'E'))) {
|
||||
p.pos++
|
||||
}
|
||||
f, err := strconv.ParseFloat(p.src[start:p.pos], 64)
|
||||
@@ -418,6 +563,68 @@ func (p *exprParser) parseFactor() (float64, error) {
|
||||
return 0, fmt.Errorf("unexpected character %q at position %d", ch, p.pos)
|
||||
}
|
||||
|
||||
// ── LowPassNode ───────────────────────────────────────────────────────────────
|
||||
|
||||
// LowPassNode implements a cascaded first-order IIR (RC) low-pass filter.
|
||||
// The filter coefficient α is recomputed each sample from the elapsed time so
|
||||
// it is correct for event-driven (non-fixed-rate) signals.
|
||||
// For Order > 1 the single-pole stage is cascaded Order times, approximating
|
||||
// a Butterworth response with -20·Order dB/decade roll-off.
|
||||
type LowPassNode struct {
|
||||
Freq float64 // cutoff frequency in Hz (–3 dB point)
|
||||
Order int // number of cascaded stages (1–8)
|
||||
}
|
||||
|
||||
func (n *LowPassNode) Type() string { return "lowpass" }
|
||||
|
||||
func (n *LowPassNode) Process(inputs []float64, state map[string]any) (float64, error) {
|
||||
if len(inputs) == 0 {
|
||||
return 0, errors.New("lowpass: no inputs")
|
||||
}
|
||||
x := inputs[0]
|
||||
now := time.Now()
|
||||
|
||||
order := n.Order
|
||||
if order < 1 {
|
||||
order = 1
|
||||
}
|
||||
if order > 8 {
|
||||
order = 8
|
||||
}
|
||||
fc := n.Freq
|
||||
if fc <= 0 {
|
||||
fc = 1.0
|
||||
}
|
||||
|
||||
prevT, hasT := state["t"].(time.Time)
|
||||
if !hasT {
|
||||
// First sample: prime all stage states with the input value.
|
||||
for i := range order {
|
||||
state[fmt.Sprintf("y%d", i)] = x
|
||||
}
|
||||
state["t"] = now
|
||||
return x, nil
|
||||
}
|
||||
|
||||
dt := now.Sub(prevT).Seconds()
|
||||
state["t"] = now
|
||||
if dt <= 0 {
|
||||
dt = 1e-9
|
||||
}
|
||||
|
||||
rc := 1.0 / (2.0 * math.Pi * fc)
|
||||
alpha := dt / (rc + dt)
|
||||
|
||||
y := x
|
||||
for i := range order {
|
||||
key := fmt.Sprintf("y%d", i)
|
||||
prev, _ := state[key].(float64)
|
||||
y = alpha*y + (1-alpha)*prev
|
||||
state[key] = y
|
||||
}
|
||||
return y, nil
|
||||
}
|
||||
|
||||
// ── LuaNode ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// LuaNode runs a Lua script in a sandboxed gopher-lua VM.
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
// Package panelacl implements per-panel ownership and sharing (Phase 2) plus a
|
||||
// nested folder hierarchy for organising panels (Phase 3). It persists a single
|
||||
// sidecar index, {storageDir}/acl.json, alongside the XML interface files.
|
||||
//
|
||||
// The effective permission a user has on a panel is the maximum of: ownership
|
||||
// (always write), the panel's public level, any direct user grant, any grant to
|
||||
// a user-group the user belongs to, and the permissions inherited from the
|
||||
// folder chain the panel lives in. Callers are expected to further cap this by
|
||||
// the user's global access level (see internal/access).
|
||||
//
|
||||
// Panels that have no record in the index are treated as fully open
|
||||
// (PermWrite). This preserves access to interfaces created before ACLs existed;
|
||||
// panels created through the ACL-aware API are recorded as private to their
|
||||
// owner.
|
||||
package panelacl
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when a folder does not exist.
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
// Perm is an effective access level on a panel or folder.
|
||||
type Perm int
|
||||
|
||||
const (
|
||||
// PermNone denies access.
|
||||
PermNone Perm = iota
|
||||
// PermRead permits viewing only.
|
||||
PermRead
|
||||
// PermWrite permits viewing and modification.
|
||||
PermWrite
|
||||
)
|
||||
|
||||
// String renders a Perm as the token used in the JSON index and HTTP API.
|
||||
func (p Perm) String() string {
|
||||
switch p {
|
||||
case PermRead:
|
||||
return "read"
|
||||
case PermWrite:
|
||||
return "write"
|
||||
default:
|
||||
return "none"
|
||||
}
|
||||
}
|
||||
|
||||
// parsePerm maps a config/API token to a Perm. Unknown/empty → PermNone.
|
||||
func parsePerm(s string) Perm {
|
||||
switch s {
|
||||
case "read":
|
||||
return PermRead
|
||||
case "write", "readwrite", "rw":
|
||||
return PermWrite
|
||||
default:
|
||||
return PermNone
|
||||
}
|
||||
}
|
||||
|
||||
// Grant shares a panel or folder with a single user or a named user-group.
|
||||
type Grant struct {
|
||||
Kind string `json:"kind"` // "user" | "group"
|
||||
Name string `json:"name"` // username or user-group name
|
||||
Perm string `json:"perm"` // "read" | "write"
|
||||
}
|
||||
|
||||
// PanelACL is the ownership + sharing record for one panel.
|
||||
type PanelACL struct {
|
||||
Owner string `json:"owner"`
|
||||
Folder string `json:"folder,omitempty"` // folder id the panel belongs to ("" = root)
|
||||
Public string `json:"public,omitempty"` // "" | "read" | "write"
|
||||
Grants []Grant `json:"grants,omitempty"`
|
||||
Order float64 `json:"order,omitempty"` // sort position within its folder
|
||||
}
|
||||
|
||||
// Folder is a node in the panel-organisation hierarchy. Permissions set on a
|
||||
// folder are inherited by every panel and subfolder beneath it.
|
||||
type Folder struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Parent string `json:"parent,omitempty"` // parent folder id ("" = root)
|
||||
Owner string `json:"owner"`
|
||||
Public string `json:"public,omitempty"`
|
||||
Grants []Grant `json:"grants,omitempty"`
|
||||
}
|
||||
|
||||
type index struct {
|
||||
Panels map[string]*PanelACL `json:"panels"`
|
||||
Folders map[string]*Folder `json:"folders"`
|
||||
}
|
||||
|
||||
// Store persists and resolves the panel ACL index. It is safe for concurrent
|
||||
// use.
|
||||
type Store struct {
|
||||
path string
|
||||
mu sync.RWMutex
|
||||
idx index
|
||||
}
|
||||
|
||||
// New opens (loading if present) the acl.json index in storageDir.
|
||||
func New(storageDir string) (*Store, error) {
|
||||
s := &Store{
|
||||
path: filepath.Join(storageDir, "acl.json"),
|
||||
idx: index{Panels: map[string]*PanelACL{}, Folders: map[string]*Folder{}},
|
||||
}
|
||||
data, err := os.ReadFile(s.path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return s, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(data, &s.idx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.idx.Panels == nil {
|
||||
s.idx.Panels = map[string]*PanelACL{}
|
||||
}
|
||||
if s.idx.Folders == nil {
|
||||
s.idx.Folders = map[string]*Folder{}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// saveLocked atomically persists the index. The caller must hold s.mu.
|
||||
func (s *Store) saveLocked() error {
|
||||
data, err := json.MarshalIndent(s.idx, "", " ")
|
||||
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)
|
||||
}
|
||||
|
||||
// ── Panel records ────────────────────────────────────────────────────────────
|
||||
|
||||
// GetPanel returns a copy of a panel's ACL record, or nil if it is unmanaged.
|
||||
func (s *Store) GetPanel(id string) *PanelACL {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
acl, ok := s.idx.Panels[id]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return clonePanel(acl)
|
||||
}
|
||||
|
||||
// CreatePanel records ownership for a newly created panel, defaulting to
|
||||
// private (owner-only) access. Existing records are left untouched.
|
||||
func (s *Store) CreatePanel(id, owner string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.idx.Panels[id]; ok {
|
||||
return nil
|
||||
}
|
||||
s.idx.Panels[id] = &PanelACL{Owner: owner}
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
// SetPanel replaces a panel's sharing settings (folder, public level, grants)
|
||||
// while preserving its existing owner (or adopting the supplied owner when the
|
||||
// panel was previously unmanaged).
|
||||
func (s *Store) SetPanel(id, owner, folder, public string, grants []Grant) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
acl := s.idx.Panels[id]
|
||||
if acl == nil {
|
||||
acl = &PanelACL{Owner: owner}
|
||||
s.idx.Panels[id] = acl
|
||||
}
|
||||
acl.Folder = folder
|
||||
acl.Public = public
|
||||
acl.Grants = grants
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
// PlacePanel sets only a panel's organizational fields — its folder and sort
|
||||
// order — preserving ownership and sharing. A previously-unmanaged panel gets a
|
||||
// record with no owner, which PanelPerm still treats as fully open (so dragging
|
||||
// a legacy panel into a folder does not lock it).
|
||||
func (s *Store) PlacePanel(id, folder string, order float64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
acl := s.idx.Panels[id]
|
||||
if acl == nil {
|
||||
acl = &PanelACL{}
|
||||
s.idx.Panels[id] = acl
|
||||
}
|
||||
acl.Folder = folder
|
||||
acl.Order = order
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
// DeletePanel removes a panel's ACL record (called when the panel is deleted).
|
||||
func (s *Store) DeletePanel(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.idx.Panels[id]; !ok {
|
||||
return nil
|
||||
}
|
||||
delete(s.idx.Panels, id)
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
// ── Folders ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Folders returns a copy of every folder, keyed by id.
|
||||
func (s *Store) Folders() map[string]Folder {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make(map[string]Folder, len(s.idx.Folders))
|
||||
for id, f := range s.idx.Folders {
|
||||
out[id] = *cloneFolder(f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetFolder returns a copy of a folder, or ErrNotFound.
|
||||
func (s *Store) GetFolder(id string) (Folder, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
f, ok := s.idx.Folders[id]
|
||||
if !ok {
|
||||
return Folder{}, ErrNotFound
|
||||
}
|
||||
return *cloneFolder(f), nil
|
||||
}
|
||||
|
||||
// CreateFolder adds a new folder owned by owner and returns it.
|
||||
func (s *Store) CreateFolder(id, name, parent, owner string) (Folder, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if parent != "" {
|
||||
if _, ok := s.idx.Folders[parent]; !ok {
|
||||
return Folder{}, ErrNotFound
|
||||
}
|
||||
}
|
||||
f := &Folder{ID: id, Name: name, Parent: parent, Owner: owner}
|
||||
s.idx.Folders[id] = f
|
||||
if err := s.saveLocked(); err != nil {
|
||||
return Folder{}, err
|
||||
}
|
||||
return *cloneFolder(f), nil
|
||||
}
|
||||
|
||||
// UpdateFolder replaces a folder's mutable fields (name, parent, public,
|
||||
// grants). It rejects parent changes that would create a cycle.
|
||||
func (s *Store) UpdateFolder(id, name, parent, public string, grants []Grant) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
f, ok := s.idx.Folders[id]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
if parent != "" {
|
||||
if _, ok := s.idx.Folders[parent]; !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
if s.createsCycleLocked(id, parent) {
|
||||
return errors.New("folder parent change would create a cycle")
|
||||
}
|
||||
}
|
||||
f.Name = name
|
||||
f.Parent = parent
|
||||
f.Public = public
|
||||
f.Grants = grants
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
// DeleteFolder removes a folder, reparenting its child folders and panels to the
|
||||
// deleted folder's parent (so nothing is orphaned).
|
||||
func (s *Store) DeleteFolder(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
f, ok := s.idx.Folders[id]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
newParent := f.Parent
|
||||
for _, child := range s.idx.Folders {
|
||||
if child.Parent == id {
|
||||
child.Parent = newParent
|
||||
}
|
||||
}
|
||||
for _, acl := range s.idx.Panels {
|
||||
if acl.Folder == id {
|
||||
acl.Folder = newParent
|
||||
}
|
||||
}
|
||||
delete(s.idx.Folders, id)
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
// createsCycleLocked reports whether setting folder id's parent to newParent
|
||||
// would introduce a cycle. The caller must hold s.mu.
|
||||
func (s *Store) createsCycleLocked(id, newParent string) bool {
|
||||
for cur := newParent; cur != ""; {
|
||||
if cur == id {
|
||||
return true
|
||||
}
|
||||
f, ok := s.idx.Folders[cur]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
cur = f.Parent
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Permission resolution ────────────────────────────────────────────────────
|
||||
|
||||
// PanelPerm returns the effective permission user has on panel id, given the
|
||||
// user-groups the user belongs to. Unmanaged panels are fully open.
|
||||
func (s *Store) PanelPerm(id, user string, groups []string) Perm {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
acl, ok := s.idx.Panels[id]
|
||||
if !ok {
|
||||
return PermWrite // legacy/unmanaged panel: fully open
|
||||
}
|
||||
// A record carrying only organizational metadata (folder/order) with no
|
||||
// owner, public level, or grants is still open — keeps an unmanaged panel
|
||||
// accessible after it has merely been moved into a folder or reordered.
|
||||
if acl.Owner == "" && acl.Public == "" && len(acl.Grants) == 0 {
|
||||
return PermWrite
|
||||
}
|
||||
best := PermNone
|
||||
if user != "" && acl.Owner == user {
|
||||
return PermWrite
|
||||
}
|
||||
best = maxPerm(best, parsePerm(acl.Public))
|
||||
best = maxPerm(best, grantsPerm(acl.Grants, user, groups))
|
||||
if acl.Folder != "" {
|
||||
best = maxPerm(best, s.folderPermLocked(acl.Folder, user, groups, map[string]bool{}))
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// FolderPerm returns the effective permission user has on folder id.
|
||||
func (s *Store) FolderPerm(id, user string, groups []string) Perm {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.folderPermLocked(id, user, groups, map[string]bool{})
|
||||
}
|
||||
|
||||
func (s *Store) folderPermLocked(id, user string, groups []string, seen map[string]bool) Perm {
|
||||
if id == "" || seen[id] {
|
||||
return PermNone
|
||||
}
|
||||
seen[id] = true
|
||||
f, ok := s.idx.Folders[id]
|
||||
if !ok {
|
||||
return PermNone
|
||||
}
|
||||
if user != "" && f.Owner == user {
|
||||
return PermWrite
|
||||
}
|
||||
best := PermNone
|
||||
best = maxPerm(best, parsePerm(f.Public))
|
||||
best = maxPerm(best, grantsPerm(f.Grants, user, groups))
|
||||
best = maxPerm(best, s.folderPermLocked(f.Parent, user, groups, seen))
|
||||
return best
|
||||
}
|
||||
|
||||
func grantsPerm(grants []Grant, user string, groups []string) Perm {
|
||||
best := PermNone
|
||||
for _, g := range grants {
|
||||
switch g.Kind {
|
||||
case "user":
|
||||
if user != "" && g.Name == user {
|
||||
best = maxPerm(best, parsePerm(g.Perm))
|
||||
}
|
||||
case "group":
|
||||
if contains(groups, g.Name) {
|
||||
best = maxPerm(best, parsePerm(g.Perm))
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func maxPerm(a, b Perm) Perm {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func contains(xs []string, v string) bool {
|
||||
for _, x := range xs {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func clonePanel(a *PanelACL) *PanelACL {
|
||||
c := *a
|
||||
c.Grants = append([]Grant(nil), a.Grants...)
|
||||
return &c
|
||||
}
|
||||
|
||||
func cloneFolder(f *Folder) *Folder {
|
||||
c := *f
|
||||
c.Grants = append([]Grant(nil), f.Grants...)
|
||||
return &c
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package panelacl
|
||||
|
||||
import "testing"
|
||||
|
||||
func newStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal("New:", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestUnmanagedPanelIsOpen(t *testing.T) {
|
||||
s := newStore(t)
|
||||
if got := s.PanelPerm("legacy", "alice", nil); got != PermWrite {
|
||||
t.Fatalf("unmanaged panel perm = %v, want write", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerAlwaysWrites(t *testing.T) {
|
||||
s := newStore(t)
|
||||
if err := s.CreatePanel("p1", "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := s.PanelPerm("p1", "alice", nil); got != PermWrite {
|
||||
t.Fatalf("owner perm = %v, want write", got)
|
||||
}
|
||||
// A freshly created panel is private to its owner.
|
||||
if got := s.PanelPerm("p1", "bob", nil); got != PermNone {
|
||||
t.Fatalf("non-owner perm on private panel = %v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicAndGrants(t *testing.T) {
|
||||
s := newStore(t)
|
||||
if err := s.CreatePanel("p1", "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grants := []Grant{
|
||||
{Kind: "user", Name: "bob", Perm: "write"},
|
||||
{Kind: "group", Name: "ops", Perm: "read"},
|
||||
}
|
||||
if err := s.SetPanel("p1", "alice", "", "read", grants); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Public read applies to anyone.
|
||||
if got := s.PanelPerm("p1", "carol", nil); got != PermRead {
|
||||
t.Fatalf("public perm = %v, want read", got)
|
||||
}
|
||||
// Direct user grant raises bob to write.
|
||||
if got := s.PanelPerm("p1", "bob", nil); got != PermWrite {
|
||||
t.Fatalf("granted user perm = %v, want write", got)
|
||||
}
|
||||
// Group membership grants read.
|
||||
if got := s.PanelPerm("p1", "dave", []string{"ops"}); got != PermRead {
|
||||
t.Fatalf("group perm = %v, want read", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderInheritance(t *testing.T) {
|
||||
s := newStore(t)
|
||||
f, err := s.CreateFolder("f1", "Shared", "", "alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.UpdateFolder(f.ID, f.Name, "", "read", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.CreatePanel("p1", "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SetPanel("p1", "alice", f.ID, "", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The panel inherits the folder's public-read level.
|
||||
if got := s.PanelPerm("p1", "bob", nil); got != PermRead {
|
||||
t.Fatalf("inherited perm = %v, want read", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderCycleRejected(t *testing.T) {
|
||||
s := newStore(t)
|
||||
a, _ := s.CreateFolder("a", "A", "", "alice")
|
||||
b, _ := s.CreateFolder("b", "B", a.ID, "alice")
|
||||
// Re-parenting A under its own descendant B must be rejected.
|
||||
if err := s.UpdateFolder(a.ID, a.Name, b.ID, "", nil); err == nil {
|
||||
t.Fatal("expected cycle rejection, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFolderReparents(t *testing.T) {
|
||||
s := newStore(t)
|
||||
a, _ := s.CreateFolder("a", "A", "", "alice")
|
||||
b, _ := s.CreateFolder("b", "B", a.ID, "alice")
|
||||
if err := s.CreatePanel("p1", "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SetPanel("p1", "alice", b.ID, "", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.DeleteFolder(b.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// B's panel should reparent to A (B's former parent), not orphan.
|
||||
if acl := s.GetPanel("p1"); acl == nil || acl.Folder != a.ID {
|
||||
t.Fatalf("panel folder after delete = %+v, want %s", acl, a.ID)
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,16 @@ import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
"github.com/uopi/uopi/internal/storage"
|
||||
)
|
||||
|
||||
@@ -23,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, channelFinderURL 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
|
||||
@@ -33,13 +37,16 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
|
||||
})
|
||||
|
||||
// WebSocket endpoint
|
||||
mux.Handle("/ws", &wsHandler{broker: brk, log: log})
|
||||
mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy})
|
||||
|
||||
// Prometheus-format metrics
|
||||
mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions))
|
||||
|
||||
// REST API
|
||||
api.New(brk, synth, store, channelFinderURL, log).Register(mux, apiPrefix)
|
||||
// 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, ctrlLogic, ctrlEngine, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix)
|
||||
mux.Handle(apiPrefix+"/", accessMiddleware(policy, trustedUserHeader, apiMux))
|
||||
|
||||
// Embedded frontend — must be last (catch-all)
|
||||
mux.Handle("/", http.FileServerFS(webFS))
|
||||
@@ -56,6 +63,44 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
|
||||
}
|
||||
}
|
||||
|
||||
// accessMiddleware resolves the end-user identity from the trusted proxy header
|
||||
// (falling back to the configured default_user), stores it on the request
|
||||
// context, and enforces the user's global access level on every API request:
|
||||
// - LevelNone → 403 for all requests.
|
||||
// - LevelRead → 403 for any mutating request (non GET/HEAD).
|
||||
// - LevelWrite → no restriction.
|
||||
//
|
||||
// The /me endpoint is always reachable so the frontend can discover the
|
||||
// caller's identity and level even when otherwise restricted.
|
||||
func accessMiddleware(policy *access.Policy, userHeader string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var raw string
|
||||
if userHeader != "" {
|
||||
raw = r.Header.Get(userHeader)
|
||||
}
|
||||
user := policy.ResolveUser(raw)
|
||||
r = r.WithContext(access.WithUser(r.Context(), user))
|
||||
|
||||
// Always allow identity discovery.
|
||||
if strings.HasSuffix(r.URL.Path, apiPrefix+"/me") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
switch policy.Level(user) {
|
||||
case access.LevelNone:
|
||||
http.Error(w, "access denied", http.StatusForbidden)
|
||||
return
|
||||
case access.LevelRead:
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
http.Error(w, "read-only access", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// Start listens and serves until ctx is cancelled.
|
||||
func (s *Server) Start(ctx context.Context) error {
|
||||
s.log.Info("listening", "addr", s.httpServer.Addr)
|
||||
|
||||
+56
-17
@@ -6,11 +6,13 @@ import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"github.com/uopi/uopi/internal/access"
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
"github.com/uopi/uopi/internal/metrics"
|
||||
@@ -64,15 +66,17 @@ type outMsg struct {
|
||||
}
|
||||
|
||||
type metaPayload struct {
|
||||
Type string `json:"type"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
DisplayLow float64 `json:"displayLow"`
|
||||
DisplayHigh float64 `json:"displayHigh"`
|
||||
DriveLow float64 `json:"driveLow,omitempty"`
|
||||
DriveHigh float64 `json:"driveHigh,omitempty"`
|
||||
EnumStrings []string `json:"enumStrings,omitempty"`
|
||||
Writable bool `json:"writable"`
|
||||
Type string `json:"type"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
DisplayLow float64 `json:"displayLow"`
|
||||
DisplayHigh float64 `json:"displayHigh"`
|
||||
DriveLow float64 `json:"driveLow,omitempty"`
|
||||
DriveHigh float64 `json:"driveHigh,omitempty"`
|
||||
EnumStrings []string `json:"enumStrings,omitempty"`
|
||||
Writable bool `json:"writable"`
|
||||
Properties map[string]string `json:"properties,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
type histPoint struct {
|
||||
@@ -85,9 +89,21 @@ type histPoint struct {
|
||||
type wsHandler struct {
|
||||
broker *broker.Broker
|
||||
log *slog.Logger
|
||||
// userHeader is the HTTP header from which the per-session end-user identity
|
||||
// is read (set by a trusted auth proxy). Empty disables per-user identity.
|
||||
userHeader string
|
||||
// policy enforces the global access level (blacklist) on signal writes.
|
||||
policy *access.Policy
|
||||
}
|
||||
|
||||
func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Capture the end-user identity from the trusted proxy header (if enabled)
|
||||
// before the connection is upgraded, while the original headers are present.
|
||||
var user string
|
||||
if h.userHeader != "" {
|
||||
user = strings.TrimSpace(r.Header.Get(h.userHeader))
|
||||
}
|
||||
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
// Permissive origin check for LAN / SSH-tunnel use; tighten when auth lands.
|
||||
InsecureSkipVerify: true,
|
||||
@@ -105,8 +121,10 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
c := &wsClient{
|
||||
conn: conn,
|
||||
broker: h.broker,
|
||||
outCh: make(chan []byte, 128),
|
||||
updateCh: make(chan broker.Update, 256),
|
||||
user: user,
|
||||
policy: h.policy,
|
||||
outCh: make(chan []byte, 512),
|
||||
updateCh: make(chan broker.Update, 1024),
|
||||
subs: make(map[broker.SignalRef]func()),
|
||||
log: h.log,
|
||||
}
|
||||
@@ -115,7 +133,7 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
wg.Add(3)
|
||||
go func() { defer wg.Done(); c.readLoop(ctx, cancel) }()
|
||||
go func() { defer wg.Done(); c.dispatchLoop(ctx) }()
|
||||
go func() { defer wg.Done(); c.writeLoop(ctx) }()
|
||||
go func() { defer wg.Done(); c.writeLoop(ctx, cancel) }()
|
||||
wg.Wait()
|
||||
|
||||
// Cancel all subscriptions on disconnect.
|
||||
@@ -134,6 +152,8 @@ type wsClient struct {
|
||||
conn *websocket.Conn
|
||||
broker *broker.Broker
|
||||
log *slog.Logger
|
||||
user string // end-user identity from the trusted proxy header ("" if none)
|
||||
policy *access.Policy // global access-level enforcement
|
||||
|
||||
outCh chan []byte // serialised outgoing messages
|
||||
updateCh chan broker.Update // raw updates from the broker
|
||||
@@ -193,7 +213,10 @@ func (c *wsClient) dispatchLoop(ctx context.Context) {
|
||||
}
|
||||
|
||||
// writeLoop sends queued messages over the WebSocket connection.
|
||||
func (c *wsClient) writeLoop(ctx context.Context) {
|
||||
// It cancels ctx on any write error so that readLoop and dispatchLoop exit
|
||||
// promptly rather than blocking forever waiting for outCh to drain.
|
||||
func (c *wsClient) writeLoop(ctx context.Context, cancel context.CancelFunc) {
|
||||
defer cancel()
|
||||
for {
|
||||
select {
|
||||
case msg := <-c.outCh:
|
||||
@@ -251,8 +274,11 @@ func (c *wsClient) handleSubscribe(ctx context.Context, refs []sigRef) {
|
||||
c.subs[ref] = cancelSub
|
||||
c.subsMu.Unlock()
|
||||
|
||||
// Send metadata once on subscribe.
|
||||
c.sendMeta(ctx, ref)
|
||||
// Send metadata in a goroutine: for EPICS signals GetMetadata may block
|
||||
// for several seconds waiting for the CA channel to connect and for
|
||||
// ca_pend_io to complete. Running it inline would stall readLoop and
|
||||
// prevent any further messages from this client from being processed.
|
||||
go c.sendMeta(ctx, ref)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,6 +295,14 @@ func (c *wsClient) handleUnsubscribe(refs []sigRef) {
|
||||
}
|
||||
|
||||
func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
|
||||
// Enforce the user's global access level: blacklisted read-only / no-access
|
||||
// users may not write signals, regardless of the channel's own writability.
|
||||
if c.policy != nil && c.policy.Level(c.policy.ResolveUser(c.user)) < access.LevelWrite {
|
||||
c.log.Warn("write: access denied", "ds", msg.DS, "name", msg.Name, "user", c.user)
|
||||
c.sendError(ctx, "ACCESS_DENIED", "you do not have write access")
|
||||
return
|
||||
}
|
||||
|
||||
ds, ok := c.broker.Source(msg.DS)
|
||||
if !ok {
|
||||
c.log.Warn("write: unknown data source", "ds", msg.DS, "name", msg.Name)
|
||||
@@ -297,8 +331,11 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
|
||||
return
|
||||
}
|
||||
|
||||
c.log.Info("write", "ds", msg.DS, "name", msg.Name, "value", value)
|
||||
c.log.Info("write", "ds", msg.DS, "name", msg.Name, "value", value, "user", c.user)
|
||||
metrics.IncWrites()
|
||||
// Attribute the write to the connecting end-user so data sources (EPICS) can
|
||||
// act under that identity rather than the server's.
|
||||
ctx = datasource.WithUser(ctx, c.user)
|
||||
if err := ds.Write(ctx, msg.Name, value); err != nil {
|
||||
c.log.Warn("write: ds.Write failed", "ds", msg.DS, "name", msg.Name, "err", err)
|
||||
c.sendError(ctx, "WRITE_ERROR", err.Error())
|
||||
@@ -341,7 +378,7 @@ func (c *wsClient) handleHistory(ctx context.Context, msg inMsg) {
|
||||
b, _ := json.Marshal(resp)
|
||||
select {
|
||||
case c.outCh <- b:
|
||||
default:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,6 +427,8 @@ func metadataToPayload(m datasource.Metadata) *metaPayload {
|
||||
DriveHigh: m.DriveHigh,
|
||||
EnumStrings: m.EnumStrings,
|
||||
Writable: m.Writable,
|
||||
Properties: m.Properties,
|
||||
Tags: m.Tags,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/datasource/stub"
|
||||
)
|
||||
|
||||
// newStressServer creates an httptest.Server backed by a stub with n signals.
|
||||
// The server and broker contexts are tied to t.Context() and cleaned up automatically.
|
||||
func newStressServer(t *testing.T, nSignals int) (*httptest.Server, *broker.Broker) {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
var ds *stub.Stub
|
||||
if nSignals > 0 {
|
||||
ds = stub.NewN(nSignals)
|
||||
} else {
|
||||
ds = stub.New()
|
||||
}
|
||||
if err := ds.Connect(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
brk := broker.New(ctx, log)
|
||||
brk.Register(ds)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/ws", &wsHandler{broker: brk, log: log})
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, brk
|
||||
}
|
||||
|
||||
// wsURL converts an http:// test server URL to ws://.
|
||||
func wsURL(srv *httptest.Server) string {
|
||||
return strings.ReplaceAll(srv.URL, "http://", "ws://") + "/ws"
|
||||
}
|
||||
|
||||
// buildSubMsg encodes a subscribe message for n stub PVs named "pv_0"…"pv_{n-1}".
|
||||
func buildSubMsg(n int) []byte {
|
||||
sigs := make([]sigRef, n)
|
||||
for i := range n {
|
||||
sigs[i] = sigRef{DS: "stub", Name: fmt.Sprintf("pv_%d", i)}
|
||||
}
|
||||
data, _ := json.Marshal(inMsg{Type: "subscribe", Signals: sigs})
|
||||
return data
|
||||
}
|
||||
|
||||
// connectAndCount dials the WebSocket, sends subMsg, counts incoming messages until
|
||||
// ctx is cancelled, adds the count to *total, and signals wg.Done.
|
||||
func connectAndCount(ctx context.Context, t *testing.T, url string, subMsg []byte, total *atomic.Int64, wg *sync.WaitGroup) {
|
||||
t.Helper()
|
||||
defer wg.Done()
|
||||
|
||||
conn, _, err := websocket.Dial(ctx, url, nil)
|
||||
if err != nil {
|
||||
t.Errorf("dial: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
|
||||
if err := conn.Write(ctx, websocket.MessageText, subMsg); err != nil {
|
||||
t.Errorf("subscribe write: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var n int64
|
||||
for {
|
||||
_, _, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
n++
|
||||
}
|
||||
total.Add(n)
|
||||
}
|
||||
|
||||
// TestStress_100PVs_10Clients verifies the full stack (broker + WS handler) can
|
||||
// deliver updates from 100 signals to 10 concurrent clients without loss or deadlock.
|
||||
func TestStress_100PVs_10Clients(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping stress test in short mode")
|
||||
}
|
||||
|
||||
const (
|
||||
nSignals = 100
|
||||
nClients = 10
|
||||
duration = 3 * time.Second
|
||||
)
|
||||
|
||||
srv, brk := newStressServer(t, nSignals)
|
||||
url := wsURL(srv)
|
||||
subMsg := buildSubMsg(nSignals)
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), duration+5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var total atomic.Int64
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for range nClients {
|
||||
wg.Add(1)
|
||||
// Each client runs for exactly `duration`, then its context expires and
|
||||
// conn.Read returns an error, causing the goroutine to exit cleanly.
|
||||
clientCtx, clientCancel := context.WithTimeout(ctx, duration)
|
||||
go func() {
|
||||
defer clientCancel()
|
||||
connectAndCount(clientCtx, t, url, subMsg, &total, &wg)
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
got := total.Load()
|
||||
// At 10 Hz, each PV fires ≥ duration*10 times; each client receives all of them.
|
||||
// min = nClients × nSignals × duration_s × 10 × 0.5 (conservative — meta msgs too)
|
||||
minExpected := int64(nClients) * int64(nSignals) * int64(duration.Seconds()) * 5
|
||||
t.Logf("100PVs/10Clients: received %d updates (min expected %d)", got, minExpected)
|
||||
if got < minExpected {
|
||||
t.Errorf("too few updates: got %d, want >= %d", got, minExpected)
|
||||
}
|
||||
|
||||
// Broker must release all upstream subscriptions after clients disconnect.
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if n := brk.ActiveSubscriptions(); n != 0 {
|
||||
t.Errorf("subscription leak: %d remain after all clients disconnected", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStress_500PVs_20Clients is the full-scale scenario: hundreds of signals,
|
||||
// tens of clients. Verifies no crash, no deadlock, and clean teardown.
|
||||
func TestStress_500PVs_20Clients(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping stress test in short mode")
|
||||
}
|
||||
|
||||
const (
|
||||
nSignals = 500
|
||||
nClients = 20
|
||||
duration = 3 * time.Second
|
||||
)
|
||||
|
||||
srv, brk := newStressServer(t, nSignals)
|
||||
url := wsURL(srv)
|
||||
subMsg := buildSubMsg(nSignals)
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), duration+10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var total atomic.Int64
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for range nClients {
|
||||
wg.Add(1)
|
||||
clientCtx, clientCancel := context.WithTimeout(ctx, duration)
|
||||
go func() {
|
||||
defer clientCancel()
|
||||
connectAndCount(clientCtx, t, url, subMsg, &total, &wg)
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
got := total.Load()
|
||||
// Conservative: expect at least 20% of the theoretical maximum
|
||||
minExpected := int64(nClients) * int64(nSignals) * int64(duration.Seconds()) * 2
|
||||
t.Logf("500PVs/20Clients: received %d updates (min expected %d)", got, minExpected)
|
||||
if got < minExpected {
|
||||
t.Errorf("too few updates: got %d, want >= %d", got, minExpected)
|
||||
}
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if n := brk.ActiveSubscriptions(); n != 0 {
|
||||
t.Errorf("subscription leak: %d remain after all clients disconnected", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStress_ClientChurn repeatedly connects and disconnects clients while a
|
||||
// stable set of signals is being delivered to ensure subscribe/unsubscribe under
|
||||
// load doesn't cause races, panics, or subscription leaks.
|
||||
func TestStress_ClientChurn(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping stress test in short mode")
|
||||
}
|
||||
|
||||
const (
|
||||
nSignals = 50
|
||||
nParallelConns = 30
|
||||
duration = 3 * time.Second
|
||||
)
|
||||
|
||||
srv, brk := newStressServer(t, nSignals)
|
||||
u := wsURL(srv)
|
||||
subMsg := buildSubMsg(nSignals)
|
||||
|
||||
deadline := time.Now().Add(duration)
|
||||
var wg sync.WaitGroup
|
||||
var total atomic.Int64
|
||||
|
||||
for range nParallelConns {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for time.Now().Before(deadline) {
|
||||
// Each iteration: one short-lived connection (~500 ms).
|
||||
func() {
|
||||
connCtx, connCancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer connCancel()
|
||||
|
||||
conn, _, err := websocket.Dial(connCtx, u, nil)
|
||||
if err != nil {
|
||||
return // dial failed (server busy) — try next iteration
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
|
||||
if err := conn.Write(connCtx, websocket.MessageText, subMsg); err != nil {
|
||||
return
|
||||
}
|
||||
var n int64
|
||||
for {
|
||||
_, _, err := conn.Read(connCtx)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
n++
|
||||
}
|
||||
total.Add(n)
|
||||
}()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
t.Logf("ClientChurn: %d total messages during %s of churn with %d concurrent connections",
|
||||
total.Load(), duration, nParallelConns)
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if n := brk.ActiveSubscriptions(); n != 0 {
|
||||
t.Errorf("subscription leak after churn: %d remain", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStress_SlowConsumer verifies that a slow-reading client doesn't block
|
||||
// fast clients or the broker fan-out (drop-on-full behaviour).
|
||||
func TestStress_SlowConsumer(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping stress test in short mode")
|
||||
}
|
||||
|
||||
const (
|
||||
nSignals = 100
|
||||
duration = 3 * time.Second
|
||||
)
|
||||
|
||||
srv, brk := newStressServer(t, nSignals)
|
||||
url := wsURL(srv)
|
||||
subMsg := buildSubMsg(nSignals)
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), duration+5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var fastTotal, slowTotal atomic.Int64
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Fast client: reads as quickly as possible.
|
||||
wg.Add(1)
|
||||
fastCtx, fastCancel := context.WithTimeout(ctx, duration)
|
||||
go func() {
|
||||
defer fastCancel()
|
||||
connectAndCount(fastCtx, t, url, subMsg, &fastTotal, &wg)
|
||||
}()
|
||||
|
||||
// Slow client: sleeps 100 ms between each read.
|
||||
wg.Add(1)
|
||||
slowCtx, slowCancel := context.WithTimeout(ctx, duration)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer slowCancel()
|
||||
|
||||
conn, _, err := websocket.Dial(slowCtx, url, nil)
|
||||
if err != nil {
|
||||
t.Errorf("slow client dial: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
|
||||
if err := conn.Write(slowCtx, websocket.MessageText, subMsg); err != nil {
|
||||
t.Errorf("slow client subscribe: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var n int64
|
||||
for {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
_, _, err := conn.Read(slowCtx)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
n++
|
||||
}
|
||||
slowTotal.Add(n)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
fast := fastTotal.Load()
|
||||
slow := slowTotal.Load()
|
||||
t.Logf("SlowConsumer: fast=%d updates, slow=%d updates in %s", fast, slow, duration)
|
||||
|
||||
// Fast client must receive substantially more than slow client.
|
||||
if fast < slow*3 {
|
||||
t.Errorf("fast client should receive many more updates than slow client (fast=%d slow=%d)", fast, slow)
|
||||
}
|
||||
|
||||
// Fast client must have received a meaningful number of updates.
|
||||
minFast := int64(nSignals) * int64(duration.Seconds()) * 5
|
||||
if fast < minFast {
|
||||
t.Errorf("fast client received too few updates: got %d, want >= %d", fast, minFast)
|
||||
}
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if n := brk.ActiveSubscriptions(); n != 0 {
|
||||
t.Errorf("subscription leak: %d remain", n)
|
||||
}
|
||||
}
|
||||
+344
-12
@@ -7,6 +7,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
@@ -22,9 +24,20 @@ type InterfaceMeta struct {
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
// Store persists interface definitions as XML files under {storageDir}/interfaces/.
|
||||
// VersionMeta describes a single persisted revision of an interface.
|
||||
type VersionMeta struct {
|
||||
Version int `json:"version"`
|
||||
Name string `json:"name"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Current bool `json:"current"`
|
||||
SavedAt time.Time `json:"savedAt"`
|
||||
}
|
||||
|
||||
// Store persists interface definitions as XML files under {storageDir}/interfaces/
|
||||
// and workspace-level JSON blobs directly in {storageDir}.
|
||||
type Store struct {
|
||||
dir string
|
||||
rootDir string // {storageDir} — for workspace-level files (groups.json, etc.)
|
||||
dir string // {storageDir}/interfaces
|
||||
}
|
||||
|
||||
// New opens (and creates, if needed) the storage directory.
|
||||
@@ -33,7 +46,22 @@ func New(storageDir string) (*Store, error) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create interfaces dir: %w", err)
|
||||
}
|
||||
return &Store{dir: dir}, nil
|
||||
return &Store{rootDir: storageDir, dir: dir}, nil
|
||||
}
|
||||
|
||||
// ReadGroups returns the raw JSON array of group tree nodes.
|
||||
// Returns an empty JSON array if no groups have been saved yet.
|
||||
func (s *Store) ReadGroups() ([]byte, error) {
|
||||
data, err := os.ReadFile(filepath.Join(s.rootDir, "groups.json"))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []byte("[]"), nil
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
|
||||
// WriteGroups persists the group tree. data must be a valid JSON array.
|
||||
func (s *Store) WriteGroups(data []byte) error {
|
||||
return os.WriteFile(filepath.Join(s.rootDir, "groups.json"), data, 0o644)
|
||||
}
|
||||
|
||||
// validateID returns an error if id could be used for path traversal or is
|
||||
@@ -63,10 +91,16 @@ func (s *Store) List() ([]InterfaceMeta, error) {
|
||||
}
|
||||
var out []InterfaceMeta
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".xml") {
|
||||
name := e.Name()
|
||||
if e.IsDir() || !strings.HasSuffix(strings.ToLower(name), ".xml") {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSuffix(e.Name(), ".xml")
|
||||
// Skip versioned backups: id.v1.xml, id.v2.xml, etc.
|
||||
if isVersioned(name) {
|
||||
continue
|
||||
}
|
||||
|
||||
id := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
meta, err := s.readMeta(id)
|
||||
if err != nil {
|
||||
continue // skip corrupt files silently
|
||||
@@ -79,12 +113,27 @@ func (s *Store) List() ([]InterfaceMeta, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func isVersioned(name string) bool {
|
||||
// Format: <id>.v<number>.xml
|
||||
parts := strings.Split(name, ".")
|
||||
if len(parts) != 3 {
|
||||
return false
|
||||
}
|
||||
vPart := parts[1]
|
||||
if !strings.HasPrefix(vPart, "v") {
|
||||
return false
|
||||
}
|
||||
_, err := strconv.Atoi(vPart[1:])
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// rootAttrs is used to parse only the top-level XML attributes.
|
||||
type rootAttrs struct {
|
||||
XMLName xml.Name `xml:"interface"`
|
||||
ID string `xml:"id,attr"`
|
||||
Name string `xml:"name,attr"`
|
||||
Version int `xml:"version,attr"`
|
||||
Tag string `xml:"tag,attr"`
|
||||
}
|
||||
|
||||
func (s *Store) readMeta(id string) (InterfaceMeta, error) {
|
||||
@@ -113,8 +162,8 @@ func (s *Store) Get(id string) ([]byte, error) {
|
||||
|
||||
// Create stores new interface XML and returns the generated ID.
|
||||
// The ID is derived from the name attribute; a timestamp suffix is added to
|
||||
// ensure uniqueness.
|
||||
func (s *Store) Create(xmlData []byte) (string, error) {
|
||||
// ensure uniqueness. If tag is non-empty it is stamped as the revision label.
|
||||
func (s *Store) Create(xmlData []byte, tag string) (string, error) {
|
||||
var root rootAttrs
|
||||
if err := xml.Unmarshal(xmlData, &root); err != nil {
|
||||
return "", fmt.Errorf("invalid XML: %w", err)
|
||||
@@ -128,18 +177,301 @@ func (s *Store) Create(xmlData []byte) (string, error) {
|
||||
if _, err := os.Stat(s.filePath(id)); err == nil {
|
||||
id = id + "-" + fmt.Sprintf("%d", time.Now().UnixMilli())
|
||||
}
|
||||
return id, os.WriteFile(s.filePath(id), xmlData, 0o644)
|
||||
|
||||
// Initialize version to 1 and stamp the generated ID so the persisted XML
|
||||
// is self-consistent (the client sends an empty id for new interfaces).
|
||||
updatedData, err := setXMLAttribute(xmlData, "version", "1")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("initialize version attribute: %w", err)
|
||||
}
|
||||
updatedData, err = setXMLAttribute(updatedData, "id", id)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stamp id attribute: %w", err)
|
||||
}
|
||||
if tag != "" {
|
||||
updatedData, err = setXMLAttribute(updatedData, "tag", tag)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("set tag attribute: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return id, os.WriteFile(s.filePath(id), updatedData, 0o644)
|
||||
}
|
||||
|
||||
// Update replaces the XML for an existing interface.
|
||||
func (s *Store) Update(id string, xmlData []byte) error {
|
||||
// Update replaces the XML for an existing interface, preserving the previous
|
||||
// version as a separate file. If tag is non-empty it is stamped as the label
|
||||
// for the new revision.
|
||||
func (s *Store) Update(id string, xmlData []byte, tag string) error {
|
||||
if err := validateID(id); err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
if _, err := os.Stat(s.filePath(id)); errors.Is(err, os.ErrNotExist) {
|
||||
|
||||
oldPath := s.filePath(id)
|
||||
oldData, err := os.ReadFile(oldPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse current version to name the backup file.
|
||||
var oldRoot rootAttrs
|
||||
if err := xml.Unmarshal(oldData, &oldRoot); err != nil {
|
||||
return fmt.Errorf("parse existing XML: %w", err)
|
||||
}
|
||||
if oldRoot.Version < 1 {
|
||||
oldRoot.Version = 1
|
||||
}
|
||||
|
||||
// Move old file to backup path: id.vN.xml
|
||||
backupPath := filepath.Join(s.dir, fmt.Sprintf("%s.v%d.xml", id, oldRoot.Version))
|
||||
if err := os.WriteFile(backupPath, oldData, 0o644); err != nil {
|
||||
return fmt.Errorf("create backup: %w", err)
|
||||
}
|
||||
|
||||
// Update version in new data.
|
||||
newVersion := oldRoot.Version + 1
|
||||
updatedData, err := setXMLAttribute(xmlData, "version", fmt.Sprintf("%d", newVersion))
|
||||
if err != nil {
|
||||
return fmt.Errorf("update version attribute: %w", err)
|
||||
}
|
||||
if tag != "" {
|
||||
updatedData, err = setXMLAttribute(updatedData, "tag", tag)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set tag attribute: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return os.WriteFile(oldPath, updatedData, 0o644)
|
||||
}
|
||||
|
||||
// Versions returns metadata for every persisted revision of the interface,
|
||||
// ordered newest-first. The current file is flagged with Current=true.
|
||||
func (s *Store) Versions(id string) ([]VersionMeta, error) {
|
||||
if err := validateID(id); err != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
// Current revision.
|
||||
curInfo, err := os.Stat(s.filePath(id))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ErrNotFound
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
curRoot, err := s.readRoot(s.filePath(id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := []VersionMeta{{
|
||||
Version: curRoot.Version,
|
||||
Name: curRoot.Name,
|
||||
Tag: curRoot.Tag,
|
||||
Current: true,
|
||||
SavedAt: curInfo.ModTime(),
|
||||
}}
|
||||
|
||||
// Backup revisions: id.vN.xml
|
||||
entries, err := os.ReadDir(s.dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefix := id + ".v"
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() || !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".xml") {
|
||||
continue
|
||||
}
|
||||
if !isVersioned(name) {
|
||||
continue
|
||||
}
|
||||
p := filepath.Join(s.dir, name)
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
root, err := s.readRoot(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, VersionMeta{
|
||||
Version: root.Version,
|
||||
Name: root.Name,
|
||||
Tag: root.Tag,
|
||||
SavedAt: info.ModTime(),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Version > out[j].Version })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetVersion returns the raw XML bytes for a specific revision of an interface.
|
||||
func (s *Store) GetVersion(id string, version int) ([]byte, error) {
|
||||
if err := validateID(id); err != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
// The current file may hold the requested version.
|
||||
curRoot, err := s.readRoot(s.filePath(id))
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if curRoot.Version == version {
|
||||
return os.ReadFile(s.filePath(id))
|
||||
}
|
||||
|
||||
backupPath := filepath.Join(s.dir, fmt.Sprintf("%s.v%d.xml", id, version))
|
||||
data, err := os.ReadFile(backupPath)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
|
||||
// versionPath returns the on-disk path for a specific revision, resolving the
|
||||
// current file when version matches the live revision.
|
||||
func (s *Store) versionPath(id string, version int) (string, error) {
|
||||
cur := s.filePath(id)
|
||||
curRoot, err := s.readRoot(cur)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if curRoot.Version == version {
|
||||
return cur, nil
|
||||
}
|
||||
backup := filepath.Join(s.dir, fmt.Sprintf("%s.v%d.xml", id, version))
|
||||
if _, err := os.Stat(backup); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return backup, nil
|
||||
}
|
||||
|
||||
// SetVersionTag sets (or clears, when tag is empty) the label of a specific
|
||||
// revision in place, without creating a new revision.
|
||||
func (s *Store) SetVersionTag(id string, version int, tag string) error {
|
||||
if err := validateID(id); err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
return os.WriteFile(s.filePath(id), xmlData, 0o644)
|
||||
path, err := s.versionPath(id, version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updated, err := setXMLAttribute(data, "tag", tag)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set tag attribute: %w", err)
|
||||
}
|
||||
return os.WriteFile(path, updated, 0o644)
|
||||
}
|
||||
|
||||
// Promote makes a past revision the current one by re-saving its content as a
|
||||
// new revision on top of history. The existing current revision is preserved
|
||||
// as a backup, so promotion is non-destructive.
|
||||
func (s *Store) Promote(id string, version int) error {
|
||||
data, err := s.GetVersion(id, version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Update(id, data, fmt.Sprintf("restored from v%d", version))
|
||||
}
|
||||
|
||||
// Fork creates a brand-new interface from the content of a specific revision,
|
||||
// assigning a fresh unique ID and resetting its version to 1.
|
||||
func (s *Store) Fork(id string, version int) (string, error) {
|
||||
data, err := s.GetVersion(id, version)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
newID := id + "-fork-" + fmt.Sprintf("%d", time.Now().UnixMilli())
|
||||
updated, err := setXMLAttribute(data, "version", "1")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reset version attribute: %w", err)
|
||||
}
|
||||
updated, err = setXMLAttribute(updated, "id", newID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stamp id attribute: %w", err)
|
||||
}
|
||||
updated, err = setXMLAttribute(updated, "tag", "")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("clear tag attribute: %w", err)
|
||||
}
|
||||
return newID, os.WriteFile(s.filePath(newID), updated, 0o644)
|
||||
}
|
||||
|
||||
// readRoot parses the top-level interface attributes from the file at path.
|
||||
func (s *Store) readRoot(path string) (rootAttrs, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return rootAttrs{}, err
|
||||
}
|
||||
var root rootAttrs
|
||||
if err := xml.Unmarshal(data, &root); err != nil {
|
||||
return rootAttrs{}, err
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
// setXMLAttribute is a primitive helper to update a top-level attribute in an
|
||||
// XML blob without full unmarshal/marshal (which might mess up formatting).
|
||||
func setXMLAttribute(data []byte, attr, value string) ([]byte, error) {
|
||||
// Very basic implementation: look for the attribute in the first 512 bytes.
|
||||
// A proper implementation would use an XML encoder or a better regex.
|
||||
s := string(data)
|
||||
|
||||
// Locate the root element's opening tag, skipping any XML declaration
|
||||
// (<?xml ... ?>), comments, or processing instructions. Operating on the
|
||||
// first '>' in the document would match the '?>' of the XML declaration and
|
||||
// corrupt its version attribute (producing invalid XML).
|
||||
tagStart := -1
|
||||
for i := 0; i+1 < len(s); i++ {
|
||||
if s[i] == '<' && s[i+1] != '?' && s[i+1] != '!' {
|
||||
tagStart = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if tagStart == -1 {
|
||||
return nil, errors.New("malformed XML: no root element")
|
||||
}
|
||||
rel := strings.Index(s[tagStart:], ">")
|
||||
if rel == -1 {
|
||||
return nil, errors.New("malformed XML: no root tag end")
|
||||
}
|
||||
tagEnd := tagStart + rel
|
||||
rootTag := s[tagStart:tagEnd]
|
||||
|
||||
attrSearch := " " + attr + "=\""
|
||||
idx := strings.Index(rootTag, attrSearch)
|
||||
if idx == -1 {
|
||||
// Attribute not found, insert it before the tag ends.
|
||||
insertIdx := tagEnd
|
||||
if s[tagEnd-1] == '/' {
|
||||
insertIdx-- // handle <tag />
|
||||
}
|
||||
return []byte(s[:insertIdx] + fmt.Sprintf(" %s=\"%s\"", attr, value) + s[insertIdx:]), nil
|
||||
}
|
||||
|
||||
// Attribute found, replace its value (idx is relative to rootTag).
|
||||
valStart := tagStart + idx + len(attrSearch)
|
||||
valEnd := strings.Index(s[valStart:], "\"")
|
||||
if valEnd == -1 {
|
||||
return nil, errors.New("malformed XML: attribute value not closed")
|
||||
}
|
||||
return []byte(s[:valStart] + value + s[valStart+valEnd:]), nil
|
||||
}
|
||||
|
||||
// Delete removes the interface with the given ID.
|
||||
|
||||
@@ -35,7 +35,7 @@ func TestListEmpty(t *testing.T) {
|
||||
|
||||
func TestListAfterCreate(t *testing.T) {
|
||||
s := newStore(t)
|
||||
if _, err := s.Create([]byte(sampleXML)); err != nil {
|
||||
if _, err := s.Create([]byte(sampleXML), ""); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
list, err := s.List()
|
||||
@@ -59,7 +59,7 @@ func TestListAfterCreate(t *testing.T) {
|
||||
|
||||
func TestCreateAndGet(t *testing.T) {
|
||||
s := newStore(t)
|
||||
id, err := s.Create([]byte(sampleXML))
|
||||
id, err := s.Create([]byte(sampleXML), "")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
@@ -78,7 +78,7 @@ func TestCreateAndGet(t *testing.T) {
|
||||
|
||||
func TestCreateUsesEmbeddedID(t *testing.T) {
|
||||
s := newStore(t)
|
||||
id, err := s.Create([]byte(sampleXML))
|
||||
id, err := s.Create([]byte(sampleXML), "")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
@@ -90,11 +90,11 @@ func TestCreateUsesEmbeddedID(t *testing.T) {
|
||||
|
||||
func TestCreateDuplicateGetsUniqueID(t *testing.T) {
|
||||
s := newStore(t)
|
||||
id1, err := s.Create([]byte(sampleXML))
|
||||
id1, err := s.Create([]byte(sampleXML), "")
|
||||
if err != nil {
|
||||
t.Fatalf("first Create: %v", err)
|
||||
}
|
||||
id2, err := s.Create([]byte(sampleXML))
|
||||
id2, err := s.Create([]byte(sampleXML), "")
|
||||
if err != nil {
|
||||
t.Fatalf("second Create: %v", err)
|
||||
}
|
||||
@@ -105,7 +105,7 @@ func TestCreateDuplicateGetsUniqueID(t *testing.T) {
|
||||
|
||||
func TestCreateInvalidXML(t *testing.T) {
|
||||
s := newStore(t)
|
||||
_, err := s.Create([]byte("not xml"))
|
||||
_, err := s.Create([]byte("not xml"), "")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid XML")
|
||||
}
|
||||
@@ -145,13 +145,13 @@ func TestGetEmptyID(t *testing.T) {
|
||||
|
||||
func TestUpdate(t *testing.T) {
|
||||
s := newStore(t)
|
||||
id, err := s.Create([]byte(sampleXML))
|
||||
id, err := s.Create([]byte(sampleXML), "")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
updated := `<interface id="test-if" name="Updated" version="2"><widget/></interface>`
|
||||
if err := s.Update(id, []byte(updated)); err != nil {
|
||||
if err := s.Update(id, []byte(updated), ""); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ func TestUpdate(t *testing.T) {
|
||||
|
||||
func TestUpdateNotFound(t *testing.T) {
|
||||
s := newStore(t)
|
||||
err := s.Update("no-such-id", []byte(sampleXML))
|
||||
err := s.Update("no-such-id", []byte(sampleXML), "")
|
||||
if !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("Update missing: want ErrNotFound, got %v", err)
|
||||
}
|
||||
@@ -178,7 +178,7 @@ func TestUpdateNotFound(t *testing.T) {
|
||||
|
||||
func TestDelete(t *testing.T) {
|
||||
s := newStore(t)
|
||||
id, err := s.Create([]byte(sampleXML))
|
||||
id, err := s.Create([]byte(sampleXML), "")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
@@ -205,7 +205,7 @@ func TestDeleteNotFound(t *testing.T) {
|
||||
|
||||
func TestClone(t *testing.T) {
|
||||
s := newStore(t)
|
||||
id, err := s.Create([]byte(sampleXML))
|
||||
id, err := s.Create([]byte(sampleXML), "")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
+17
-4
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"os/user"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -42,11 +42,18 @@ type Config struct {
|
||||
// EPICS_CA_ADDR_LIST — space-separated list of server addresses
|
||||
// EPICS_CA_AUTO_ADDR_LIST — "NO" disables automatic broadcast addresses
|
||||
func ConfigFromEnv() Config {
|
||||
name := filepath.Base(os.Args[0])
|
||||
// CA security ACLs match on the client username, not the program name.
|
||||
// Use the OS username (same as libca), falling back to $USER, then "user".
|
||||
clientName := "user"
|
||||
if u, err := user.Current(); err == nil && u.Username != "" {
|
||||
clientName = u.Username
|
||||
} else if v := os.Getenv("USER"); v != "" {
|
||||
clientName = v
|
||||
}
|
||||
host, _ := os.Hostname()
|
||||
cfg := Config{
|
||||
AutoAddrList: true,
|
||||
ClientName: name,
|
||||
ClientName: clientName,
|
||||
HostName: host,
|
||||
}
|
||||
if v := os.Getenv("EPICS_CA_ADDR_LIST"); v != "" {
|
||||
@@ -103,7 +110,13 @@ func NewClient(ctx context.Context, cfg Config) (*Client, error) {
|
||||
}
|
||||
|
||||
if cfg.ClientName == "" {
|
||||
cfg.ClientName = filepath.Base(os.Args[0])
|
||||
if u, err := user.Current(); err == nil && u.Username != "" {
|
||||
cfg.ClientName = u.Username
|
||||
} else if v := os.Getenv("USER"); v != "" {
|
||||
cfg.ClientName = v
|
||||
} else {
|
||||
cfg.ClientName = "user"
|
||||
}
|
||||
}
|
||||
if cfg.HostName == "" {
|
||||
cfg.HostName, _ = os.Hostname()
|
||||
|
||||
+45
-16
@@ -57,13 +57,15 @@ type chanState struct {
|
||||
cid uint32
|
||||
pvName string
|
||||
|
||||
mu sync.RWMutex
|
||||
sid uint32 // server-assigned channel ID (0 until CREATE_CHAN reply)
|
||||
dbfType int // native DBF field type
|
||||
count uint32 // element count
|
||||
access uint32 // AccessRead | AccessWrite bitmask
|
||||
readyC chan struct{} // closed once CREATE_CHAN reply is received
|
||||
monitors []*monState // active subscriptions
|
||||
mu sync.RWMutex
|
||||
sid uint32 // server-assigned channel ID (0 until CREATE_CHAN reply)
|
||||
dbfType int // native DBF field type
|
||||
count uint32 // element count
|
||||
access uint32 // AccessRead | AccessWrite bitmask
|
||||
gotChan bool // CREATE_CHAN reply received for current connection
|
||||
gotAccess bool // ACCESS_RIGHTS received for current connection
|
||||
readyC chan struct{} // closed once both CREATE_CHAN and ACCESS_RIGHTS received; replaced on reconnect
|
||||
monitors []*monState // active subscriptions
|
||||
}
|
||||
|
||||
func newChanState(cid uint32, pvName string) *chanState {
|
||||
@@ -82,6 +84,8 @@ func newChanState(cid uint32, pvName string) *chanState {
|
||||
func (cs *chanState) resetForReconnect() {
|
||||
cs.mu.Lock()
|
||||
cs.sid = 0
|
||||
cs.gotChan = false
|
||||
cs.gotAccess = false
|
||||
old := cs.readyC
|
||||
cs.readyC = make(chan struct{})
|
||||
cs.mu.Unlock()
|
||||
@@ -94,25 +98,25 @@ func (cs *chanState) resetForReconnect() {
|
||||
}
|
||||
}
|
||||
|
||||
// waitReady blocks until sid != 0 (CREATE_CHAN reply received for the current
|
||||
// connection) or ctx expires. It loops through reconnections automatically:
|
||||
// when resetForReconnect closes the current readyC the goroutine wakes, sees
|
||||
// sid == 0, and waits on the freshly created channel.
|
||||
// waitReady blocks until both CREATE_CHAN and ACCESS_RIGHTS have been received
|
||||
// (i.e. sid != 0 and gotAccess == true) or ctx expires. It loops through
|
||||
// reconnections automatically: when resetForReconnect closes the current readyC
|
||||
// the goroutine wakes, sees sid == 0, and waits on the freshly created channel.
|
||||
func (cs *chanState) waitReady(ctx context.Context) error {
|
||||
for {
|
||||
cs.mu.RLock()
|
||||
sid := cs.sid
|
||||
gotAccess := cs.gotAccess
|
||||
ready := cs.readyC
|
||||
cs.mu.RUnlock()
|
||||
|
||||
if sid != 0 {
|
||||
if sid != 0 && gotAccess {
|
||||
return nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ready:
|
||||
// State changed — either CREATE_CHAN reply (sid set) or reconnect
|
||||
// started (sid cleared, new readyC installed). Loop to check.
|
||||
// State changed — loop to re-check sid and gotAccess.
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
@@ -378,7 +382,11 @@ func (c *circuit) dispatch(conn net.Conn, hdr proto.Header, payload []byte) {
|
||||
cs.sid = hdr.Parameter2
|
||||
cs.dbfType = int(hdr.DataType)
|
||||
cs.count = hdr.DataCount
|
||||
ready := cs.readyC
|
||||
cs.gotChan = true
|
||||
var ready chan struct{}
|
||||
if cs.gotAccess {
|
||||
ready = cs.readyC
|
||||
}
|
||||
// Snapshot monitors for EVENT_ADD re-subscription.
|
||||
mons := make([]*monState, len(cs.monitors))
|
||||
copy(mons, cs.monitors)
|
||||
@@ -397,7 +405,12 @@ func (c *circuit) dispatch(conn net.Conn, hdr proto.Header, payload []byte) {
|
||||
default:
|
||||
}
|
||||
}
|
||||
close(ready) // unblock waitReady callers
|
||||
// Unblock waitReady only once ACCESS_RIGHTS has also arrived.
|
||||
// ACCESS_RIGHTS always follows CREATE_CHAN, so ready is typically nil here
|
||||
// and the close happens in the CmdAccessRights handler below.
|
||||
if ready != nil {
|
||||
close(ready)
|
||||
}
|
||||
|
||||
case proto.CmdCreateFail:
|
||||
// Server does not host this PV (or quota exceeded).
|
||||
@@ -422,13 +435,29 @@ func (c *circuit) dispatch(conn net.Conn, hdr proto.Header, payload []byte) {
|
||||
|
||||
case proto.CmdAccessRights:
|
||||
// Parameter1 = cid, Parameter2 = access bitmask.
|
||||
// ACCESS_RIGHTS always arrives just after CREATE_CHAN; we defer closing
|
||||
// readyC until here so callers see the correct access value immediately.
|
||||
c.mu.RLock()
|
||||
cs, ok := c.byCID[hdr.Parameter1]
|
||||
c.mu.RUnlock()
|
||||
dbg("CA ACCESS_RIGHTS", "cid", hdr.Parameter1, "access", hdr.Parameter2, "found", ok)
|
||||
if ok {
|
||||
cs.mu.Lock()
|
||||
cs.access = hdr.Parameter2
|
||||
cs.gotAccess = true
|
||||
var ready chan struct{}
|
||||
if cs.gotChan {
|
||||
ready = cs.readyC
|
||||
}
|
||||
cs.mu.Unlock()
|
||||
if ready != nil {
|
||||
select {
|
||||
case <-ready:
|
||||
// Already closed (guard against duplicate ACCESS_RIGHTS).
|
||||
default:
|
||||
close(ready)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case proto.CmdEventAdd:
|
||||
|
||||
@@ -80,6 +80,7 @@ func main() {
|
||||
// Copy uPlot CSS and favicon
|
||||
copyFile(filepath.Join(root, "web", "vendor", "uplot.css"), filepath.Join(distDir, "uplot.css"))
|
||||
copyFile(filepath.Join(root, "web", "public", "favicon.svg"), filepath.Join(distDir, "favicon.svg"))
|
||||
copyFile(filepath.Join(root, "web", "public", "favicon.svg"), filepath.Join(distDir, "favicon.ico"))
|
||||
|
||||
// Write index.html
|
||||
html := `<!doctype html>
|
||||
@@ -88,6 +89,7 @@ func main() {
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>uopi</title>
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="stylesheet" href="/uplot.css" />
|
||||
<link rel="stylesheet" href="/main.css" />
|
||||
|
||||
+33
-5
@@ -2,11 +2,39 @@
|
||||
listen = ":8080"
|
||||
storage_dir = "./interfaces"
|
||||
|
||||
# ── Identity & access control ───────────────────────────────────────────────
|
||||
# The end-user identity is read from a header set by a trusted authenticating
|
||||
# reverse proxy. Leave trusted_user_header empty for unproxied/dev/LAN use.
|
||||
# SECURITY: only enable this when the proxy strips any client-supplied value of
|
||||
# this header, otherwise it can be spoofed.
|
||||
trusted_user_header = "" # e.g. "X-Forwarded-User"
|
||||
# Identity used when the trusted header is absent/empty. Empty = anonymous.
|
||||
default_user = ""
|
||||
|
||||
# Every user is trusted with full write access by default. The blacklist
|
||||
# downgrades specific users. Levels: "readonly" (view only, no writes) or
|
||||
# "noaccess" (denied entirely).
|
||||
# [[server.blacklist]]
|
||||
# user = "guest"
|
||||
# level = "readonly"
|
||||
|
||||
# Named sets of users, referenced by per-panel sharing rules.
|
||||
# [[groups]]
|
||||
# name = "operators"
|
||||
# members = ["alice", "bob"]
|
||||
|
||||
# Optional: restrict who may add or edit panel logic (the <logic> block of
|
||||
# interfaces) AND server-side control logic. Entries are usernames or group
|
||||
# names. Empty/unset = no restriction (any writer may edit logic). Also settable
|
||||
# via UOPI_SERVER_LOGIC_EDITORS (space-separated).
|
||||
# logic_editors = ["operators", "alice"]
|
||||
|
||||
[datasource.epics]
|
||||
enabled = true
|
||||
ca_addr_list = "" # overrides EPICS_CA_ADDR_LIST if set
|
||||
archive_url = "" # EPICS Archive Appliance base URL, e.g. http://archiver:17665/
|
||||
enabled = true
|
||||
ca_addr_list = "" # overrides EPICS_CA_ADDR_LIST if set
|
||||
archive_url = "" # EPICS Archive Appliance base URL
|
||||
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
|
||||
|
||||
+39
-11
@@ -4,7 +4,9 @@ import { wsClient } from './lib/ws';
|
||||
import ViewMode from './ViewMode';
|
||||
import EditMode from './EditMode';
|
||||
import FullscreenMode from './FullscreenMode';
|
||||
import type { Interface } from './lib/types';
|
||||
import { applyZoom, getStoredZoom } from './ZoomControl';
|
||||
import { AuthContext, DEFAULT_ME, fetchMe, canRead } from './lib/auth';
|
||||
import type { Interface, Me } from './lib/types';
|
||||
|
||||
type AppMode = 'view' | 'edit';
|
||||
|
||||
@@ -15,6 +17,10 @@ export default function App() {
|
||||
const [mode, setMode] = useState<AppMode>('view');
|
||||
const [wsStatus, setWsStatus] = useState('connecting');
|
||||
const [editTarget, setEditTarget] = useState<Interface | null>(null);
|
||||
// Interface to show when returning to view mode after editing
|
||||
const [viewTarget, setViewTarget] = useState<Interface | null>(null);
|
||||
// Resolved identity + global access level for the current user.
|
||||
const [me, setMe] = useState<Me>(DEFAULT_ME);
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = wsClient.status.subscribe(setWsStatus);
|
||||
@@ -24,7 +30,9 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
const dpr = window.devicePixelRatio ?? 1;
|
||||
document.documentElement.style.setProperty('--dpr', String(dpr));
|
||||
applyZoom(getStoredZoom());
|
||||
if (!fsParam) wsClient.connect('/ws');
|
||||
fetchMe().then(setMe);
|
||||
}, []);
|
||||
|
||||
if (fsParam) {
|
||||
@@ -36,20 +44,40 @@ export default function App() {
|
||||
setMode('edit');
|
||||
}
|
||||
|
||||
function exitEdit() {
|
||||
// `iface === null` means the editor discarded an unsaved/new panel; keep
|
||||
// showing whatever was previously open (held in viewTarget).
|
||||
function exitEdit(iface: Interface | null) {
|
||||
if (iface) setViewTarget(iface);
|
||||
setMode('view');
|
||||
setEditTarget(null);
|
||||
}
|
||||
|
||||
if (!canRead(me.level)) {
|
||||
return (
|
||||
<div class="app-root">
|
||||
<div class="access-denied">
|
||||
<h1>Access denied</h1>
|
||||
<p>
|
||||
Your account{me.user ? ` (${me.user})` : ''} does not have permission
|
||||
to access this application. Contact an administrator.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{wsStatus === 'disconnected' && (
|
||||
<div class="connection-banner">WebSocket disconnected — reconnecting…</div>
|
||||
)}
|
||||
{mode === 'view'
|
||||
? <ViewMode onEdit={enterEdit} />
|
||||
: <EditMode initial={editTarget} onDone={exitEdit} />
|
||||
}
|
||||
</div>
|
||||
<AuthContext.Provider value={me}>
|
||||
<div class="app-root">
|
||||
{wsStatus === 'disconnected' && (
|
||||
<div class="connection-banner">WebSocket disconnected — reconnecting…</div>
|
||||
)}
|
||||
{mode === 'view' ? (
|
||||
<ViewMode onEdit={enterEdit} initialInterface={viewTarget} onView={setViewTarget} />
|
||||
) : (
|
||||
<EditMode key={editTarget?.id || 'new'} initial={editTarget} onDone={exitEdit} />
|
||||
)}
|
||||
</div>
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
+110
-6
@@ -1,5 +1,8 @@
|
||||
import { h } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { h, Fragment } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { initLocalState } from './lib/localstate';
|
||||
import { logicEngine } from './lib/logic';
|
||||
import { getWidgetCmdStore, type WidgetCmd } from './lib/widgetCommands';
|
||||
import type { Interface, Widget, SignalRef } from './lib/types';
|
||||
import TextView from './widgets/TextView';
|
||||
import TextLabel from './widgets/TextLabel';
|
||||
@@ -14,6 +17,9 @@ import PlotWidget from './widgets/PlotWidget';
|
||||
import ImageWidget from './widgets/ImageWidget';
|
||||
import LinkWidget from './widgets/LinkWidget';
|
||||
import ContextMenu from './ContextMenu';
|
||||
import InfoPanel from './InfoPanel';
|
||||
import SplitLayout from './SplitLayout';
|
||||
import LogicDialogs from './LogicDialogs';
|
||||
|
||||
const COMPONENTS: Record<string, any> = {
|
||||
textview: TextView,
|
||||
@@ -37,6 +43,32 @@ interface CtxState {
|
||||
signal: SignalRef | null;
|
||||
}
|
||||
|
||||
// Wraps a single view-mode widget so panel logic (action.widget) can drive it:
|
||||
// `hidden` removes it from the view, `disabled` dims it and blocks interaction
|
||||
// via a transparent overlay (which still surfaces the right-click menu). Plot
|
||||
// pause/clear are handled inside PlotWidget itself.
|
||||
function WidgetView({ widget, children, onContextMenu }: {
|
||||
widget: Widget;
|
||||
children: any;
|
||||
onContextMenu: (e: MouseEvent) => void;
|
||||
key?: string;
|
||||
}) {
|
||||
const [cmd, setCmd] = useState<WidgetCmd>(() => getWidgetCmdStore(widget.id).get());
|
||||
useEffect(() => getWidgetCmdStore(widget.id).subscribe(setCmd), [widget.id]);
|
||||
if (cmd.hidden) return null;
|
||||
if (!cmd.disabled) return children;
|
||||
return (
|
||||
<Fragment>
|
||||
{children}
|
||||
<div
|
||||
class="widget-disable-overlay"
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
iface: Interface | null;
|
||||
onNavigate?: (interfaceId: string) => void;
|
||||
@@ -47,6 +79,18 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
|
||||
const [ctxMenu, setCtxMenu] = useState<CtxState>({
|
||||
visible: false, x: 0, y: 0, signal: null,
|
||||
});
|
||||
const [infoSignal, setInfoSignal] = useState<SignalRef | null>(null);
|
||||
|
||||
// Instantiate this panel's local state variables from their initial values.
|
||||
useEffect(() => {
|
||||
initLocalState(iface?.statevars);
|
||||
}, [iface?.id, iface?.statevars]);
|
||||
|
||||
// Activate panel logic for the live view; tear it down on unmount/panel switch.
|
||||
useEffect(() => {
|
||||
logicEngine.load(iface?.logic);
|
||||
return () => logicEngine.clear();
|
||||
}, [iface?.id, iface?.logic]);
|
||||
|
||||
function onCtxMenu(e: MouseEvent, widget: Widget) {
|
||||
e.preventDefault();
|
||||
@@ -54,6 +98,14 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
|
||||
setCtxMenu({ visible: true, x: e.clientX, y: e.clientY, signal: widget.signals[0] ?? null });
|
||||
}
|
||||
|
||||
function closeCtxMenu() {
|
||||
setCtxMenu(c => ({ ...c, visible: false }));
|
||||
}
|
||||
|
||||
function handleInfo() {
|
||||
if (ctxMenu.signal) setInfoSignal(ctxMenu.signal);
|
||||
}
|
||||
|
||||
if (!iface) {
|
||||
return (
|
||||
<div class="canvas-container">
|
||||
@@ -64,14 +116,50 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
// Plot panel: plots fill the viewport arranged in a recursive split layout.
|
||||
if (iface.kind === 'plot' && iface.layout) {
|
||||
const byId = new Map(iface.widgets.map(w => [w.id, w]));
|
||||
return (
|
||||
<div class="canvas-container">
|
||||
<div class="plot-panel-view">
|
||||
<SplitLayout
|
||||
layout={iface.layout}
|
||||
renderLeaf={(wid) => {
|
||||
const widget = byId.get(wid);
|
||||
if (!widget) return <div class="plot-pane-empty">missing plot</div>;
|
||||
return (
|
||||
<PlotWidget
|
||||
widget={widget}
|
||||
onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)}
|
||||
timeRange={timeRange}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ContextMenu
|
||||
{...ctxMenu}
|
||||
onClose={closeCtxMenu}
|
||||
onInfo={handleInfo}
|
||||
/>
|
||||
|
||||
{infoSignal && (
|
||||
<InfoPanel signal={infoSignal} onClose={() => setInfoSignal(null)} />
|
||||
)}
|
||||
|
||||
<LogicDialogs />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="canvas-container">
|
||||
<div class="canvas-area" style={`width:${iface.w}px;height:${iface.h}px;`}>
|
||||
{iface.widgets.map(widget => {
|
||||
const Comp = COMPONENTS[widget.type];
|
||||
return Comp
|
||||
const inner = Comp
|
||||
? <Comp
|
||||
key={widget.id}
|
||||
widget={widget}
|
||||
onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)}
|
||||
onNavigate={onNavigate}
|
||||
@@ -79,7 +167,6 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
|
||||
/>
|
||||
: (
|
||||
<div
|
||||
key={widget.id}
|
||||
class="unknown-widget"
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
title={`Unknown widget type: ${widget.type}`}
|
||||
@@ -88,12 +175,29 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
|
||||
<span class="unknown-label">{widget.type}</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<WidgetView
|
||||
key={widget.id}
|
||||
widget={widget}
|
||||
onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)}
|
||||
>
|
||||
{inner}
|
||||
</WidgetView>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ContextMenu
|
||||
{...ctxMenu}
|
||||
onClose={() => setCtxMenu(c => ({ ...c, visible: false }))}
|
||||
onClose={closeCtxMenu}
|
||||
onInfo={handleInfo}
|
||||
/>
|
||||
|
||||
{infoSignal && (
|
||||
<InfoPanel signal={infoSignal} onClose={() => setInfoSignal(null)} />
|
||||
)}
|
||||
|
||||
<LogicDialogs />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useMemo } from 'preact/hooks';
|
||||
import type { SignalRef } from './lib/types';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onAddSignals: (signals: SignalRef[]) => void;
|
||||
}
|
||||
|
||||
interface Rule {
|
||||
type: 'name' | 'tag' | 'property';
|
||||
key?: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
type SearchSource = 'cfs' | 'archiver';
|
||||
|
||||
export default function ChannelFinderModal({ onClose, onAddSignals }: Props) {
|
||||
const [source, setSource] = useState<SearchSource>('cfs');
|
||||
|
||||
// CFS State
|
||||
const [rules, setRules] = useState<Rule[]>([{ type: 'name', value: '' }]);
|
||||
const [cfsResults, setCfsResults] = useState<Array<{ name: string, properties: any, tags: any }>>([]);
|
||||
|
||||
// Archiver State
|
||||
const [archPattern, setArchPattern] = useState('');
|
||||
const [archResults, setArchResults] = useState<string[]>([]);
|
||||
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function handleCfsSearch() {
|
||||
if (!rules.some(r => r.value.trim() !== '')) return;
|
||||
setSearching(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
for (const r of rules) {
|
||||
if (!r.value.trim()) continue;
|
||||
if (r.type === 'name') {
|
||||
params.append('~name', r.value.includes('*') ? r.value : `*${r.value}*`);
|
||||
} else if (r.type === 'tag') {
|
||||
params.append('~tag', r.value);
|
||||
} else if (r.type === 'property' && r.key) {
|
||||
params.append(r.key, r.value);
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/v1/channel-finder?${params.toString()}`);
|
||||
if (!res.ok) throw new Error(`CFS Search failed: ${res.statusText}`);
|
||||
const data = await res.json();
|
||||
setCfsResults(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleArchSearch() {
|
||||
if (!archPattern.trim()) return;
|
||||
setSearching(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/archiver/search?q=${encodeURIComponent(archPattern)}`);
|
||||
if (!res.ok) {
|
||||
if (res.status === 501) throw new Error("Archiver discovery not configured");
|
||||
throw new Error(`Archiver Search failed: ${res.statusText}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
setArchResults(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}
|
||||
|
||||
function addRule() {
|
||||
setRules([...rules, { type: 'property', key: '', value: '' }]);
|
||||
}
|
||||
|
||||
function updateRule(idx: number, patch: Partial<Rule>) {
|
||||
setRules(rules.map((r, i) => i === idx ? { ...r, ...patch } : r));
|
||||
}
|
||||
|
||||
function removeRule(idx: number) {
|
||||
setRules(rules.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
function handleAddAll() {
|
||||
const refs = source === 'cfs'
|
||||
? cfsResults.map(r => ({ ds: 'epics', name: r.name }))
|
||||
: archResults.map(name => ({ ds: 'epics', name }));
|
||||
onAddSignals(refs);
|
||||
onClose();
|
||||
}
|
||||
|
||||
const resultsCount = source === 'cfs' ? cfsResults.length : archResults.length;
|
||||
|
||||
return (
|
||||
<div class="wizard-backdrop" onClick={onClose}>
|
||||
<div class="wizard" onClick={e => e.stopPropagation()} style="max-width: 750px; height: 85vh;">
|
||||
<div class="wizard-header">
|
||||
<span>PV Discovery</span>
|
||||
<button class="icon-btn" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div class="wizard-body" style="display: flex; flex-direction: column;">
|
||||
{/* Source Tabs */}
|
||||
<div class="signal-tree-tabs" style="margin-bottom: 1.5rem;">
|
||||
<button
|
||||
class={`stab${source === 'cfs' ? ' stab-active' : ''}`}
|
||||
onClick={() => { setSource('cfs'); setError(''); }}
|
||||
>Channel Finder</button>
|
||||
<button
|
||||
class={`stab${source === 'archiver' ? ' stab-active' : ''}`}
|
||||
onClick={() => { setSource('archiver'); setError(''); }}
|
||||
>Archive Appliance</button>
|
||||
</div>
|
||||
|
||||
{source === 'cfs' ? (
|
||||
<div style="flex: 0 0 auto;">
|
||||
<div class="wizard-section-title">Channel Finder Rules</div>
|
||||
<p class="hint" style="padding: 0 0 0.5rem 0;">Filter by name, tags, or properties.</p>
|
||||
|
||||
{rules.map((rule, idx) => (
|
||||
<div key={idx} class="wizard-field wizard-field-row" style="align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||
<select
|
||||
class="prop-select"
|
||||
style="flex: 0 0 100px;"
|
||||
value={rule.type}
|
||||
onChange={e => updateRule(idx, { type: (e.target as HTMLSelectElement).value as any })}
|
||||
>
|
||||
<option value="name">Name</option>
|
||||
<option value="tag">Tag</option>
|
||||
<option value="property">Property</option>
|
||||
</select>
|
||||
|
||||
{rule.type === 'property' && (
|
||||
<input
|
||||
class="prop-input"
|
||||
style="flex: 1;"
|
||||
placeholder="Property name"
|
||||
value={rule.key}
|
||||
onInput={e => updateRule(idx, { key: (e.target as HTMLInputElement).value })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<input
|
||||
class="prop-input"
|
||||
style="flex: 2;"
|
||||
placeholder={rule.type === 'name' ? 'PV name pattern' : 'Value'}
|
||||
value={rule.value}
|
||||
onInput={e => updateRule(idx, { value: (e.target as HTMLInputElement).value })}
|
||||
onKeyDown={e => e.key === 'Enter' && handleCfsSearch()}
|
||||
/>
|
||||
|
||||
<button
|
||||
class="icon-btn"
|
||||
onClick={() => removeRule(idx)}
|
||||
disabled={rules.length === 1}
|
||||
>✕</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
|
||||
<button class="panel-btn" onClick={addRule}>+ Add Rule</button>
|
||||
<button
|
||||
class="toolbar-btn toolbar-btn-primary"
|
||||
onClick={handleCfsSearch}
|
||||
disabled={searching}
|
||||
style="padding: 0 1.5rem;"
|
||||
>
|
||||
{searching ? 'Searching…' : 'Search CFS'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style="flex: 0 0 auto;">
|
||||
<div class="wizard-section-title">Archiver Search</div>
|
||||
<p class="hint" style="padding: 0 0 0.5rem 0;">Search for archived PVs using glob patterns (e.g. <code>*TEMP*</code>).</p>
|
||||
|
||||
<div class="wizard-field wizard-field-row" style="gap: 0.5rem;">
|
||||
<input
|
||||
class="prop-input"
|
||||
style="flex: 1;"
|
||||
autoFocus
|
||||
placeholder="PV pattern..."
|
||||
value={archPattern}
|
||||
onInput={e => setArchPattern((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleArchSearch()}
|
||||
/>
|
||||
<button
|
||||
class="toolbar-btn toolbar-btn-primary"
|
||||
onClick={handleArchSearch}
|
||||
disabled={searching || !archPattern.trim()}
|
||||
style="padding: 0 1.5rem;"
|
||||
>
|
||||
{searching ? 'Searching…' : 'Search Archiver'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="wizard-section-title" style="margin-top: 1.5rem; display: flex; justify-content: space-between; align-items: center;">
|
||||
<span>Results {resultsCount > 0 && `(${resultsCount})`}</span>
|
||||
{resultsCount > 0 && (
|
||||
<button class="panel-btn" style="font-size: 0.7rem; padding: 0.1rem 0.4rem;" onClick={handleAddAll}>Add All to Sidebar</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p class="wizard-error">{error}</p>}
|
||||
|
||||
<div class="panel-list" style="flex: 1; border: 1px solid #2d3748; border-radius: 4px; background: #0f1117; overflow-y: auto;">
|
||||
{resultsCount === 0 ? (
|
||||
<p class="hint" style="text-align: center; padding: 2rem;">
|
||||
{searching ? 'Searching…' : 'No results to show.'}
|
||||
</p>
|
||||
) : (
|
||||
<ul class="iface-list">
|
||||
{source === 'cfs' ? (
|
||||
cfsResults.map(r => (
|
||||
<li key={r.name} class="iface-item" style="cursor: default;">
|
||||
<div style="display: flex; flex-direction: column; gap: 2px;">
|
||||
<span style="font-family: ui-monospace, monospace; color: #e2e8f0;">{r.name}</span>
|
||||
<div style="display: flex; gap: 4px; flex-wrap: wrap;">
|
||||
{r.tags?.map((t: any) => (
|
||||
<span key={t.name} style="font-size: 0.65rem; background: #1e293b; color: #94a3b8; padding: 0 4px; border-radius: 3px; border: 1px solid #334155;">
|
||||
{t.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="icon-btn"
|
||||
title="Add to sidebar"
|
||||
onClick={() => onAddSignals([{ ds: 'epics', name: r.name }])}
|
||||
>+</button>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
archResults.map(name => (
|
||||
<li key={name} class="iface-item" style="cursor: default;">
|
||||
<span style="font-family: ui-monospace, monospace; color: #e2e8f0;">{name}</span>
|
||||
<button
|
||||
class="icon-btn"
|
||||
title="Add to sidebar"
|
||||
onClick={() => onAddSignals([{ ds: 'epics', name }])}
|
||||
>+</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wizard-footer">
|
||||
<button class="toolbar-btn" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+10
-4
@@ -10,9 +10,10 @@ interface Props {
|
||||
y: number;
|
||||
signal: SignalRef | null;
|
||||
onClose: () => void;
|
||||
onInfo?: () => void;
|
||||
}
|
||||
|
||||
export default function ContextMenu({ visible, x, y, signal, onClose }: Props) {
|
||||
export default function ContextMenu({ visible, x, y, signal, onClose, onInfo }: Props) {
|
||||
const [meta, setMeta] = useState<SignalMeta | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -36,12 +37,15 @@ export default function ContextMenu({ visible, x, y, signal, onClose }: Props) {
|
||||
if (!visible) return null;
|
||||
|
||||
function copySignalName() {
|
||||
if (signal) {
|
||||
navigator.clipboard.writeText(signal.name).catch(() => {});
|
||||
}
|
||||
if (signal) navigator.clipboard.writeText(signal.name).catch(() => {});
|
||||
onClose();
|
||||
}
|
||||
|
||||
function openInfo() {
|
||||
onClose();
|
||||
onInfo?.();
|
||||
}
|
||||
|
||||
function exportCSV() {
|
||||
// Phase 5+: export CSV data
|
||||
onClose();
|
||||
@@ -62,6 +66,8 @@ export default function ContextMenu({ visible, x, y, signal, onClose }: Props) {
|
||||
)}
|
||||
</div>
|
||||
<div class="ctx-divider" />
|
||||
<button class="ctx-item" onClick={openInfo}>Signal info</button>
|
||||
<div class="ctx-divider" />
|
||||
<button class="ctx-item" onClick={copySignalName}>Copy signal name</button>
|
||||
<button class="ctx-item" onClick={exportCSV}>Export data to CSV</button>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,8 @@ const TIPS: Record<string, Array<{ text: string; section?: string }>> = {
|
||||
{ text: 'Right-click a widget to view signal info or export data as CSV.', section: 'view' },
|
||||
{ text: 'The ● Live button shows live streaming is active. Use the date pickers to load historical data.', section: 'history' },
|
||||
{ text: 'Click Edit (top-right) to open the drag-and-drop panel builder.', section: 'edit' },
|
||||
{ text: 'Use the Share button on a panel to grant access to users or groups.', section: 'sharing' },
|
||||
{ text: 'The ⚙ Control logic button opens server-side automation that runs even with no panel open.', section: 'control' },
|
||||
{ text: 'The dot in the status chip turns green when the WebSocket is connected.', section: 'view' },
|
||||
],
|
||||
edit: [
|
||||
@@ -20,6 +22,8 @@ const TIPS: Record<string, Array<{ text: string; section?: string }>> = {
|
||||
{ text: 'Ctrl+click to select multiple widgets; then use the align toolbar.', section: 'edit' },
|
||||
{ text: 'Ctrl+Z to undo, Ctrl+Y to redo. Up to 50 steps are remembered.', section: 'shortcuts' },
|
||||
{ text: 'Use "+ Synthetic" in the signal tree to create computed/filtered signals.', section: 'signals' },
|
||||
{ text: 'Switch to the Logic tab to add interactive behaviour with a node-graph flow editor.', section: 'logic' },
|
||||
{ text: 'Add Local variables for set-points and counters that live inside the panel.', section: 'edit' },
|
||||
{ text: 'Arrow keys nudge the selected widget by 1 px (Shift for grid size).', section: 'shortcuts' },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,826 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||
import SearchableSelect from './SearchableSelect';
|
||||
import LuaEditor from './LuaEditor';
|
||||
import { checkExpr } from './lib/expr';
|
||||
|
||||
// ── Types (mirror internal/controllogic/model.go) ────────────────────────────
|
||||
|
||||
type CLNodeKind =
|
||||
| 'trigger.threshold'
|
||||
| 'trigger.change'
|
||||
| 'trigger.timer'
|
||||
| 'trigger.cron'
|
||||
| 'trigger.alarm'
|
||||
| 'gate.and'
|
||||
| 'flow.if'
|
||||
| 'flow.loop'
|
||||
| 'action.write'
|
||||
| 'action.delay'
|
||||
| 'action.log'
|
||||
| 'action.lua';
|
||||
|
||||
interface CLNode {
|
||||
id: string;
|
||||
kind: CLNodeKind;
|
||||
x: number;
|
||||
y: number;
|
||||
params: Record<string, string>;
|
||||
}
|
||||
interface CLWire { from: string; fromPort?: string; to: string; }
|
||||
interface CLGraph {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
nodes: CLNode[];
|
||||
wires: CLWire[];
|
||||
}
|
||||
|
||||
interface DataSource { name: string; }
|
||||
interface SignalInfo { name: string; }
|
||||
|
||||
function splitRef(ref: string): { ds: string; name: string } {
|
||||
const i = ref.indexOf(':');
|
||||
if (i < 0) return { ds: '', name: '' };
|
||||
return { ds: ref.slice(0, i), name: ref.slice(i + 1) };
|
||||
}
|
||||
|
||||
// ── Geometry (shared with LogicEditor look/feel) ─────────────────────────────
|
||||
|
||||
const REM = (() => {
|
||||
if (typeof document === 'undefined') return 16;
|
||||
return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
||||
})();
|
||||
const NODE_W = 11.5 * REM;
|
||||
const PORT_TOP = 2 * REM;
|
||||
const PORT_GAP = 1.375 * REM;
|
||||
const PORT_R = 0.375 * REM;
|
||||
|
||||
interface PaletteEntry { kind: CLNodeKind; label: string; params: Record<string, string>; }
|
||||
|
||||
const PALETTE: PaletteEntry[] = [
|
||||
{ kind: 'trigger.threshold', label: 'Threshold', params: { signal: '', op: '>', value: '0' } },
|
||||
{ kind: 'trigger.change', label: 'On change', params: { signal: '' } },
|
||||
{ kind: 'trigger.timer', label: 'Timer', params: { interval: '1000' } },
|
||||
{ kind: 'trigger.cron', label: 'Cron', params: { spec: '*/5 * * * *' } },
|
||||
{ kind: 'trigger.alarm', label: 'Alarm', params: { signal: '', min: '0', max: '100' } },
|
||||
{ kind: 'gate.and', label: 'AND gate', params: {} },
|
||||
{ kind: 'flow.if', label: 'If / else', params: { cond: '' } },
|
||||
{ kind: 'flow.loop', label: 'Loop', params: { mode: 'count', count: '3', cond: '' } },
|
||||
{ kind: 'action.write', label: 'Write', params: { target: '', expr: '' } },
|
||||
{ kind: 'action.delay', label: 'Delay', params: { ms: '500' } },
|
||||
{ kind: 'action.log', label: 'Log', params: { expr: '', label: '' } },
|
||||
{ kind: 'action.lua', label: 'Lua script', params: { script: '-- get("ds:name") reads, set("ds:name", v) writes,\n-- log("msg") logs to the server.\n' } },
|
||||
];
|
||||
|
||||
const KIND_LABEL: Record<CLNodeKind, string> = {
|
||||
'trigger.threshold': 'Threshold',
|
||||
'trigger.change': 'On change',
|
||||
'trigger.timer': 'Timer',
|
||||
'trigger.cron': 'Cron',
|
||||
'trigger.alarm': 'Alarm',
|
||||
'gate.and': 'AND gate',
|
||||
'flow.if': 'If / else',
|
||||
'flow.loop': 'Loop',
|
||||
'action.write': 'Write',
|
||||
'action.delay': 'Delay',
|
||||
'action.log': 'Log',
|
||||
'action.lua': 'Lua script',
|
||||
};
|
||||
|
||||
const THRESHOLD_OPS = ['>', '<', '>=', '<=', '==', '!='] as const;
|
||||
|
||||
const CRON_PRESETS: { label: string; spec: string }[] = [
|
||||
{ label: 'Every minute', spec: '* * * * *' },
|
||||
{ label: 'Every 5 minutes', spec: '*/5 * * * *' },
|
||||
{ label: 'Every hour', spec: '0 * * * *' },
|
||||
{ label: 'Daily at 08:00', spec: '0 8 * * *' },
|
||||
{ label: 'Weekdays at 09:00', spec: '0 9 * * 1-5' },
|
||||
];
|
||||
|
||||
function isTrigger(kind: CLNodeKind): boolean { return kind.startsWith('trigger.'); }
|
||||
function category(kind: CLNodeKind): 'trigger' | 'gate' | 'flow' | 'action' {
|
||||
if (kind.startsWith('trigger.')) return 'trigger';
|
||||
if (kind.startsWith('gate.')) return 'gate';
|
||||
if (kind.startsWith('flow.')) return 'flow';
|
||||
return 'action';
|
||||
}
|
||||
function hasInput(kind: CLNodeKind): boolean { return !isTrigger(kind); }
|
||||
|
||||
interface Port { id: string; label: string; }
|
||||
function outputs(kind: CLNodeKind): Port[] {
|
||||
if (kind === 'flow.if') return [{ id: 'then', label: 'then' }, { id: 'else', label: 'else' }];
|
||||
if (kind === 'flow.loop') return [{ id: 'body', label: 'body' }, { id: 'done', label: 'done' }];
|
||||
return [{ id: 'out', label: '' }];
|
||||
}
|
||||
function nodeHeight(kind: CLNodeKind): number { return PORT_TOP + outputs(kind).length * PORT_GAP; }
|
||||
|
||||
function genId(): string { return 'n_' + Math.random().toString(36).slice(2, 9); }
|
||||
|
||||
function inAnchor(n: CLNode) { return { x: n.x, y: n.y + PORT_TOP }; }
|
||||
function outAnchor(n: CLNode, port: string) {
|
||||
const ports = outputs(n.kind);
|
||||
const i = Math.max(0, ports.findIndex(p => p.id === port));
|
||||
return { x: n.x + NODE_W, y: n.y + PORT_TOP + i * PORT_GAP };
|
||||
}
|
||||
function wirePathStr(x1: number, y1: number, x2: number, y2: number): string {
|
||||
const dx = Math.max(40, Math.abs(x2 - x1) / 2);
|
||||
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
|
||||
}
|
||||
|
||||
// ── Manager modal ────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props { onClose: () => void; }
|
||||
|
||||
export default function ControlLogicEditor({ onClose }: Props) {
|
||||
const [graphs, setGraphs] = useState<CLGraph[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [graph, setGraph] = useState<CLGraph | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function reload() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/controllogic');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
setGraphs(await res.json());
|
||||
} catch (err) {
|
||||
setError(`Failed to load control logic: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
useEffect(() => { reload(); }, []);
|
||||
|
||||
function selectGraph(g: CLGraph) {
|
||||
if (dirty && !confirm('Discard unsaved changes to the current graph?')) return;
|
||||
setSelectedId(g.id);
|
||||
setGraph(JSON.parse(JSON.stringify(g)));
|
||||
setDirty(false);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function createGraph() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const body: Omit<CLGraph, 'id'> = { name: 'New control logic', enabled: false, nodes: [], wires: [] };
|
||||
const res = await fetch('/api/v1/controllogic', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const created: CLGraph = await res.json();
|
||||
await reload();
|
||||
setSelectedId(created.id);
|
||||
setGraph(created);
|
||||
setDirty(false);
|
||||
} catch (err) {
|
||||
setError(`Create failed: ${err instanceof Error ? err.message : err}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGraph() {
|
||||
if (!graph) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/controllogic/${encodeURIComponent(graph.id)}`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(graph),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
setDirty(false);
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setError(`Save failed: ${err instanceof Error ? err.message : err}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteGraph(id: string) {
|
||||
if (!confirm('Delete this control logic graph? This stops it on the server.')) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/controllogic/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
if (!res.ok && res.status !== 204) throw new Error(`HTTP ${res.status}`);
|
||||
if (selectedId === id) { setSelectedId(null); setGraph(null); setDirty(false); }
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setError(`Delete failed: ${err instanceof Error ? err.message : err}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle a graph's enabled flag straight from the list (persists immediately).
|
||||
async function toggleEnabled(g: CLGraph) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = { ...g, enabled: !g.enabled };
|
||||
const res = await fetch(`/api/v1/controllogic/${encodeURIComponent(g.id)}`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updated),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
if (selectedId === g.id && graph) setGraph({ ...graph, enabled: updated.enabled });
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setError(`Update failed: ${err instanceof Error ? err.message : err}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function patchGraph(patch: Partial<CLGraph>) {
|
||||
if (!graph) return;
|
||||
setGraph({ ...graph, ...patch });
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div class="cl-modal">
|
||||
<header class="cl-header">
|
||||
<span class="cl-title">Control logic</span>
|
||||
<span class="hint cl-subtitle">Server-side flows — run continuously, independent of any panel.</span>
|
||||
<div class="cl-header-actions">
|
||||
{graph && dirty && <span class="cl-dirty">unsaved</span>}
|
||||
{graph && <button class="panel-btn" disabled={busy || !dirty} onClick={saveGraph}>Save</button>}
|
||||
<button class="panel-btn" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <div class="cl-error">{error}</div>}
|
||||
|
||||
<div class="cl-body">
|
||||
<div class="cl-list">
|
||||
<div class="cl-list-head">
|
||||
<span>Graphs</span>
|
||||
<button class="panel-btn" disabled={busy} onClick={createGraph}>+ New</button>
|
||||
</div>
|
||||
{graphs.length === 0 && <div class="hint cl-list-empty">No control logic yet.</div>}
|
||||
{graphs.map(g => (
|
||||
<div key={g.id}
|
||||
class={`cl-list-item${selectedId === g.id ? ' cl-list-item-active' : ''}`}
|
||||
onClick={() => selectGraph(g)}>
|
||||
<span class={`cl-status-dot${g.enabled ? ' cl-status-on' : ''}`}
|
||||
title={g.enabled ? 'Enabled' : 'Disabled'} />
|
||||
<span class="cl-list-name" title={g.name}>{g.name || '(unnamed)'}</span>
|
||||
<button class="cl-mini-btn" title={g.enabled ? 'Disable' : 'Enable'}
|
||||
onClick={(e) => { e.stopPropagation(); toggleEnabled(g); }}>
|
||||
{g.enabled ? '⏸' : '▶'}
|
||||
</button>
|
||||
<button class="cl-mini-btn" title="Delete"
|
||||
onClick={(e) => { e.stopPropagation(); deleteGraph(g.id); }}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="cl-editor-wrap">
|
||||
{!graph && <div class="cl-empty hint">Select a graph on the left, or create a new one.</div>}
|
||||
{graph && (
|
||||
<Fragment>
|
||||
<div class="cl-graph-bar">
|
||||
<input class="prop-input cl-name-input" value={graph.name}
|
||||
placeholder="Graph name"
|
||||
onInput={(e) => patchGraph({ name: (e.target as HTMLInputElement).value })} />
|
||||
<label class="cl-enable">
|
||||
<input type="checkbox" checked={graph.enabled}
|
||||
onChange={(e) => patchGraph({ enabled: (e.target as HTMLInputElement).checked })} />
|
||||
Enabled
|
||||
</label>
|
||||
<span class="hint">Saving a disabled graph stops it; enabling + saving starts it.</span>
|
||||
</div>
|
||||
<FlowEditor graph={graph}
|
||||
onChange={(g) => { setGraph({ ...graph, nodes: g.nodes, wires: g.wires }); setDirty(true); }} />
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Node-graph editor ─────────────────────────────────────────────────────────
|
||||
|
||||
function FlowEditor({ graph, onChange }: {
|
||||
graph: CLGraph;
|
||||
onChange: (g: { nodes: CLNode[]; wires: CLWire[] }) => void;
|
||||
}) {
|
||||
const nodes = graph.nodes;
|
||||
const wires = graph.wires;
|
||||
|
||||
const [selectedNode, setSelectedNode] = useState<string | null>(null);
|
||||
const [selectedWire, setSelectedWire] = useState<number | null>(null);
|
||||
const [dataSources, setDataSources] = useState<string[]>([]);
|
||||
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
|
||||
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const dragNode = useRef<{ id: string; dx: number; dy: number } | null>(null);
|
||||
const [pendingWire, setPendingWire] = useState<{ from: string; port: string; x: number; y: number } | null>(null);
|
||||
const pendingRef = useRef(pendingWire);
|
||||
pendingRef.current = pendingWire;
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/datasources')
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then((ds: DataSource[]) => setDataSources(ds.map(d => d.name)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function loadSignals(ds: string) {
|
||||
if (!ds || ds === 'local' || ds === 'sys' || dsSignals[ds]) return;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/signals?ds=${encodeURIComponent(ds)}`);
|
||||
if (!res.ok) return;
|
||||
const sigs: SignalInfo[] = await res.json();
|
||||
setDsSignals(prev => ({ ...prev, [ds]: sigs.map(s => s.name) }));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Bare-name write targets are graph-local variables; offer them under 'local'.
|
||||
const localNames = Array.from(new Set(
|
||||
nodes.filter(n => n.kind === 'action.write')
|
||||
.map(n => n.params.target ?? '')
|
||||
.filter(t => t && !t.includes(':'))
|
||||
));
|
||||
const dsOptions = ['local', 'sys', ...dataSources];
|
||||
function signalOptions(ds: string): string[] {
|
||||
if (ds === 'local') return localNames;
|
||||
if (ds === 'sys') return ['time', 'dt'];
|
||||
return dsSignals[ds] ?? [];
|
||||
}
|
||||
|
||||
const selected = nodes.find(n => n.id === selectedNode) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (selected?.kind === 'trigger.threshold' || selected?.kind === 'trigger.change' || selected?.kind === 'trigger.alarm') {
|
||||
loadSignals(splitRef(selected.params.signal ?? '').ds);
|
||||
}
|
||||
if (selected?.kind === 'action.write') loadSignals(splitRef(selected.params.target ?? '').ds);
|
||||
}, [selectedNode, selected?.kind]);
|
||||
|
||||
function emit(next: { nodes: CLNode[]; wires: CLWire[] }) { onChange(next); }
|
||||
function setNodes(next: CLNode[]) { emit({ nodes: next, wires }); }
|
||||
function setWires(next: CLWire[]) { emit({ nodes, wires: next }); }
|
||||
|
||||
function addNode(entry: PaletteEntry, x?: number, y?: number) {
|
||||
const node: CLNode = {
|
||||
id: genId(),
|
||||
kind: entry.kind,
|
||||
x: x ?? 40 + (nodes.length % 5) * 30,
|
||||
y: y ?? 40 + (nodes.length % 5) * 30,
|
||||
params: { ...entry.params },
|
||||
};
|
||||
emit({ nodes: [...nodes, node], wires });
|
||||
setSelectedNode(node.id);
|
||||
setSelectedWire(null);
|
||||
}
|
||||
function patchParams(id: string, patch: Record<string, string>) {
|
||||
setNodes(nodes.map(n => (n.id === id ? { ...n, params: { ...n.params, ...patch } } : n)));
|
||||
}
|
||||
function moveNode(id: string, x: number, y: number) {
|
||||
setNodes(nodes.map(n => (n.id === id ? { ...n, x, y } : n)));
|
||||
}
|
||||
function deleteNode(id: string) {
|
||||
emit({ nodes: nodes.filter(n => n.id !== id), wires: wires.filter(w => w.from !== id && w.to !== id) });
|
||||
if (selectedNode === id) setSelectedNode(null);
|
||||
}
|
||||
function addWire(from: string, port: string, to: string) {
|
||||
if (from === to) return;
|
||||
if (wires.some(w => w.from === from && (w.fromPort ?? 'out') === port && w.to === to)) return;
|
||||
setWires([...wires, { from, to, ...(port !== 'out' ? { fromPort: port } : {}) }]);
|
||||
}
|
||||
function deleteWire(idx: number) {
|
||||
setWires(wires.filter((_, i) => i !== idx));
|
||||
if (selectedWire === idx) setSelectedWire(null);
|
||||
}
|
||||
|
||||
function toCanvas(e: MouseEvent): { x: number; y: number } {
|
||||
const el = canvasRef.current!;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return { x: e.clientX - rect.left + el.scrollLeft, y: e.clientY - rect.top + el.scrollTop };
|
||||
}
|
||||
|
||||
function startNodeDrag(e: MouseEvent, node: CLNode) {
|
||||
e.stopPropagation();
|
||||
const p = toCanvas(e);
|
||||
dragNode.current = { id: node.id, dx: p.x - node.x, dy: p.y - node.y };
|
||||
setSelectedNode(node.id);
|
||||
setSelectedWire(null);
|
||||
window.addEventListener('mousemove', onNodeDragMove);
|
||||
window.addEventListener('mouseup', onNodeDragUp);
|
||||
}
|
||||
function onNodeDragMove(e: MouseEvent) {
|
||||
const d = dragNode.current;
|
||||
if (!d) return;
|
||||
const p = toCanvas(e);
|
||||
moveNode(d.id, Math.max(0, p.x - d.dx), Math.max(0, p.y - d.dy));
|
||||
}
|
||||
function onNodeDragUp() {
|
||||
dragNode.current = null;
|
||||
window.removeEventListener('mousemove', onNodeDragMove);
|
||||
window.removeEventListener('mouseup', onNodeDragUp);
|
||||
}
|
||||
|
||||
function startWire(e: MouseEvent, node: CLNode, port: string) {
|
||||
e.stopPropagation();
|
||||
const p = toCanvas(e);
|
||||
setPendingWire({ from: node.id, port, x: p.x, y: p.y });
|
||||
window.addEventListener('mousemove', onWireMove);
|
||||
window.addEventListener('mouseup', onWireUp);
|
||||
}
|
||||
function onWireMove(e: MouseEvent) {
|
||||
const cur = pendingRef.current;
|
||||
if (!cur) return;
|
||||
const p = toCanvas(e);
|
||||
setPendingWire({ ...cur, x: p.x, y: p.y });
|
||||
}
|
||||
function endWire() {
|
||||
pendingRef.current = null;
|
||||
window.removeEventListener('mousemove', onWireMove);
|
||||
window.removeEventListener('mouseup', onWireUp);
|
||||
setPendingWire(null);
|
||||
}
|
||||
function onWireUp() { endWire(); }
|
||||
function finishWire(target: CLNode) {
|
||||
const cur = pendingRef.current;
|
||||
if (cur && hasInput(target.kind)) addWire(cur.from, cur.port, target.id);
|
||||
endWire();
|
||||
}
|
||||
|
||||
const DRAG_MIME = 'application/x-uopi-cl-node';
|
||||
function onCanvasDragOver(e: DragEvent) {
|
||||
if (Array.from(e.dataTransfer?.types ?? []).includes(DRAG_MIME)) {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
}
|
||||
function onCanvasDrop(e: DragEvent) {
|
||||
const kind = e.dataTransfer?.getData(DRAG_MIME);
|
||||
if (!kind) return;
|
||||
e.preventDefault();
|
||||
const entry = PALETTE.find(p => p.kind === kind);
|
||||
if (!entry) return;
|
||||
const p = toCanvas(e);
|
||||
addNode(entry, Math.max(0, p.x - NODE_W / 2), Math.max(0, p.y - PORT_TOP / 2));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
const t = e.target as Element;
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return;
|
||||
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
|
||||
if (selectedWire !== null) deleteWire(selectedWire);
|
||||
else if (selectedNode) deleteNode(selectedNode);
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [selectedNode, selectedWire, nodes, wires]);
|
||||
|
||||
const byId = new Map(nodes.map(n => [n.id, n]));
|
||||
|
||||
function insertRef(id: string, field: string, ds: string, sig: string) {
|
||||
const cur = (byId.get(id)?.params[field]) ?? '';
|
||||
patchParams(id, { [field]: `${cur}{${ds}:${sig}}` });
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flow-editor">
|
||||
<div class="flow-palette">
|
||||
<div class="flow-palette-title">Triggers</div>
|
||||
{PALETTE.filter(e => category(e.kind) === 'trigger').map(paletteBtn)}
|
||||
<div class="flow-palette-title">Logic</div>
|
||||
{PALETTE.filter(e => category(e.kind) === 'gate' || category(e.kind) === 'flow').map(paletteBtn)}
|
||||
<div class="flow-palette-title">Actions</div>
|
||||
{PALETTE.filter(e => category(e.kind) === 'action').map(paletteBtn)}
|
||||
<div class="flow-palette-hint hint">
|
||||
Triggers start a flow. Drag a node's right port to another node's left port to connect.
|
||||
In expressions, reference signals as <code>{'{ds:name}'}</code> and graph-local vars by name.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow-canvas" ref={canvasRef}
|
||||
onMouseDown={() => { setSelectedNode(null); setSelectedWire(null); }}
|
||||
onDragOver={onCanvasDragOver}
|
||||
onDrop={onCanvasDrop}>
|
||||
<div class="flow-canvas-inner">
|
||||
<svg class="flow-wires">
|
||||
{wires.map((w, idx) => {
|
||||
const a = byId.get(w.from);
|
||||
const b = byId.get(w.to);
|
||||
if (!a || !b) return null;
|
||||
const p1 = outAnchor(a, w.fromPort ?? 'out');
|
||||
const p2 = inAnchor(b);
|
||||
return (
|
||||
<path key={idx}
|
||||
class={`flow-wire${selectedWire === idx ? ' flow-wire-selected' : ''}`}
|
||||
d={wirePathStr(p1.x, p1.y, p2.x, p2.y)}
|
||||
onClick={(e) => { e.stopPropagation(); setSelectedWire(idx); setSelectedNode(null); }} />
|
||||
);
|
||||
})}
|
||||
{pendingWire && (() => {
|
||||
const a = byId.get(pendingWire.from);
|
||||
if (!a) return null;
|
||||
const p1 = outAnchor(a, pendingWire.port);
|
||||
return <path class="flow-wire flow-wire-pending" d={wirePathStr(p1.x, p1.y, pendingWire.x, pendingWire.y)} />;
|
||||
})()}
|
||||
</svg>
|
||||
|
||||
{nodes.map(node => (
|
||||
<div key={node.id}
|
||||
class={`flow-node flow-node-${category(node.kind)}${selectedNode === node.id ? ' flow-node-selected' : ''}`}
|
||||
style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px; min-height:${nodeHeight(node.kind)}px;`}
|
||||
onMouseDown={(e) => startNodeDrag(e, node)}
|
||||
onMouseUp={() => { if (pendingRef.current) finishWire(node); }}>
|
||||
<div class="flow-node-header">
|
||||
<span class="flow-node-title">{KIND_LABEL[node.kind]}</span>
|
||||
<button class="flow-node-del" title="Delete node"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); deleteNode(node.id); }}>✕</button>
|
||||
</div>
|
||||
<div class="flow-node-body hint">{nodeSummary(node)}</div>
|
||||
|
||||
{hasInput(node.kind) && (
|
||||
<div class="flow-port flow-port-in" title="Input"
|
||||
style={`top:${PORT_TOP - PORT_R}px; left:${-PORT_R}px;`}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onMouseUp={(e) => { e.stopPropagation(); finishWire(node); }} />
|
||||
)}
|
||||
{outputs(node.kind).map((port, i) => (
|
||||
<Fragment key={port.id}>
|
||||
{port.label && (
|
||||
<span class="flow-port-label" style={`top:${PORT_TOP + i * PORT_GAP - 0.5 * REM}px;`}>{port.label}</span>
|
||||
)}
|
||||
<div class={`flow-port flow-port-out flow-port-${port.id}`}
|
||||
style={`top:${PORT_TOP + i * PORT_GAP - PORT_R}px; right:${-PORT_R}px;`}
|
||||
title={`Output: ${port.id}`}
|
||||
onMouseDown={(e) => startWire(e, node, port.id)} />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow-inspector">
|
||||
{!selected && <div class="hint">Select a node to edit it, or add one from the palette.</div>}
|
||||
{selected && (
|
||||
<Fragment>
|
||||
<div class="wizard-section-title">{KIND_LABEL[selected.kind]}</div>
|
||||
|
||||
{(selected.kind === 'trigger.threshold' || selected.kind === 'trigger.change' || selected.kind === 'trigger.alarm') && (() => {
|
||||
const { ds, name } = splitRef(selected.params.signal ?? '');
|
||||
return (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Data source</label>
|
||||
<SearchableSelect value={ds} options={dataSources}
|
||||
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { signal: `${newDs}:` }); }} />
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Signal</label>
|
||||
<SearchableSelect value={name} options={signalOptions(ds)}
|
||||
onSelect={(sig) => patchParams(selected.id, { signal: `${ds}:${sig}` })}
|
||||
placeholder={ds ? 'Search signals…' : 'Select data source first'} />
|
||||
</div>
|
||||
{selected.kind === 'trigger.threshold' && (
|
||||
<div class="wizard-field wizard-field-row">
|
||||
<div class="wizard-field" style="flex:0 0 4.5rem;">
|
||||
<label>Op</label>
|
||||
<select class="prop-select" value={selected.params.op ?? '>'}
|
||||
onChange={(e) => patchParams(selected.id, { op: (e.target as HTMLSelectElement).value })}>
|
||||
{THRESHOLD_OPS.map(o => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="wizard-field" style="flex:1;">
|
||||
<label>Value</label>
|
||||
<input class="prop-input" value={selected.params.value ?? ''}
|
||||
onInput={(e) => patchParams(selected.id, { value: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selected.kind === 'trigger.alarm' && (
|
||||
<div class="wizard-field wizard-field-row">
|
||||
<div class="wizard-field" style="flex:1;">
|
||||
<label>Min</label>
|
||||
<input class="prop-input" type="number" value={selected.params.min ?? '0'}
|
||||
onInput={(e) => patchParams(selected.id, { min: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
<div class="wizard-field" style="flex:1;">
|
||||
<label>Max</label>
|
||||
<input class="prop-input" type="number" value={selected.params.max ?? '100'}
|
||||
onInput={(e) => patchParams(selected.id, { max: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selected.kind === 'trigger.change' && <p class="hint">Fires whenever the signal's value changes.</p>}
|
||||
{selected.kind === 'trigger.alarm' && <p class="hint">Fires when the signal leaves the [min, max] range (rising edge).</p>}
|
||||
</Fragment>
|
||||
);
|
||||
})()}
|
||||
|
||||
{selected.kind === 'trigger.timer' && (
|
||||
<div class="wizard-field">
|
||||
<label>Interval (ms)</label>
|
||||
<input class="prop-input" type="number" value={selected.params.interval ?? '1000'}
|
||||
onInput={(e) => patchParams(selected.id, { interval: (e.target as HTMLInputElement).value })} />
|
||||
<p class="hint">Fires repeatedly on the server while the graph is enabled.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.kind === 'trigger.cron' && (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Schedule (cron)</label>
|
||||
<input class="prop-input" value={selected.params.spec ?? ''}
|
||||
placeholder="min hour dom month dow"
|
||||
onInput={(e) => patchParams(selected.id, { spec: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Presets</label>
|
||||
<select class="prop-select" value=""
|
||||
onChange={(e) => { const v = (e.target as HTMLSelectElement).value; if (v) patchParams(selected.id, { spec: v }); }}>
|
||||
<option value="">choose…</option>
|
||||
{CRON_PRESETS.map(p => <option key={p.spec} value={p.spec}>{p.label} ({p.spec})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<p class="hint">5 fields: minute hour day-of-month month day-of-week. Supports <code>*</code>, <code>*/n</code>, ranges <code>a-b</code> and lists <code>a,b</code>.</p>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{selected.kind === 'gate.and' && (
|
||||
<p class="hint">Wire two or more triggers into this gate. The flow continues only when all
|
||||
inputs are satisfied (level triggers like Threshold/Alarm use their current truth).</p>
|
||||
)}
|
||||
|
||||
{selected.kind === 'flow.if' && (
|
||||
<ExprField label="Condition" value={selected.params.cond ?? ''}
|
||||
onChange={(v) => patchParams(selected.id, { cond: v })}
|
||||
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
|
||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
|
||||
hint="True → 'then' port, false → 'else'. e.g. {stub:level} > 5 && temp < 100. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
|
||||
)}
|
||||
|
||||
{selected.kind === 'flow.loop' && (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Mode</label>
|
||||
<select class="prop-select" value={selected.params.mode ?? 'count'}
|
||||
onChange={(e) => patchParams(selected.id, { mode: (e.target as HTMLSelectElement).value })}>
|
||||
<option value="count">Repeat N times</option>
|
||||
<option value="while">While condition</option>
|
||||
</select>
|
||||
</div>
|
||||
{(selected.params.mode ?? 'count') === 'count' ? (
|
||||
<div class="wizard-field">
|
||||
<label>Repeat count</label>
|
||||
<input class="prop-input" type="number" value={selected.params.count ?? '3'}
|
||||
onInput={(e) => patchParams(selected.id, { count: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
) : (
|
||||
<ExprField label="While condition" value={selected.params.cond ?? ''}
|
||||
onChange={(v) => patchParams(selected.id, { cond: v })}
|
||||
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
|
||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
|
||||
hint="Loops the 'body' port while true (capped). e.g. {stub:level} < 90" />
|
||||
)}
|
||||
<p class="hint">Runs the <b>body</b> port repeatedly, then continues on <b>done</b>.</p>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.write' && (() => {
|
||||
const { ds, name } = splitRef(selected.params.target ?? '');
|
||||
return (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Target data source</label>
|
||||
<SearchableSelect value={ds} options={dsOptions}
|
||||
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { target: `${newDs}:` }); }} />
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Target signal</label>
|
||||
<SearchableSelect value={name} options={signalOptions(ds)}
|
||||
onSelect={(sig) => patchParams(selected.id, { target: `${ds}:${sig}` })}
|
||||
placeholder={ds ? 'Search signals…' : 'Select data source first'} />
|
||||
</div>
|
||||
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
|
||||
onChange={(v) => patchParams(selected.id, { expr: v })}
|
||||
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
|
||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
|
||||
hint="Write to a data source signal, or a bare name to store a graph-local var. e.g. ({stub:a} - {stub:b}) / {sys:dt}." />
|
||||
</Fragment>
|
||||
);
|
||||
})()}
|
||||
|
||||
{selected.kind === 'action.delay' && (
|
||||
<div class="wizard-field">
|
||||
<label>Delay (ms)</label>
|
||||
<input class="prop-input" type="number" value={selected.params.ms ?? '0'}
|
||||
onInput={(e) => patchParams(selected.id, { ms: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.log' && (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Label (optional)</label>
|
||||
<input class="prop-input" value={selected.params.label ?? ''}
|
||||
placeholder="prefix for the log line"
|
||||
onInput={(e) => patchParams(selected.id, { label: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
|
||||
onChange={(v) => patchParams(selected.id, { expr: v })}
|
||||
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
|
||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
|
||||
hint="Logs this value to the server log — handy for debugging a flow." />
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.lua' && (
|
||||
<div class="wizard-field">
|
||||
<label>Lua script</label>
|
||||
<LuaEditor value={selected.params.script ?? ''}
|
||||
onChange={(v) => patchParams(selected.id, { script: v })} rows={14} />
|
||||
<p class="hint">
|
||||
Host functions: <code>get("ds:name")</code> reads a signal/sys/local value,
|
||||
<code>set("ds:name", v)</code> writes (bare name → graph-local var),
|
||||
<code>log(msg)</code> logs to the server. The <code>os/io</code> libraries are disabled.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(selected.id)}>Delete node</button>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
function paletteBtn(entry: PaletteEntry) {
|
||||
return (
|
||||
<button key={entry.kind} class={`flow-palette-btn flow-palette-${category(entry.kind)}`}
|
||||
title={`${entry.kind} — drag onto the canvas or click to add`}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer?.setData(DRAG_MIME, entry.kind);
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy';
|
||||
}}
|
||||
onClick={() => addNode(entry)}>{entry.label}</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function ExprField({ label, value, onChange, onInsert, dsOptions, signalOptions, loadSignals, hint }: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onInsert: (ds: string, sig: string) => void;
|
||||
dsOptions: string[];
|
||||
signalOptions: (ds: string) => string[];
|
||||
loadSignals: (ds: string) => void;
|
||||
hint: string;
|
||||
}) {
|
||||
const [insDs, setInsDs] = useState('');
|
||||
const err = checkExpr(value);
|
||||
return (
|
||||
<div class="wizard-field">
|
||||
<label>{label}</label>
|
||||
<input class={`prop-input${err ? ' prop-input-error' : ''}`} value={value}
|
||||
placeholder="expression"
|
||||
onInput={(e) => onChange((e.target as HTMLInputElement).value)} />
|
||||
{err && <p class="wizard-error">{err}</p>}
|
||||
<div class="flow-expr-insert">
|
||||
<SearchableSelect value={insDs} options={dsOptions}
|
||||
onSelect={(ds) => { setInsDs(ds); loadSignals(ds); }} placeholder="ds…" />
|
||||
<SearchableSelect value="" options={signalOptions(insDs)}
|
||||
onSelect={(sig) => onInsert(insDs, sig)}
|
||||
placeholder={insDs ? '+ insert signal' : 'pick ds first'} />
|
||||
</div>
|
||||
<p class="hint">{hint}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function nodeSummary(n: CLNode): string {
|
||||
switch (n.kind) {
|
||||
case 'trigger.threshold': return `${n.params.signal || '?'} ${n.params.op || '>'} ${n.params.value || '0'}`;
|
||||
case 'trigger.change': return `Δ ${n.params.signal || '?'}`;
|
||||
case 'trigger.timer': return `every ${n.params.interval || '?'} ms`;
|
||||
case 'trigger.cron': return n.params.spec || '(no schedule)';
|
||||
case 'trigger.alarm': return `${n.params.signal || '?'} ∉ [${n.params.min || '?'}, ${n.params.max || '?'}]`;
|
||||
case 'gate.and': return 'all inputs';
|
||||
case 'flow.if': return n.params.cond || '(no condition)';
|
||||
case 'flow.loop': return (n.params.mode ?? 'count') === 'count'
|
||||
? `repeat ${n.params.count || '0'}×`
|
||||
: `while ${n.params.cond || '?'}`;
|
||||
case 'action.write': return `${n.params.target || '?'} = ${n.params.expr || ''}`;
|
||||
case 'action.delay': return `wait ${n.params.ms || '0'} ms`;
|
||||
case 'action.log': return `log ${n.params.label ? n.params.label + ': ' : ''}${n.params.expr || ''}`;
|
||||
case 'action.lua': return 'Lua script';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
@@ -364,7 +364,7 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
||||
>
|
||||
<div class="canvas-area" style={`width:${iface.w}px;height:${iface.h}px;`}>
|
||||
|
||||
{iface.widgets.map(widget => {
|
||||
{(iface.widgets || []).map(widget => {
|
||||
const Comp = COMPONENTS[widget.type];
|
||||
return Comp
|
||||
? <Comp key={widget.id} widget={widget} />
|
||||
@@ -376,8 +376,9 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
||||
);
|
||||
})}
|
||||
|
||||
{iface.widgets.map(widget => {
|
||||
{(iface.widgets || []).map(widget => {
|
||||
const isSelected = selectedIds.includes(widget.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`ov-${widget.id}`}
|
||||
|
||||
+508
-104
@@ -1,16 +1,29 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useCallback, useRef } from 'preact/hooks';
|
||||
import type { Interface, Widget } from './lib/types';
|
||||
import { h, Fragment } from 'preact';
|
||||
import { useState, useCallback, useRef, useEffect } from 'preact/hooks';
|
||||
import type { Interface, Widget, PlotLayout, StateVar, LogicGraph } from './lib/types';
|
||||
import { serializeInterface, parseInterface } from './lib/xml';
|
||||
import { initLocalState } from './lib/localstate';
|
||||
import SignalTree from './SignalTree';
|
||||
import EditCanvas, { genWidgetId, DEFAULT_SIZES } from './EditCanvas';
|
||||
import PlotPanelCanvas from './PlotPanelCanvas';
|
||||
import LogicEditor from './LogicEditor';
|
||||
import PropertiesPane from './PropertiesPane';
|
||||
import ContextualHelp from './ContextualHelp';
|
||||
import HelpModal from './HelpModal';
|
||||
import ZoomControl from './ZoomControl';
|
||||
import { useAuth, canWrite } from './lib/auth';
|
||||
|
||||
interface Props {
|
||||
initial: Interface | null;
|
||||
onDone: () => void;
|
||||
onDone: (iface: Interface | null) => void;
|
||||
}
|
||||
|
||||
interface VersionMeta {
|
||||
version: number;
|
||||
name: string;
|
||||
tag?: string;
|
||||
current: boolean;
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
function blankInterface(): Interface {
|
||||
@@ -68,12 +81,15 @@ function distributeWidgets(widgets: Widget[], ids: string[], axis: 'h' | 'v'): W
|
||||
|
||||
const STATIC_WIDGET_TYPES = [
|
||||
{ type: 'textlabel', label: 'Text Label' },
|
||||
{ type: 'button', label: 'Action Button' },
|
||||
{ type: 'image', label: 'Image' },
|
||||
{ type: 'link', label: 'Link' },
|
||||
];
|
||||
|
||||
export default function EditMode({ initial, onDone }: Props) {
|
||||
const [iface, setIface] = useState<Interface>(initial ?? blankInterface());
|
||||
const me = useAuth();
|
||||
const writable = canWrite(me.level);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -83,73 +99,223 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
const [showInsertMenu, setShowInsertMenu] = useState(false);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [helpSection, setHelpSection] = useState('edit');
|
||||
function openHelp(section = 'edit') { setHelpSection(section); setShowHelp(true); }
|
||||
const [leftW, setLeftW] = useState(220);
|
||||
const [rightW, setRightW] = useState(260);
|
||||
// Center column tab: the drag-and-drop layout editor, or the panel-logic editor.
|
||||
const [centerTab, setCenterTab] = useState<'layout' | 'logic'>('layout');
|
||||
|
||||
// Undo / redo
|
||||
const undoStack = useRef<Widget[][]>([]);
|
||||
const redoStack = useRef<Widget[][]>([]);
|
||||
// History panel
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [versions, setVersions] = useState<VersionMeta[]>([]);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [tag, setTag] = useState('');
|
||||
// Bumped whenever the editor content is replaced wholesale (version load /
|
||||
// promote) to force a full canvas remount, so no stale widget state lingers.
|
||||
const [canvasNonce, setCanvasNonce] = useState(0);
|
||||
|
||||
// Clipboard
|
||||
const clipboard = useRef<Widget[]>([]);
|
||||
|
||||
const startResize = useCallback((side: 'left' | 'right') => {
|
||||
return (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startW = side === 'left' ? leftW : rightW;
|
||||
function onMove(mv: MouseEvent) {
|
||||
const dx = mv.clientX - startX;
|
||||
if (side === 'left') setLeftW(Math.max(140, startW + dx));
|
||||
else setRightW(Math.max(180, startW - dx));
|
||||
}
|
||||
function onUp() {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
}
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
};
|
||||
}, [leftW, rightW]);
|
||||
|
||||
const openHelp = useCallback((section = 'edit') => {
|
||||
setHelpSection(section);
|
||||
setShowHelp(true);
|
||||
}, []);
|
||||
|
||||
// Undo / redo. Snapshots capture both widgets and (for plot panels) the split
|
||||
// layout, so split/close/resize operations are undone atomically.
|
||||
type Snapshot = { widgets: Widget[]; layout?: PlotLayout };
|
||||
const undoStack = useRef<Snapshot[]>([]);
|
||||
const redoStack = useRef<Snapshot[]>([]);
|
||||
const [historyLen, setHistoryLen] = useState(0); // drives canUndo/canRedo display
|
||||
const widgetsRef = useRef(iface.widgets);
|
||||
widgetsRef.current = iface.widgets;
|
||||
const widgetsRef = useRef(iface.widgets || []);
|
||||
widgetsRef.current = iface.widgets || [];
|
||||
const layoutRef = useRef(iface.layout);
|
||||
layoutRef.current = iface.layout;
|
||||
|
||||
function pushUndo() {
|
||||
undoStack.current = [...undoStack.current.slice(-49), [...widgetsRef.current]];
|
||||
const snapshot = useCallback((): Snapshot => ({
|
||||
widgets: [...widgetsRef.current],
|
||||
layout: layoutRef.current,
|
||||
}), []);
|
||||
|
||||
const pushUndo = useCallback(() => {
|
||||
undoStack.current = [...undoStack.current.slice(-49), snapshot()];
|
||||
redoStack.current = [];
|
||||
setHistoryLen(undoStack.current.length);
|
||||
}
|
||||
}, [snapshot]);
|
||||
|
||||
function undo() {
|
||||
const undo = useCallback(() => {
|
||||
if (undoStack.current.length === 0) return;
|
||||
const prev = undoStack.current[undoStack.current.length - 1];
|
||||
redoStack.current = [widgetsRef.current, ...redoStack.current];
|
||||
redoStack.current = [snapshot(), ...redoStack.current];
|
||||
undoStack.current = undoStack.current.slice(0, -1);
|
||||
setHistoryLen(undoStack.current.length);
|
||||
setIface(f => ({ ...f, widgets: prev }));
|
||||
setIface(f => ({ ...f, widgets: prev.widgets, layout: prev.layout }));
|
||||
setDirty(true);
|
||||
}
|
||||
}, [snapshot]);
|
||||
|
||||
function redo() {
|
||||
const redo = useCallback(() => {
|
||||
if (redoStack.current.length === 0) return;
|
||||
const next = redoStack.current[0];
|
||||
undoStack.current = [...undoStack.current, widgetsRef.current];
|
||||
undoStack.current = [...undoStack.current, snapshot()];
|
||||
redoStack.current = redoStack.current.slice(1);
|
||||
setHistoryLen(undoStack.current.length);
|
||||
setIface(f => ({ ...f, widgets: next }));
|
||||
setIface(f => ({ ...f, widgets: next.widgets, layout: next.layout }));
|
||||
setDirty(true);
|
||||
}
|
||||
}, [snapshot]);
|
||||
|
||||
const handleWidgetsChange = useCallback((widgets: Widget[]) => {
|
||||
pushUndo();
|
||||
setIface(f => ({ ...f, widgets }));
|
||||
setDirty(true);
|
||||
}, []);
|
||||
}, [pushUndo]);
|
||||
|
||||
// Combined widgets + layout change for plot-panel split/close/resize.
|
||||
const handlePlotChange = useCallback((widgets: Widget[], layout: PlotLayout) => {
|
||||
pushUndo();
|
||||
setIface(f => ({ ...f, widgets, layout }));
|
||||
setDirty(true);
|
||||
}, [pushUndo]);
|
||||
|
||||
const handleWidgetChange = useCallback((updated: Widget) => {
|
||||
pushUndo();
|
||||
setIface(f => ({ ...f, widgets: f.widgets.map(w => w.id === updated.id ? updated : w) }));
|
||||
setIface(f => ({ ...f, widgets: (f.widgets || []).map(w => w.id === updated.id ? updated : w) }));
|
||||
setDirty(true);
|
||||
}, []);
|
||||
}, [pushUndo]);
|
||||
|
||||
const handleIfaceChange = useCallback((updated: Interface) => {
|
||||
setIface(updated);
|
||||
setDirty(true);
|
||||
}, []);
|
||||
|
||||
const handleStateVarsChange = useCallback((statevars: StateVar[]) => {
|
||||
setIface(f => ({ ...f, statevars }));
|
||||
setDirty(true);
|
||||
initLocalState(statevars);
|
||||
}, []);
|
||||
|
||||
const handleLogicChange = useCallback((logic: LogicGraph) => {
|
||||
setIface(f => ({ ...f, logic }));
|
||||
setDirty(true);
|
||||
}, []);
|
||||
|
||||
// Instantiate local state variables when the edited panel loads/changes so
|
||||
// they can be previewed live in the canvas just like real signals.
|
||||
useEffect(() => {
|
||||
initLocalState(iface.statevars);
|
||||
}, [iface.id]);
|
||||
|
||||
// ── Keyboard shortcuts ─────────────────────────────────────────────────────
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
const target = e.target as Element;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') return;
|
||||
if (e.key === '?') { openHelp('edit'); return; }
|
||||
|
||||
// The logic editor manages its own undo/redo/clipboard/delete shortcuts.
|
||||
if (centerTab === 'logic') return;
|
||||
|
||||
// Plot panels use split/close controls instead of free-form widget editing;
|
||||
// only undo/redo apply here.
|
||||
if (iface.kind === 'plot') {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; }
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; }
|
||||
return;
|
||||
}
|
||||
|
||||
const curWidgets = widgetsRef.current;
|
||||
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedIds.length > 0) {
|
||||
pushUndo();
|
||||
setIface(f => ({ ...f, widgets: curWidgets.filter(w => !selectedIds.includes(w.id)) }));
|
||||
setSelectedIds([]);
|
||||
setDirty(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Copy
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'c' && selectedIds.length > 0) {
|
||||
e.preventDefault();
|
||||
clipboard.current = curWidgets
|
||||
.filter(w => selectedIds.includes(w.id))
|
||||
.map(w => ({ ...w }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Paste
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'v' && clipboard.current.length > 0) {
|
||||
e.preventDefault();
|
||||
pushUndo();
|
||||
const offset = 20;
|
||||
const pasted = clipboard.current.map(w => ({
|
||||
...w,
|
||||
id: genWidgetId(),
|
||||
x: w.x + offset,
|
||||
y: w.y + offset,
|
||||
}));
|
||||
setIface(f => ({ ...f, widgets: [...curWidgets, ...pasted] }));
|
||||
setSelectedIds(pasted.map(w => w.id));
|
||||
setDirty(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; }
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; }
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
||||
e.preventDefault();
|
||||
setSelectedIds(curWidgets.map(w => w.id));
|
||||
return;
|
||||
}
|
||||
// Arrow nudge
|
||||
if (['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key) && selectedIds.length > 0) {
|
||||
e.preventDefault();
|
||||
const step = e.shiftKey ? (snapGrid || 10) * 2 : (snapGrid || 1);
|
||||
const dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0;
|
||||
const dy = e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? step : 0;
|
||||
handleWidgetsChange(curWidgets.map(w =>
|
||||
selectedIds.includes(w.id) ? { ...w, x: w.x + dx, y: w.y + dy } : w
|
||||
));
|
||||
}
|
||||
}, [selectedIds, snapGrid, undo, redo, handleWidgetsChange, openHelp, pushUndo, iface.kind, centerTab]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
// ── Align / distribute ─────────────────────────────────────────────────────
|
||||
|
||||
function doAlign(mode: string) {
|
||||
const aligned = alignWidgets(iface.widgets, selectedIds, mode);
|
||||
const doAlign = useCallback((mode: string) => {
|
||||
const aligned = alignWidgets(iface.widgets || [], selectedIds, mode);
|
||||
handleWidgetsChange(aligned);
|
||||
}
|
||||
}, [iface.widgets, selectedIds, handleWidgetsChange]);
|
||||
|
||||
function doDistribute(axis: 'h' | 'v') {
|
||||
const distributed = distributeWidgets(iface.widgets, selectedIds, axis);
|
||||
const doDistribute = useCallback((axis: 'h' | 'v') => {
|
||||
const distributed = distributeWidgets(iface.widgets || [], selectedIds, axis);
|
||||
handleWidgetsChange(distributed);
|
||||
}
|
||||
}, [iface.widgets, selectedIds, handleWidgetsChange]);
|
||||
|
||||
// ── Insert static widget ───────────────────────────────────────────────────
|
||||
|
||||
function insertWidget(type: string) {
|
||||
const insertWidget = useCallback((type: string) => {
|
||||
setShowInsertMenu(false);
|
||||
const [defW, defH] = DEFAULT_SIZES[type] ?? [150, 60];
|
||||
const newWidget: Widget = {
|
||||
@@ -160,22 +326,26 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
w: defW,
|
||||
h: defH,
|
||||
signals: [],
|
||||
options: type === 'textlabel' ? { label: 'Label' } : {},
|
||||
options:
|
||||
type === 'textlabel' ? { label: 'Label' } :
|
||||
type === 'button' ? { label: 'Button', mode: 'oneshot' } :
|
||||
{},
|
||||
};
|
||||
handleWidgetsChange([...iface.widgets, newWidget]);
|
||||
handleWidgetsChange([...(iface.widgets || []), newWidget]);
|
||||
setSelectedIds([newWidget.id]);
|
||||
}
|
||||
}, [iface.widgets, handleWidgetsChange]);
|
||||
|
||||
// ── Save / export / import ─────────────────────────────────────────────────
|
||||
|
||||
async function handleSave() {
|
||||
const handleSave = useCallback(async (saveTag = '') => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const xml = serializeInterface(iface);
|
||||
const url = iface.id
|
||||
const base = iface.id
|
||||
? `/api/v1/interfaces/${encodeURIComponent(iface.id)}`
|
||||
: '/api/v1/interfaces';
|
||||
const url = saveTag ? `${base}?tag=${encodeURIComponent(saveTag)}` : base;
|
||||
const method = iface.id ? 'PUT' : 'POST';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
@@ -188,14 +358,141 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
setIface(f => ({ ...f, id: json.id }));
|
||||
}
|
||||
setDirty(false);
|
||||
setTag('');
|
||||
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
|
||||
if (showHistory) loadVersions();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
// loadVersions is stable (declared below); intentionally omitted from deps.
|
||||
}, [iface, showHistory]);
|
||||
|
||||
function handleExport() {
|
||||
// ── Version history ────────────────────────────────────────────────────────
|
||||
|
||||
const loadVersions = useCallback(async () => {
|
||||
if (!iface.id) { setVersions([]); return; }
|
||||
setHistoryLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions`);
|
||||
if (!res.ok) throw new Error(`Load versions failed (${res.status})`);
|
||||
setVersions(await res.json());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
}, [iface.id]);
|
||||
|
||||
const toggleHistory = useCallback(() => {
|
||||
setShowHistory(s => {
|
||||
if (!s) loadVersions();
|
||||
return !s;
|
||||
});
|
||||
}, [loadVersions]);
|
||||
|
||||
// Load a past revision into the editor non-destructively: the current id is
|
||||
// preserved, so saving the restored content creates a new revision on top.
|
||||
const loadVersion = useCallback(async (version: number) => {
|
||||
if (!iface.id) return;
|
||||
if (dirty && !confirm('You have unsaved changes. Load this version anyway?')) return;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}`);
|
||||
if (!res.ok) throw new Error(`Load version failed (${res.status})`);
|
||||
const restored = parseInterface(await res.text());
|
||||
restored.id = iface.id; // keep editing the same interface
|
||||
setIface(restored);
|
||||
setSelectedIds([]);
|
||||
setDirty(true);
|
||||
setCanvasNonce(n => n + 1);
|
||||
undoStack.current = [];
|
||||
redoStack.current = [];
|
||||
setHistoryLen(0);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [iface.id, dirty]);
|
||||
|
||||
// Edit (or clear) the label of an existing revision in place.
|
||||
const editVersionTag = useCallback(async (version: number, currentTag: string) => {
|
||||
if (!iface.id) return;
|
||||
const next = prompt('Label for this version (leave empty to clear):', currentTag ?? '');
|
||||
if (next === null) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}/tag?tag=${encodeURIComponent(next)}`,
|
||||
{ method: 'PUT' },
|
||||
);
|
||||
if (!res.ok) throw new Error(`Tag update failed (${res.status})`);
|
||||
loadVersions();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [iface.id, loadVersions]);
|
||||
|
||||
// Make a past revision the current one (saved as a new revision on top).
|
||||
const promoteVersion = useCallback(async (version: number) => {
|
||||
if (!iface.id) return;
|
||||
if (dirty && !confirm('You have unsaved changes that will be discarded. Set this version as current?')) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}/promote`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
if (!res.ok) throw new Error(`Promote failed (${res.status})`);
|
||||
// Reload the now-current interface content into the editor.
|
||||
const cur = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}`);
|
||||
const reloaded = parseInterface(await cur.text());
|
||||
setIface(reloaded);
|
||||
setSelectedIds([]);
|
||||
setDirty(false);
|
||||
setCanvasNonce(n => n + 1);
|
||||
undoStack.current = [];
|
||||
redoStack.current = [];
|
||||
setHistoryLen(0);
|
||||
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
|
||||
loadVersions();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [iface.id, dirty, loadVersions]);
|
||||
|
||||
// Fork a revision into a brand-new interface.
|
||||
const forkVersion = useCallback(async (version: number) => {
|
||||
if (!iface.id) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}/fork`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
if (!res.ok) throw new Error(`Fork failed (${res.status})`);
|
||||
const json = await res.json();
|
||||
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
|
||||
alert(`Forked into new interface "${json.id}". Open it from the interface list.`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [iface.id]);
|
||||
|
||||
// ── Auto-snapshot: every 5 minutes, if there are unsaved changes on an
|
||||
// already-persisted interface, save a new revision automatically. ──────────
|
||||
const saveRef = useRef(handleSave);
|
||||
saveRef.current = handleSave;
|
||||
const autoSaveState = useRef({ dirty, id: iface.id, saving });
|
||||
autoSaveState.current = { dirty, id: iface.id, saving };
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const s = autoSaveState.current;
|
||||
if (s.dirty && s.id && !s.saving) {
|
||||
saveRef.current('auto');
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
const xml = serializeInterface(iface);
|
||||
const blob = new Blob([xml], { type: 'application/xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -204,9 +501,9 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
a.download = `${iface.name.replace(/[^a-z0-9]/gi, '_')}.xml`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}, [iface]);
|
||||
|
||||
function handleImport() {
|
||||
const handleImport = useCallback(() => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.xml,application/xml,text/xml';
|
||||
@@ -225,48 +522,30 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
}, []);
|
||||
|
||||
function handleLeave() {
|
||||
const handleLeave = useCallback(async () => {
|
||||
if (dirty && !confirm('You have unsaved changes. Leave without saving?')) return;
|
||||
onDone();
|
||||
}
|
||||
|
||||
// ── Keyboard shortcuts ─────────────────────────────────────────────────────
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
const target = e.target as Element;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') return;
|
||||
if (e.key === '?') { openHelp('edit'); return; }
|
||||
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedIds.length > 0) {
|
||||
pushUndo();
|
||||
setIface(f => ({ ...f, widgets: f.widgets.filter(w => !selectedIds.includes(w.id)) }));
|
||||
setSelectedIds([]);
|
||||
setDirty(true);
|
||||
// Brand-new panel that was never saved: nothing to show — return to the
|
||||
// previously open panel (signalled by passing null).
|
||||
if (!iface.id) { onDone(null); return; }
|
||||
// Existing panel closed with unsaved edits: reopen the last saved (active)
|
||||
// version rather than carrying the discarded in-memory changes into view.
|
||||
if (dirty) {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
onDone(parseInterface(await res.text()));
|
||||
} catch {
|
||||
onDone(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; }
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; }
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
||||
e.preventDefault();
|
||||
setSelectedIds(iface.widgets.map(w => w.id));
|
||||
return;
|
||||
}
|
||||
// Arrow nudge
|
||||
if (['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key) && selectedIds.length > 0) {
|
||||
e.preventDefault();
|
||||
const step = e.shiftKey ? (snapGrid || 10) * 2 : (snapGrid || 1);
|
||||
const dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0;
|
||||
const dy = e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? step : 0;
|
||||
handleWidgetsChange(iface.widgets.map(w =>
|
||||
selectedIds.includes(w.id) ? { ...w, x: w.x + dx, y: w.y + dy } : w
|
||||
));
|
||||
}
|
||||
}
|
||||
onDone(iface);
|
||||
}, [dirty, onDone, iface]);
|
||||
|
||||
const selectedWidget = selectedIds.length === 1
|
||||
? iface.widgets.find(w => w.id === selectedIds[0]) ?? null
|
||||
? (iface.widgets || []).find(w => w.id === selectedIds[0]) ?? null
|
||||
: null;
|
||||
|
||||
const canUndo = historyLen > 0;
|
||||
@@ -274,7 +553,7 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
const multiSelected = selectedIds.length > 1;
|
||||
|
||||
return (
|
||||
<div class="edit-mode-layout" onKeyDown={handleKeyDown} tabIndex={-1}>
|
||||
<div class="edit-mode-layout" tabIndex={-1}>
|
||||
{/* ── Toolbar ── */}
|
||||
<header class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
@@ -309,17 +588,19 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
<span>Grid</span>
|
||||
</label>
|
||||
|
||||
{/* Insert static widget */}
|
||||
<div class="toolbar-dropdown">
|
||||
<button class="toolbar-btn" onClick={() => setShowInsertMenu(m => !m)}>+ Insert ▾</button>
|
||||
{showInsertMenu && (
|
||||
<div class="toolbar-dropdown-menu" onClick={() => setShowInsertMenu(false)}>
|
||||
{STATIC_WIDGET_TYPES.map(({ type, label }) => (
|
||||
<button key={type} class="ctx-item" onClick={() => insertWidget(type)}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Insert static widget — not applicable to split plot panels */}
|
||||
{iface.kind !== 'plot' && (
|
||||
<div class="toolbar-dropdown">
|
||||
<button class="toolbar-btn" onClick={() => setShowInsertMenu(m => !m)}>+ Insert ▾</button>
|
||||
{showInsertMenu && (
|
||||
<div class="toolbar-dropdown-menu" onClick={() => setShowInsertMenu(false)}>
|
||||
{STATIC_WIDGET_TYPES.map(({ type, label }) => (
|
||||
<button key={type} class="ctx-item" onClick={() => insertWidget(type)}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Align/distribute — only when ≥2 selected */}
|
||||
{selectedIds.length >= 2 && (
|
||||
@@ -344,11 +625,25 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
</div>
|
||||
|
||||
<div class="toolbar-right">
|
||||
<ZoomControl />
|
||||
<ContextualHelp mode="edit" onOpenManual={openHelp} />
|
||||
<button class="icon-btn help-manual-btn" onClick={() => openHelp('edit')} title="Open user manual">📖</button>
|
||||
<button class="toolbar-btn" onClick={handleImport}>Import</button>
|
||||
<button class="toolbar-btn" onClick={handleExport}>Export</button>
|
||||
<button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving}>
|
||||
<button
|
||||
class={`toolbar-btn${showHistory ? ' toolbar-btn-active' : ''}`}
|
||||
onClick={toggleHistory}
|
||||
disabled={!iface.id}
|
||||
title={iface.id ? 'Version history' : 'Save the interface first to enable history'}
|
||||
>
|
||||
🕘 History
|
||||
</button>
|
||||
<button
|
||||
class="toolbar-btn toolbar-btn-primary"
|
||||
onClick={() => handleSave(tag)}
|
||||
disabled={saving || !writable}
|
||||
title={writable ? '' : 'You have read-only access'}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button class="toolbar-btn" onClick={handleLeave}>✕ Close</button>
|
||||
@@ -357,21 +652,130 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
|
||||
{/* ── Three-panel body ── */}
|
||||
<div class="edit-body">
|
||||
<SignalTree />
|
||||
<EditCanvas
|
||||
iface={iface}
|
||||
selectedIds={selectedIds}
|
||||
onSelect={setSelectedIds}
|
||||
onChange={handleWidgetsChange}
|
||||
snapGrid={snapGrid}
|
||||
/>
|
||||
<PropertiesPane
|
||||
selected={selectedWidget}
|
||||
multiCount={multiSelected ? selectedIds.length : 0}
|
||||
iface={iface}
|
||||
onChange={handleWidgetChange}
|
||||
onIfaceChange={handleIfaceChange}
|
||||
/>
|
||||
{/* The logic tab is a self-contained flow editor with its own palette and
|
||||
inspector, so the layout-only side panes are hidden while it is open. */}
|
||||
{centerTab !== 'logic' && (
|
||||
<Fragment>
|
||||
<SignalTree
|
||||
width={leftW}
|
||||
panelId={iface.id}
|
||||
statevars={iface.statevars}
|
||||
onStateVarsChange={handleStateVarsChange}
|
||||
/>
|
||||
<div class="panel-resize-handle" onMouseDown={startResize('left')} />
|
||||
</Fragment>
|
||||
)}
|
||||
<div class="edit-center">
|
||||
<div class="center-tabs">
|
||||
<button
|
||||
class={`center-tab${centerTab === 'layout' ? ' center-tab-active' : ''}`}
|
||||
onClick={() => setCenterTab('layout')}
|
||||
>Layout</button>
|
||||
{me.canEditLogic && (
|
||||
<button
|
||||
class={`center-tab${centerTab === 'logic' ? ' center-tab-active' : ''}`}
|
||||
onClick={() => setCenterTab('logic')}
|
||||
>Logic{iface.logic && iface.logic.nodes.length > 0 ? ` (${iface.logic.nodes.length})` : ''}</button>
|
||||
)}
|
||||
</div>
|
||||
{centerTab === 'logic' && me.canEditLogic ? (
|
||||
<LogicEditor
|
||||
graph={iface.logic ?? { nodes: [], wires: [] }}
|
||||
onChange={handleLogicChange}
|
||||
widgets={iface.widgets}
|
||||
statevars={iface.statevars}
|
||||
onStateVarsChange={handleStateVarsChange}
|
||||
/>
|
||||
) : iface.kind === 'plot' ? (
|
||||
<PlotPanelCanvas
|
||||
key={canvasNonce}
|
||||
iface={iface}
|
||||
selectedIds={selectedIds}
|
||||
onSelect={setSelectedIds}
|
||||
onChange={handlePlotChange}
|
||||
/>
|
||||
) : (
|
||||
<EditCanvas
|
||||
key={canvasNonce}
|
||||
iface={iface}
|
||||
selectedIds={selectedIds}
|
||||
onSelect={setSelectedIds}
|
||||
onChange={handleWidgetsChange}
|
||||
snapGrid={snapGrid}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{centerTab !== 'logic' && (
|
||||
<Fragment>
|
||||
<div class="panel-resize-handle" onMouseDown={startResize('right')} />
|
||||
<PropertiesPane
|
||||
selected={selectedWidget}
|
||||
multiCount={multiSelected ? selectedIds.length : 0}
|
||||
iface={iface}
|
||||
onChange={handleWidgetChange}
|
||||
onIfaceChange={handleIfaceChange}
|
||||
width={rightW}
|
||||
/>
|
||||
</Fragment>
|
||||
)}
|
||||
{showHistory && (
|
||||
<div class="history-pane">
|
||||
<div class="history-pane-header">
|
||||
<span>Version History</span>
|
||||
<button class="icon-btn" onClick={() => setShowHistory(false)} title="Close">✕</button>
|
||||
</div>
|
||||
<div class="history-save-row">
|
||||
<input
|
||||
class="history-tag-input"
|
||||
placeholder="Optional label…"
|
||||
value={tag}
|
||||
onInput={(e) => setTag((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<button class="toolbar-btn" onClick={() => handleSave(tag)} disabled={saving || !writable}>
|
||||
Save snapshot
|
||||
</button>
|
||||
</div>
|
||||
<div class="history-list">
|
||||
{historyLoading && <div class="history-empty">Loading…</div>}
|
||||
{!historyLoading && versions.length === 0 && (
|
||||
<div class="history-empty">No saved versions yet.</div>
|
||||
)}
|
||||
{!historyLoading && versions.map(v => (
|
||||
<div
|
||||
key={v.version}
|
||||
class={`history-item${v.current ? ' history-item-current' : ''}`}
|
||||
>
|
||||
<div class="history-item-main">
|
||||
<span class="history-version">v{v.version}</span>
|
||||
{v.tag && <span class="history-tag">{v.tag}</span>}
|
||||
{v.current && <span class="history-current-badge">current</span>}
|
||||
</div>
|
||||
<div class="history-item-meta">
|
||||
{new Date(v.savedAt).toLocaleString()}
|
||||
</div>
|
||||
<div class="history-item-actions">
|
||||
{!v.current && (
|
||||
<button class="history-action-btn" onClick={() => loadVersion(v.version)} title="Load this version into the editor (does not change history)">
|
||||
Load
|
||||
</button>
|
||||
)}
|
||||
{!v.current && (
|
||||
<button class="history-action-btn" onClick={() => promoteVersion(v.version)} title="Make this version the current one">
|
||||
Set current
|
||||
</button>
|
||||
)}
|
||||
<button class="history-action-btn" onClick={() => forkVersion(v.version)} title="Create a new interface from this version">
|
||||
Fork
|
||||
</button>
|
||||
<button class="history-action-btn" onClick={() => editVersionTag(v.version, v.tag ?? '')} title="Edit this version's label">
|
||||
Label
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showHelp && (
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import type { GroupNode, SignalRef } from './lib/types';
|
||||
|
||||
interface Props {
|
||||
onDragStart?: (sig: SignalRef) => void;
|
||||
}
|
||||
|
||||
// ── Pure tree helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function genId(): string {
|
||||
return Math.random().toString(36).slice(2, 10);
|
||||
}
|
||||
|
||||
/** Insert a child node at the end of the folder at `path` (empty = root). */
|
||||
function insertAt(nodes: GroupNode[], path: number[], child: GroupNode): GroupNode[] {
|
||||
if (path.length === 0) return [...nodes, child];
|
||||
return nodes.map((n, i) => {
|
||||
if (i !== path[0] || n.kind !== 'folder') return n;
|
||||
return { ...n, children: insertAt(n.children, path.slice(1), child) };
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove the node at `path`. */
|
||||
function removeAt(nodes: GroupNode[], path: number[]): GroupNode[] {
|
||||
if (path.length === 1) return nodes.filter((_, i) => i !== path[0]);
|
||||
return nodes.map((n, i) => {
|
||||
if (i !== path[0] || n.kind !== 'folder') return n;
|
||||
return { ...n, children: removeAt(n.children, path.slice(1)) };
|
||||
});
|
||||
}
|
||||
|
||||
/** Update the folder label at `path`. */
|
||||
function renameAt(nodes: GroupNode[], path: number[], label: string): GroupNode[] {
|
||||
if (path.length === 1) {
|
||||
return nodes.map((n, i) =>
|
||||
i === path[0] && n.kind === 'folder' ? { ...n, label } : n
|
||||
);
|
||||
}
|
||||
return nodes.map((n, i) => {
|
||||
if (i !== path[0] || n.kind !== 'folder') return n;
|
||||
return { ...n, children: renameAt(n.children, path.slice(1), label) };
|
||||
});
|
||||
}
|
||||
|
||||
// ── Parse signal input ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Parse "ds:name" or bare "name" (defaults to epics). */
|
||||
function parseSignal(input: string): { ds: string; name: string } | null {
|
||||
const s = input.trim();
|
||||
if (!s) return null;
|
||||
const colon = s.indexOf(':');
|
||||
if (colon > 0) return { ds: s.slice(0, colon), name: s.slice(colon + 1) };
|
||||
return { ds: 'epics', name: s };
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function GroupsTree({ onDragStart }: Props) {
|
||||
const [nodes, setNodes] = useState<GroupNode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||
|
||||
// What the user is currently adding inline (null = nothing)
|
||||
const [addingAt, setAddingAt] = useState<{ path: number[]; kind: 'signal' | 'folder' } | null>(null);
|
||||
const [addInput, setAddInput] = useState('');
|
||||
|
||||
// Inline folder rename
|
||||
const [renamingPath, setRenamingPath] = useState<number[] | null>(null);
|
||||
const [renameInput, setRenameInput] = useState('');
|
||||
|
||||
// ── Load / save ─────────────────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/groups')
|
||||
.then(r => r.json())
|
||||
.then((data: GroupNode[]) => { setNodes(data); setLoading(false); })
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
function save(next: GroupNode[]) {
|
||||
fetch('/api/v1/groups', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(next),
|
||||
}).catch(e => console.error('groups save failed:', e));
|
||||
}
|
||||
|
||||
function update(next: GroupNode[]) {
|
||||
setNodes(next);
|
||||
save(next);
|
||||
}
|
||||
|
||||
// ── Drag ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeDraggable(ds: string, name: string) {
|
||||
return {
|
||||
draggable: true as const,
|
||||
onDragStart: (e: DragEvent) => {
|
||||
const ref: SignalRef = { ds, name };
|
||||
e.dataTransfer?.setData('application/json', JSON.stringify(ref));
|
||||
onDragStart?.(ref);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Inline add ──────────────────────────────────────────────────────────────
|
||||
|
||||
function startAdding(path: number[], kind: 'signal' | 'folder') {
|
||||
setAddingAt({ path, kind });
|
||||
setAddInput('');
|
||||
}
|
||||
|
||||
function commitAdd() {
|
||||
if (!addingAt) return;
|
||||
const { path, kind } = addingAt;
|
||||
const val = addInput.trim();
|
||||
setAddingAt(null);
|
||||
setAddInput('');
|
||||
if (!val) return;
|
||||
if (kind === 'folder') {
|
||||
const node: GroupNode = { kind: 'folder', id: genId(), label: val, children: [] };
|
||||
update(insertAt(nodes, path, node));
|
||||
} else {
|
||||
const sig = parseSignal(val);
|
||||
if (!sig) return;
|
||||
const node: GroupNode = { kind: 'signal', ds: sig.ds, name: sig.name };
|
||||
update(insertAt(nodes, path, node));
|
||||
}
|
||||
}
|
||||
|
||||
function cancelAdd() {
|
||||
setAddingAt(null);
|
||||
setAddInput('');
|
||||
}
|
||||
|
||||
// ── Rename ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function startRename(path: number[], label: string) {
|
||||
setRenamingPath(path);
|
||||
setRenameInput(label);
|
||||
}
|
||||
|
||||
function commitRename() {
|
||||
if (!renamingPath || !renameInput.trim()) { cancelRename(); return; }
|
||||
update(renameAt(nodes, renamingPath, renameInput.trim()));
|
||||
setRenamingPath(null);
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
setRenamingPath(null);
|
||||
setRenameInput('');
|
||||
}
|
||||
|
||||
// ── Collapse ────────────────────────────────────────────────────────────────
|
||||
|
||||
function toggleCollapse(id: string) {
|
||||
setCollapsed(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function renderAddRow(path: number[], kind: 'signal' | 'folder') {
|
||||
const isActive = addingAt &&
|
||||
addingAt.kind === kind &&
|
||||
JSON.stringify(addingAt.path) === JSON.stringify(path);
|
||||
if (!isActive) return null;
|
||||
return (
|
||||
<div class="group-add-row">
|
||||
<input
|
||||
class="group-add-input"
|
||||
autoFocus
|
||||
placeholder={kind === 'signal' ? 'ds:name or PV name…' : 'Folder name…'}
|
||||
value={addInput}
|
||||
onInput={(e) => setAddInput((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') commitAdd();
|
||||
if (e.key === 'Escape') cancelAdd();
|
||||
}}
|
||||
/>
|
||||
<button class="icon-btn" onClick={commitAdd}>✓</button>
|
||||
<button class="icon-btn" onClick={cancelAdd}>✕</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderNode(node: GroupNode, path: number[]) {
|
||||
const pathKey = JSON.stringify(path);
|
||||
|
||||
if (node.kind === 'signal') {
|
||||
return (
|
||||
<div
|
||||
key={pathKey}
|
||||
class="group-signal-item"
|
||||
title={`${node.ds}:${node.name}`}
|
||||
{...makeDraggable(node.ds, node.name)}
|
||||
>
|
||||
<span class="group-signal-ds">{node.ds}</span>
|
||||
<span class="group-signal-name">{node.name}</span>
|
||||
<button
|
||||
class="icon-btn group-node-remove"
|
||||
title="Remove from group"
|
||||
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
|
||||
onClick={(e: MouseEvent) => { e.stopPropagation(); update(removeAt(nodes, path)); }}
|
||||
>✕</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// folder node
|
||||
const isCollapsed = collapsed.has(node.id);
|
||||
const isRenaming = renamingPath !== null && JSON.stringify(renamingPath) === pathKey;
|
||||
|
||||
return (
|
||||
<div key={pathKey} class="group-folder">
|
||||
<div class="group-folder-header">
|
||||
<span
|
||||
class="group-folder-arrow"
|
||||
onClick={() => toggleCollapse(node.id)}
|
||||
>
|
||||
{isCollapsed ? '▸' : '▾'}
|
||||
</span>
|
||||
|
||||
{isRenaming ? (
|
||||
<input
|
||||
class="group-rename-input"
|
||||
autoFocus
|
||||
value={renameInput}
|
||||
onInput={(e) => setRenameInput((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') commitRename();
|
||||
if (e.key === 'Escape') cancelRename();
|
||||
}}
|
||||
onBlur={commitRename}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
class="group-folder-name"
|
||||
onDblClick={() => startRename(path, node.label)}
|
||||
title="Double-click to rename"
|
||||
>
|
||||
{node.label}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span class="group-folder-count">{node.children.length}</span>
|
||||
|
||||
<div class="group-folder-actions">
|
||||
<button
|
||||
class="icon-btn"
|
||||
title="Add signal"
|
||||
onClick={() => { if (!isCollapsed) toggleCollapse(node.id); startAdding(path, 'signal'); }}
|
||||
>+📶</button>
|
||||
<button
|
||||
class="icon-btn"
|
||||
title="Add subfolder"
|
||||
onClick={() => { if (!isCollapsed) toggleCollapse(node.id); startAdding(path, 'folder'); }}
|
||||
>+📁</button>
|
||||
<button
|
||||
class="icon-btn group-node-remove"
|
||||
title="Delete folder"
|
||||
onClick={() => update(removeAt(nodes, path))}
|
||||
>🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div class="group-folder-children">
|
||||
{node.children.map((child, i) => renderNode(child, [...path, i]))}
|
||||
{renderAddRow(path, 'signal')}
|
||||
{renderAddRow(path, 'folder')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isAddingRoot = addingAt !== null && addingAt.path.length === 0;
|
||||
|
||||
return (
|
||||
<div class="groups-tree">
|
||||
{/* Root-level toolbar */}
|
||||
<div class="groups-toolbar">
|
||||
<button
|
||||
class="panel-btn"
|
||||
title="New root group"
|
||||
onClick={() => startAdding([], 'folder')}
|
||||
>
|
||||
+ Group
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Inline input for new root folder */}
|
||||
{isAddingRoot && addingAt?.kind === 'folder' && (
|
||||
<div class="group-add-row group-add-root">
|
||||
<input
|
||||
class="group-add-input"
|
||||
autoFocus
|
||||
placeholder="Group name…"
|
||||
value={addInput}
|
||||
onInput={(e) => setAddInput((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') commitAdd();
|
||||
if (e.key === 'Escape') cancelAdd();
|
||||
}}
|
||||
/>
|
||||
<button class="icon-btn" onClick={commitAdd}>✓</button>
|
||||
<button class="icon-btn" onClick={cancelAdd}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="panel-list group-list">
|
||||
{loading ? (
|
||||
<p class="hint">Loading groups…</p>
|
||||
) : nodes.length === 0 && !isAddingRoot ? (
|
||||
<p class="hint">No groups yet — click "+ Group" to create one.</p>
|
||||
) : (
|
||||
nodes.map((node, i) => renderNode(node, [i]))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+182
-6
@@ -406,7 +406,11 @@ const SECTIONS = [
|
||||
{ id: 'view', label: 'View Mode' },
|
||||
{ id: 'edit', label: 'Edit Mode' },
|
||||
{ id: 'widgets', label: 'Widgets' },
|
||||
{ id: 'plots', label: 'Plot Panels' },
|
||||
{ id: 'signals', label: 'Signals' },
|
||||
{ id: 'logic', label: 'Panel Logic' },
|
||||
{ id: 'control', label: 'Control Logic' },
|
||||
{ id: 'sharing', label: 'Sharing & Access' },
|
||||
{ id: 'history', label: 'Historical Data' },
|
||||
{ id: 'shortcuts', label: 'Keyboard Shortcuts' },
|
||||
];
|
||||
@@ -510,6 +514,24 @@ function SectionEdit() {
|
||||
<h3 class="help-h3">Align & distribute</h3>
|
||||
<p>When 2+ widgets are selected the alignment toolbar appears. Buttons align edges or centres; with 3+ selected you can distribute spacing evenly.</p>
|
||||
|
||||
<h3 class="help-h3">Layout & Logic tabs</h3>
|
||||
<p>The centre of the editor has two tabs:</p>
|
||||
<ul class="help-list">
|
||||
<li><strong>Layout</strong> — the drag-and-drop widget canvas described above.</li>
|
||||
<li><strong>Logic</strong> — a visual flow editor that adds interactive behaviour to the
|
||||
panel (buttons that run actions, thresholds that pop up dialogs, timers, …). See the
|
||||
<em>Panel Logic</em> section. The tab is hidden if you are not permitted to edit logic.</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="help-h3">Local variables</h3>
|
||||
<p>
|
||||
Panels can define their own <strong>local variables</strong> — lightweight values that live
|
||||
only inside the panel (no data source needed). Add them from the signal tree's
|
||||
<em>Local</em> group (or the Logic palette). They are written by Set-value widgets, buttons
|
||||
and logic actions, and referenced anywhere a signal is, making them handy for set-points,
|
||||
toggles and counters used by panel logic.
|
||||
</p>
|
||||
|
||||
<h3 class="help-h3">Saving</h3>
|
||||
<ul class="help-list">
|
||||
<li><strong>Save</strong> — stores the panel on the server (XML). A <span style="color:#f59e0b">●</span> indicates unsaved changes.</li>
|
||||
@@ -520,6 +542,141 @@ function SectionEdit() {
|
||||
);
|
||||
}
|
||||
|
||||
function SectionPlots() {
|
||||
return (
|
||||
<div>
|
||||
<p class="help-lead">
|
||||
A <strong>plot panel</strong> is a special interface dedicated to charts. Instead of
|
||||
free-form boxes, plots <em>fill the viewport</em> and you split the space between them like
|
||||
panes in a tiling window manager (tmux / IDE style).
|
||||
</p>
|
||||
|
||||
<h3 class="help-h3">Creating one</h3>
|
||||
<ul class="help-list">
|
||||
<li>Click <strong>+ Plot</strong> in the interface list to create a plot panel — it opens
|
||||
with one full-viewport empty plot.</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="help-h3">Splitting & arranging</h3>
|
||||
<ul class="help-list">
|
||||
<li>Hover a pane and use its split buttons (<strong>⬌</strong> vertical / <strong>⬍</strong>
|
||||
horizontal) to divide it — a new empty plot appears in the freed half.</li>
|
||||
<li>Drag the divider between two panes to resize them; nesting is unlimited.</li>
|
||||
<li>Click a pane to select it, then configure its plot in the Properties pane (plot type,
|
||||
time window, range, legend, per-signal colour).</li>
|
||||
<li>Drag a signal from the tree onto a pane to add it to that pane's plot.</li>
|
||||
<li>Use a pane's <strong>✕</strong> to remove it; the layout collapses onto its sibling.</li>
|
||||
</ul>
|
||||
<p>In view mode the saved split layout fills the screen with live, streaming plots.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLogic() {
|
||||
return (
|
||||
<div>
|
||||
<p class="help-lead">
|
||||
The <strong>Logic</strong> tab in the panel editor is a node-graph (Node-RED style) flow
|
||||
editor. You wire <em>trigger</em> nodes to <em>action</em> nodes to give a panel interactive
|
||||
behaviour. Logic runs entirely client-side while the panel is open in view mode.
|
||||
</p>
|
||||
|
||||
<h3 class="help-h3">Building a flow</h3>
|
||||
<ul class="help-list">
|
||||
<li>Drag blocks from the left palette onto the canvas (or click to add), then drag from a
|
||||
node's output port to another node's input port to connect them.</li>
|
||||
<li>Select a node to edit its parameters in the right inspector.</li>
|
||||
<li>Expression fields accept arithmetic/logic over signals — reference a signal as
|
||||
<code>{'{ds:name}'}</code> and a panel-local variable by its bare name.</li>
|
||||
<li>The editor has its own undo/redo and copy/paste (see Keyboard Shortcuts).</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="help-h3">Node types</h3>
|
||||
<div class="help-widget-table">
|
||||
{[
|
||||
{ type: 'Triggers', desc: 'Button press, threshold crossing, value change, timer/interval, panel loop, and On-open / On-close lifecycle.' },
|
||||
{ type: 'Logic', desc: 'AND gate, If (then/else branches), and Loop (count or while).' },
|
||||
{ type: 'Actions', desc: 'Write a value to a signal/variable, Delay, Log (debug), and Accumulate / Export-CSV / Clear for in-memory data arrays.' },
|
||||
{ type: 'Dialogs', desc: 'Info and Error pop-ups, and a Set-point prompt that asks the user for a number and writes it to a target.' },
|
||||
].map(w => (
|
||||
<div key={w.type} class="help-widget-row">
|
||||
<span class="help-widget-name">{w.type}</span>
|
||||
<span class="help-widget-desc">{w.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h3 class="help-h3">System helpers</h3>
|
||||
<ul class="help-list">
|
||||
<li><code>{'{sys:time}'}</code> — current time in epoch seconds.</li>
|
||||
<li><code>{'{sys:dt}'}</code> — seconds since the firing trigger last fired (useful for rates).</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionControl() {
|
||||
return (
|
||||
<div>
|
||||
<p class="help-lead">
|
||||
<strong>Control logic</strong> is server-side automation. Unlike panel logic (which only
|
||||
runs while a panel is open in a browser), control-logic graphs run continuously on the
|
||||
server, independent of any client. Open them with the <strong>⚙ Control logic</strong>
|
||||
button in the view-mode toolbar.
|
||||
</p>
|
||||
|
||||
<h3 class="help-h3">What it can do</h3>
|
||||
<ul class="help-list">
|
||||
<li>React to <em>cron</em> schedules and signal <em>alarm</em>/threshold conditions.</li>
|
||||
<li>Run a <strong>Lua</strong> block for custom logic and compute writes back to signals.</li>
|
||||
<li>Each graph can be enabled/disabled independently; changes reload the engine live.</li>
|
||||
</ul>
|
||||
|
||||
<p class="help-callout">
|
||||
Editing both panel logic and control logic can be restricted to an allowlist of users or
|
||||
groups (<code>server.logic_editors</code> in the config). When restricted, users not on the
|
||||
list keep full access to everything else but cannot add or change logic — the Logic tab and
|
||||
the ⚙ Control logic button are hidden for them.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionSharing() {
|
||||
return (
|
||||
<div>
|
||||
<p class="help-lead">
|
||||
uopi has graduated, server-enforced access control. Identity comes from a trusted
|
||||
reverse-proxy header (with a configurable default for unproxied/LAN use) — there is no login
|
||||
page in the app itself.
|
||||
</p>
|
||||
|
||||
<h3 class="help-h3">Global access levels</h3>
|
||||
<ul class="help-list">
|
||||
<li>Every user is trusted with full <strong>write</strong> access by default.</li>
|
||||
<li>A config blacklist can downgrade specific users to <strong>read-only</strong> or
|
||||
<strong>no access</strong>. Read-only users see panels but cannot edit or write signals.</li>
|
||||
<li>Named <strong>groups</strong> are defined in the config and used by panel sharing.</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="help-h3">Panel ownership & sharing</h3>
|
||||
<ul class="help-list">
|
||||
<li>New panels are <strong>private</strong> to their owner by default.</li>
|
||||
<li>Use the <strong>Share</strong> button on a panel (in the interface list) to grant
|
||||
read or write to specific users/groups, or make it public.</li>
|
||||
<li>Your global level always caps the per-panel permission (read-only stays read-only).</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="help-h3">Folders</h3>
|
||||
<ul class="help-list">
|
||||
<li>Organise panels into nested <strong>folders</strong> in the interface list.</li>
|
||||
<li>Permissions inherit down the folder chain.</li>
|
||||
<li>Drag panels to reorder them or move them between folders.</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionWidgets() {
|
||||
return (
|
||||
<div>
|
||||
@@ -602,7 +759,19 @@ function SectionSignals() {
|
||||
</p>
|
||||
|
||||
<h3 class="help-h3">Synthetic signals</h3>
|
||||
<p>Click <strong>+ Synthetic</strong> to open the wizard. Choose an input signal, a processing node (gain, moving average, lowpass filter, …), and optional metadata. The new signal appears in the tree immediately.</p>
|
||||
<p>Click <strong>+ Synthetic</strong> to compose a new signal from existing ones. A quick
|
||||
<em>wizard</em> covers the common case (pick an input + a processing node such as gain,
|
||||
offset, moving average, lowpass/highpass, derivative, integral, clamp or a custom
|
||||
formula). For multi-input pipelines, the <strong>node-graph editor</strong> lets you wire
|
||||
inputs through a chain of DSP blocks visually. The new signal appears in the tree
|
||||
immediately.</p>
|
||||
<p>Each synthetic signal has a <strong>visibility scope</strong>: <em>panel</em> (only the
|
||||
panel that created it), <em>user</em>, or <em>global</em> (shared with everyone).</p>
|
||||
|
||||
<h3 class="help-h3">Local variables</h3>
|
||||
<p>The <strong>Local</strong> group holds panel-local variables — values stored in the
|
||||
panel itself with no data source. Use them for set-points, toggles and counters driven by
|
||||
panel logic. They are added in the editor and saved with the panel.</p>
|
||||
</div>
|
||||
<DiagramSignalTree />
|
||||
</div>
|
||||
@@ -643,6 +812,8 @@ function SectionHistory() {
|
||||
|
||||
function SectionShortcuts() {
|
||||
const shortcuts: Array<[string, string]> = [
|
||||
['Ctrl+C', 'Copy selected widgets (Edit mode)'],
|
||||
['Ctrl+V', 'Paste widgets from clipboard (Edit mode)'],
|
||||
['Ctrl+Z', 'Undo last change (Edit mode)'],
|
||||
['Ctrl+Y / Ctrl+Shift+Z', 'Redo (Edit mode)'],
|
||||
['Ctrl+A', 'Select all widgets (Edit mode)'],
|
||||
@@ -674,14 +845,15 @@ function SectionShortcuts() {
|
||||
|
||||
<h3 class="help-h3">API & Metrics</h3>
|
||||
<ul class="help-list">
|
||||
<li>REST API: <code>/api/v1/datasources</code>, <code>/api/v1/signals</code>, <code>/api/v1/interfaces</code>, …</li>
|
||||
<li>Prometheus metrics: <code>/metrics</code></li>
|
||||
<li>Health check: <code>/healthz</code></li>
|
||||
<li>WebSocket: <code>ws://<host>/ws</code></li>
|
||||
<li>Signals/data: <code>/api/v1/datasources</code>, <code>/api/v1/signals</code>, <code>/api/v1/synthetic</code></li>
|
||||
<li>Panels: <code>/api/v1/interfaces</code>, <code>/api/v1/folders</code>, <code>/api/v1/interfaces/{'{id}'}/acl</code></li>
|
||||
<li>Identity & groups: <code>/api/v1/me</code>, <code>/api/v1/usergroups</code></li>
|
||||
<li>Control logic: <code>/api/v1/controllogic</code></li>
|
||||
<li>Prometheus metrics: <code>/metrics</code> · Health check: <code>/healthz</code> · WebSocket: <code>ws://<host>/ws</code></li>
|
||||
</ul>
|
||||
|
||||
<h3 class="help-h3">Configuration file</h3>
|
||||
<pre class="help-pre">[server]{'\n'}listen = ":8080"{'\n'}storage_dir = "./data"{'\n\n'}[datasource.epics]{'\n'}enabled = true{'\n'}archive_url = "http://archiver:17665"{'\n\n'}[datasource.synthetic]{'\n'}enabled = true</pre>
|
||||
<pre class="help-pre">[server]{'\n'}listen = ":8080"{'\n'}storage_dir = "./interfaces"{'\n'}# optional: restrict who may edit panel/control logic{'\n'}# logic_editors = ["operators"]{'\n\n'}[datasource.epics]{'\n'}enabled = true{'\n'}archive_url = "http://archiver:17665"{'\n\n'}[datasource.synthetic]{'\n'}enabled = true</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -691,7 +863,11 @@ const SECTION_COMPONENTS: Record<string, () => JSX.Element> = {
|
||||
view: SectionView,
|
||||
edit: SectionEdit,
|
||||
widgets: SectionWidgets,
|
||||
plots: SectionPlots,
|
||||
signals: SectionSignals,
|
||||
logic: SectionLogic,
|
||||
control: SectionControl,
|
||||
sharing: SectionSharing,
|
||||
history: SectionHistory,
|
||||
shortcuts: SectionShortcuts,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { h } from 'preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { getMetaStore, getSignalStore } from './lib/stores';
|
||||
import type { SignalRef, SignalMeta, SignalValue } from './lib/types';
|
||||
|
||||
interface Props {
|
||||
signal: SignalRef;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function qualityColor(q: string): string {
|
||||
switch (q) {
|
||||
case 'good': return '#4ade80';
|
||||
case 'uncertain': return '#fbbf24';
|
||||
case 'bad': return '#f87171';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
}
|
||||
|
||||
function fmt(v: any): string {
|
||||
if (v === null || v === undefined) return '—';
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v.toPrecision(6).replace(/\.?0+$/, '') : String(v);
|
||||
if (Array.isArray(v)) return `[${v.slice(0, 8).map((x: any) => fmt(x)).join(', ')}${v.length > 8 ? ', …' : ''}]`;
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function fmtTs(ts: string | null): string {
|
||||
if (!ts) return '—';
|
||||
try { return new Date(ts).toLocaleString(); } catch { return ts; }
|
||||
}
|
||||
|
||||
export default function InfoPanel({ signal, onClose }: Props) {
|
||||
const [meta, setMeta] = useState<SignalMeta | null>(null);
|
||||
const [sv, setSv] = useState<SignalValue>({ value: null, quality: 'unknown', ts: null });
|
||||
|
||||
useEffect(() => {
|
||||
const unsubM = getMetaStore(signal).subscribe(setMeta);
|
||||
const unsubV = getSignalStore(signal).subscribe(setSv);
|
||||
return () => { unsubM(); unsubV(); };
|
||||
}, [signal.ds, signal.name]);
|
||||
|
||||
return (
|
||||
<div class="info-panel">
|
||||
<div class="info-panel-header">
|
||||
<span class="info-panel-title">Signal Info</span>
|
||||
<button class="info-panel-close" onClick={onClose} title="Close">✕</button>
|
||||
</div>
|
||||
<div class="info-panel-body">
|
||||
<table class="info-table">
|
||||
<tbody>
|
||||
<tr><td class="info-key">Datasource</td><td class="info-val info-mono">{signal.ds}</td></tr>
|
||||
<tr><td class="info-key">Name</td><td class="info-val info-mono">{signal.name}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="info-sep" />
|
||||
|
||||
<table class="info-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="info-key">Value</td>
|
||||
<td class="info-val">
|
||||
<span
|
||||
class="quality-dot"
|
||||
style={`background:${qualityColor(sv.quality)};display:inline-block;margin-right:5px;vertical-align:middle;`}
|
||||
/>
|
||||
{fmt(sv.value)}{meta?.unit ? ` ${meta.unit}` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td class="info-key">Quality</td><td class="info-val">{sv.quality}</td></tr>
|
||||
<tr><td class="info-key">Timestamp</td><td class="info-val">{fmtTs(sv.ts)}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{meta && (
|
||||
<div>
|
||||
<div class="info-sep" />
|
||||
<table class="info-table">
|
||||
<tbody>
|
||||
<tr><td class="info-key">Type</td><td class="info-val">{meta.type}</td></tr>
|
||||
{meta.unit && <tr><td class="info-key">Unit</td><td class="info-val">{meta.unit}</td></tr>}
|
||||
<tr><td class="info-key">Writable</td><td class="info-val">{meta.writable ? 'Yes' : 'No'}</td></tr>
|
||||
{(meta.displayLow !== 0 || meta.displayHigh !== 0) && (
|
||||
<tr>
|
||||
<td class="info-key">Display range</td>
|
||||
<td class="info-val">{meta.displayLow} … {meta.displayHigh}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{meta.tags && meta.tags.length > 0 && (
|
||||
<div>
|
||||
<div class="info-sep" />
|
||||
<div class="info-section-hdr">Tags</div>
|
||||
<div style="display: flex; gap: 4px; flex-wrap: wrap; padding: 4px 0.75rem;">
|
||||
{meta.tags.map(t => (
|
||||
<span key={t} style="font-size: 0.7rem; background: #1e293b; color: #94a3b8; padding: 1px 6px; border-radius: 4px; border: 1px solid #334155;">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta.properties && Object.keys(meta.properties).length > 0 && (
|
||||
<div>
|
||||
<div class="info-sep" />
|
||||
<div class="info-section-hdr">Properties</div>
|
||||
<table class="info-table">
|
||||
<tbody>
|
||||
{Object.entries(meta.properties).map(([k, v]) => (
|
||||
<tr key={k}>
|
||||
<td class="info-key">{k}</td>
|
||||
<td class="info-val">{v}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta.enumStrings && meta.enumStrings.length > 0 && (
|
||||
<div>
|
||||
<div class="info-sep" />
|
||||
<div class="info-section-hdr">States</div>
|
||||
<table class="info-table">
|
||||
<tbody>
|
||||
{meta.enumStrings.map((s, i) => (
|
||||
<tr key={i}>
|
||||
<td class="info-key">{i}</td>
|
||||
<td class="info-val info-mono">{s}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta.description && (
|
||||
<div>
|
||||
<div class="info-sep" />
|
||||
<div class="info-section-hdr">Description</div>
|
||||
<div class="info-desc">{meta.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+217
-43
@@ -1,36 +1,110 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||
import type { Interface } from './lib/types';
|
||||
|
||||
interface ServerInterface {
|
||||
id: string;
|
||||
name: string;
|
||||
version: number;
|
||||
}
|
||||
import { useAuth, canWrite } from './lib/auth';
|
||||
import type { Interface, InterfaceListItem, Folder } from './lib/types';
|
||||
import ShareDialog from './ShareDialog';
|
||||
|
||||
interface Props {
|
||||
onLoad: (xml: string) => void;
|
||||
onSelect?: (id: string) => void;
|
||||
onEdit?: (iface?: Interface) => void;
|
||||
onNewPlot?: () => void;
|
||||
onEditId?: (id: string) => void;
|
||||
width?: number;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
}
|
||||
|
||||
export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId }: Props) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [interfaces, setInterfaces] = useState<ServerInterface[]>([]);
|
||||
export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onEditId, width, collapsed, onToggleCollapse }: Props) {
|
||||
const [interfaces, setInterfaces] = useState<InterfaceListItem[]>([]);
|
||||
const [folders, setFolders] = useState<Folder[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [share, setShare] = useState<{ id: string; name: string } | null>(null);
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const dragId = useRef<string | null>(null);
|
||||
const me = useAuth();
|
||||
const writable = canWrite(me.level);
|
||||
|
||||
// Panels in a folder ('' = root), sorted by their stored order then name.
|
||||
function panelsIn(folder: string): InterfaceListItem[] {
|
||||
return interfaces
|
||||
.filter(i => (i.folder || '') === folder)
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || (a.name || a.id).localeCompare(b.name || b.id));
|
||||
}
|
||||
|
||||
// Persist a new ordering for a folder's panels (also moves panels between folders).
|
||||
async function reorder(folder: string, ids: string[]) {
|
||||
const res = await fetch('/api/v1/interfaces/reorder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ folder, ids }),
|
||||
});
|
||||
if (res.ok) refresh();
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
dragId.current = null;
|
||||
setDropTarget(null);
|
||||
}
|
||||
|
||||
// Drop dragged panel just before `target` within target's folder.
|
||||
function dropOnPanel(target: InterfaceListItem, e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const id = dragId.current;
|
||||
if (!id || id === target.id) { endDrag(); return; }
|
||||
const dest = target.folder || '';
|
||||
const ids = panelsIn(dest).map(p => p.id).filter(pid => pid !== id);
|
||||
const idx = ids.indexOf(target.id);
|
||||
ids.splice(idx < 0 ? ids.length : idx, 0, id);
|
||||
reorder(dest, ids);
|
||||
endDrag();
|
||||
}
|
||||
|
||||
// Drop dragged panel at the end of `folder` ('' = root).
|
||||
function dropInFolder(folder: string, e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const id = dragId.current;
|
||||
if (!id) { endDrag(); return; }
|
||||
const ids = panelsIn(folder).map(p => p.id).filter(pid => pid !== id);
|
||||
ids.push(id);
|
||||
reorder(folder, ids);
|
||||
endDrag();
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
setLoading(true);
|
||||
fetch('/api/v1/interfaces')
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then(data => setInterfaces(data))
|
||||
Promise.all([
|
||||
fetch('/api/v1/interfaces').then(r => r.ok ? r.json() : []),
|
||||
fetch('/api/v1/folders').then(r => r.ok ? r.json() : []),
|
||||
])
|
||||
.then(([ifaceData, folderData]) => {
|
||||
const list = Array.isArray(ifaceData) ? ifaceData : [];
|
||||
const normalized: InterfaceListItem[] = list.map((item: any) => ({
|
||||
id: String(item.id || item.ID || ''),
|
||||
name: String(item.name || item.Name || ''),
|
||||
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);
|
||||
setFolders(Array.isArray(folderData) ? folderData : []);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => { refresh(); }, []);
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
const handleRefresh = () => refresh();
|
||||
window.addEventListener('uopi:refresh-interfaces', handleRefresh);
|
||||
return () => window.removeEventListener('uopi:refresh-interfaces', handleRefresh);
|
||||
}, []);
|
||||
|
||||
function triggerImport() {
|
||||
fileInputRef.current?.click();
|
||||
@@ -65,13 +139,105 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId }: Pr
|
||||
window.open(`?fs=${encodeURIComponent(id)}`, '_blank');
|
||||
}
|
||||
|
||||
async function handleNewFolder(parent: string) {
|
||||
const name = prompt('New folder name:');
|
||||
if (!name || !name.trim()) return;
|
||||
const res = await fetch('/api/v1/folders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name.trim(), parent }),
|
||||
});
|
||||
if (res.ok) refresh();
|
||||
}
|
||||
|
||||
async function handleDeleteFolder(id: string, name: string) {
|
||||
if (!confirm(`Delete folder "${name}"? Its panels and subfolders move up one level.`)) return;
|
||||
const res = await fetch(`/api/v1/folders/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
if (res.ok) refresh();
|
||||
}
|
||||
|
||||
// The caller may manage sharing when they can write and either own the panel
|
||||
// or it is unmanaged (no owner recorded yet).
|
||||
function canShare(item: InterfaceListItem): boolean {
|
||||
return writable && (!item.owner || item.owner === me.user);
|
||||
}
|
||||
|
||||
function toggle(id: string) {
|
||||
setExpanded(prev => ({ ...prev, [id]: !prev[id] }));
|
||||
}
|
||||
|
||||
function renderPanel(item: InterfaceListItem) {
|
||||
const drag = writable && item.perm === 'write';
|
||||
return (
|
||||
<li
|
||||
key={item.id}
|
||||
class={`iface-item${dropTarget === `p:${item.id}` ? ' drop-target' : ''}`}
|
||||
draggable={drag}
|
||||
onDragStart={drag ? (e) => { dragId.current = item.id; e.dataTransfer!.effectAllowed = 'move'; } : undefined}
|
||||
onDragEnd={endDrag}
|
||||
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.stopPropagation(); setDropTarget(`p:${item.id}`); } }}
|
||||
onDragLeave={() => setDropTarget(t => t === `p:${item.id}` ? null : t)}
|
||||
onDrop={(e) => dropOnPanel(item, e)}
|
||||
>
|
||||
<span class="iface-item-name" onClick={() => onSelect?.(item.id)} title={item.owner ? `Owner: ${item.owner}` : undefined}>
|
||||
{item.name || item.id}
|
||||
{item.perm === 'read' && <span class="iface-badge" title="Read-only">ro</span>}
|
||||
</span>
|
||||
<div class="iface-item-actions">
|
||||
{item.perm === 'write' && <button class="icon-btn" title="Edit" onClick={() => onEditId?.(item.id)}>✎</button>}
|
||||
<button class="icon-btn" title="Open fullscreen in new tab" onClick={() => handleFullscreen(item.id)}>⛶</button>
|
||||
{writable && <button class="icon-btn" title="Clone" onClick={() => handleClone(item.id)}>⎘</button>}
|
||||
{canShare(item) && <button class="icon-btn" title="Share" onClick={() => setShare({ id: item.id, name: item.name })}>⚹</button>}
|
||||
{item.perm === 'write' && <button class="icon-btn iface-delete" title="Delete" onClick={() => handleDelete(item.id, item.name)}>✕</button>}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 = panelsIn(folder.id);
|
||||
const open = expanded[folder.id] !== false; // default expanded
|
||||
const canDrop = folder.perm === 'write';
|
||||
return (
|
||||
<li key={folder.id} class="iface-folder">
|
||||
<div
|
||||
class={`iface-folder-header${dropTarget === `f:${folder.id}` ? ' drop-target' : ''}`}
|
||||
onDragOver={canDrop ? (e) => { if (dragId.current) { e.preventDefault(); e.stopPropagation(); setDropTarget(`f:${folder.id}`); } } : undefined}
|
||||
onDragLeave={() => setDropTarget(t => t === `f:${folder.id}` ? null : t)}
|
||||
onDrop={canDrop ? (e) => dropInFolder(folder.id, e) : undefined}
|
||||
>
|
||||
<span class="iface-folder-name" onClick={() => toggle(folder.id)}>
|
||||
{open ? '▾' : '▸'} {folder.name}
|
||||
</span>
|
||||
{folder.perm === 'write' && (
|
||||
<div class="iface-item-actions">
|
||||
<button class="icon-btn" title="New subfolder" onClick={() => handleNewFolder(folder.id)}>+</button>
|
||||
<button class="icon-btn iface-delete" title="Delete folder" onClick={() => handleDeleteFolder(folder.id, folder.name)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{open && (
|
||||
<ul class="iface-list iface-sublist">
|
||||
{children.map(renderFolder)}
|
||||
{panels.map(renderPanel)}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
const rootFolders = folders.filter(f => !f.parent);
|
||||
const rootPanels = panelsIn('');
|
||||
|
||||
return (
|
||||
<aside class={`panel${collapsed ? ' collapsed' : ''}`}>
|
||||
<aside class={`panel${collapsed ? ' collapsed' : ''}`} style={!collapsed && width ? `width:${width}px;min-width:${width}px;` : undefined}>
|
||||
<div class="panel-header">
|
||||
{!collapsed && <span class="panel-title">Interfaces</span>}
|
||||
<button
|
||||
class="icon-btn"
|
||||
onClick={() => setCollapsed(c => !c)}
|
||||
onClick={onToggleCollapse}
|
||||
title={collapsed ? 'Expand panel' : 'Collapse panel'}
|
||||
>
|
||||
{collapsed ? '▶' : '◀'}
|
||||
@@ -79,44 +245,52 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId }: Pr
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div>
|
||||
<div class="panel-actions">
|
||||
<button class="panel-btn" onClick={() => onEdit?.()}>+ New</button>
|
||||
<button class="panel-btn" onClick={triggerImport}>Import</button>
|
||||
<input
|
||||
type="file"
|
||||
accept=".xml,application/xml,text/xml"
|
||||
style="display:none"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{writable && (
|
||||
<div class="panel-actions">
|
||||
<button class="panel-btn" onClick={() => onEdit?.()}>+ New</button>
|
||||
<button class="panel-btn" onClick={() => onNewPlot?.()}>+ Plot</button>
|
||||
<button class="panel-btn" onClick={() => handleNewFolder('')}>+ Folder</button>
|
||||
<button class="panel-btn" onClick={triggerImport}>Import</button>
|
||||
<input
|
||||
type="file"
|
||||
accept=".xml,application/xml,text/xml"
|
||||
style="display:none"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="panel-list">
|
||||
{loading ? (
|
||||
<p class="hint">Loading…</p>
|
||||
) : interfaces.length === 0 ? (
|
||||
) : interfaces.length === 0 && folders.length === 0 ? (
|
||||
<p class="hint">No interfaces yet. Create one or import XML.</p>
|
||||
) : (
|
||||
<ul class="iface-list">
|
||||
{interfaces.map(iface => (
|
||||
<li key={iface.id} class="iface-item">
|
||||
<span class="iface-item-name" onClick={() => onSelect?.(iface.id)}>
|
||||
{iface.name || iface.id}
|
||||
</span>
|
||||
<div class="iface-item-actions">
|
||||
<button class="icon-btn" title="Edit" onClick={() => onEditId?.(iface.id)}>✎</button>
|
||||
<button class="icon-btn" title="Open fullscreen in new tab" onClick={() => handleFullscreen(iface.id)}>⛶</button>
|
||||
<button class="icon-btn" title="Clone" onClick={() => handleClone(iface.id)}>⎘</button>
|
||||
<button class="icon-btn iface-delete" title="Delete" onClick={() => handleDelete(iface.id, iface.name)}>✕</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
<ul
|
||||
class={`iface-list${dropTarget === 'f:' ? ' drop-target' : ''}`}
|
||||
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); setDropTarget('f:'); } }}
|
||||
onDragLeave={() => setDropTarget(t => t === 'f:' ? null : t)}
|
||||
onDrop={(e) => dropInFolder('', e)}
|
||||
>
|
||||
{rootFolders.map(renderFolder)}
|
||||
{rootPanels.map(renderPanel)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{share && (
|
||||
<ShareDialog
|
||||
ifaceId={share.id}
|
||||
ifaceName={share.name}
|
||||
folders={folders}
|
||||
onClose={() => setShare(null)}
|
||||
onSaved={refresh}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { logicDialogs, type DialogRequest } from './lib/logic';
|
||||
|
||||
// Renders the user-interaction dialogs requested by action.dialog.* logic nodes.
|
||||
// The engine pushes requests to the `logicDialogs` store and awaits the user's
|
||||
// response; this component shows each as a modal and calls req.resolve.
|
||||
export default function LogicDialogs() {
|
||||
const [reqs, setReqs] = useState<DialogRequest[]>([]);
|
||||
useEffect(() => logicDialogs.subscribe(setReqs), []);
|
||||
if (reqs.length === 0) return null;
|
||||
return <Fragment>{reqs.map(r => <DialogModal key={r.id} req={r} />)}</Fragment>;
|
||||
}
|
||||
|
||||
function DialogModal({ req }: { req: DialogRequest; key?: string }) {
|
||||
const isSetpoint = req.kind === 'setpoint';
|
||||
const inputIdx = req.fields
|
||||
.map((f, i) => (f.type === 'input' ? i : -1))
|
||||
.filter(i => i >= 0);
|
||||
// Per-input editable values, keyed by field index, seeded from the prefill.
|
||||
const [vals, setVals] = useState<Record<number, string>>(
|
||||
() => Object.fromEntries(inputIdx.map(i => [i, req.fields[i].value]))
|
||||
);
|
||||
|
||||
function ok() {
|
||||
req.resolve(inputIdx.map(i => vals[i] ?? ''));
|
||||
}
|
||||
function cancel() { req.resolve(null); }
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !(e.target instanceof HTMLTextAreaElement)) { e.preventDefault(); ok(); }
|
||||
if (e.key === 'Escape') { e.preventDefault(); cancel(); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="logic-dialog-backdrop" onKeyDown={onKey}>
|
||||
<div class={`logic-dialog logic-dialog-${req.kind}`} role="dialog">
|
||||
<div class="logic-dialog-header">{req.title || (req.kind === 'error' ? 'Error' : isSetpoint ? 'Set value' : 'Info')}</div>
|
||||
{req.message && <div class="logic-dialog-message">{req.message}</div>}
|
||||
{req.fields.map((f, i) => (
|
||||
<div class="logic-dialog-field" key={i}>
|
||||
{f.label && <label class="logic-dialog-label">{f.label}</label>}
|
||||
{f.type === 'input' ? (
|
||||
<input class="prop-input logic-dialog-input" type="number"
|
||||
autoFocus={i === inputIdx[0]}
|
||||
value={vals[i] ?? ''}
|
||||
onInput={(e) => setVals(v => ({ ...v, [i]: (e.target as HTMLInputElement).value }))}
|
||||
onKeyDown={onKey} />
|
||||
) : (
|
||||
<span class="logic-dialog-value">{f.value}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div class="logic-dialog-actions">
|
||||
{isSetpoint && <button class="panel-btn" onClick={cancel}>Cancel</button>}
|
||||
<button class="panel-btn panel-btn-primary" onClick={ok}>OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,137 @@
|
||||
import { h } from 'preact';
|
||||
import { useRef } from 'preact/hooks';
|
||||
|
||||
// ── Tokenizer ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type TT = 'kw' | 'str' | 'cmt' | 'num' | 'text';
|
||||
|
||||
const LUA_KEYWORDS = new Set([
|
||||
'if', 'then', 'else', 'elseif', 'end', 'while', 'do', 'for', 'in',
|
||||
'return', 'local', 'function', 'not', 'and', 'or', 'nil', 'true',
|
||||
'false', 'break', 'repeat', 'until',
|
||||
]);
|
||||
|
||||
interface Tok { t: TT; s: string; }
|
||||
|
||||
function isAlpha(c: string) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_'; }
|
||||
function isAlNum(c: string) { return isAlpha(c) || (c >= '0' && c <= '9'); }
|
||||
function isDigit(c: string) { return c >= '0' && c <= '9'; }
|
||||
|
||||
function tokenize(src: string): Tok[] {
|
||||
const out: Tok[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < src.length) {
|
||||
const c = src[i];
|
||||
|
||||
// Single-line comment
|
||||
if (c === '-' && src[i + 1] === '-') {
|
||||
const end = src.indexOf('\n', i);
|
||||
const stop = end < 0 ? src.length : end;
|
||||
out.push({ t: 'cmt', s: src.slice(i, stop) });
|
||||
i = stop;
|
||||
continue;
|
||||
}
|
||||
|
||||
// String: "..." or '...'
|
||||
if (c === '"' || c === "'") {
|
||||
let j = i + 1;
|
||||
while (j < src.length) {
|
||||
if (src[j] === '\\') { j += 2; continue; }
|
||||
if (src[j] === c) { j++; break; }
|
||||
j++;
|
||||
}
|
||||
out.push({ t: 'str', s: src.slice(i, j) });
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Long string [[...]]
|
||||
if (c === '[' && src[i + 1] === '[') {
|
||||
const end = src.indexOf(']]', i + 2);
|
||||
const stop = end < 0 ? src.length : end + 2;
|
||||
out.push({ t: 'str', s: src.slice(i, stop) });
|
||||
i = stop;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Number
|
||||
if (isDigit(c) || (c === '.' && isDigit(src[i + 1] ?? ''))) {
|
||||
let j = i;
|
||||
if (src[j] === '0' && (src[j + 1] === 'x' || src[j + 1] === 'X')) {
|
||||
j += 2;
|
||||
while (j < src.length && /[0-9a-fA-F]/.test(src[j])) j++;
|
||||
} else {
|
||||
while (j < src.length && (isDigit(src[j]) || src[j] === '.')) j++;
|
||||
if (j < src.length && (src[j] === 'e' || src[j] === 'E')) {
|
||||
j++;
|
||||
if (src[j] === '+' || src[j] === '-') j++;
|
||||
while (j < src.length && isDigit(src[j])) j++;
|
||||
}
|
||||
}
|
||||
out.push({ t: 'num', s: src.slice(i, j) });
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Identifier / keyword
|
||||
if (isAlpha(c)) {
|
||||
let j = i;
|
||||
while (j < src.length && isAlNum(src[j])) j++;
|
||||
const word = src.slice(i, j);
|
||||
out.push({ t: LUA_KEYWORDS.has(word) ? 'kw' : 'text', s: word });
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push({ t: 'text', s: c });
|
||||
i++;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderTokens(src: string) {
|
||||
return tokenize(src).map((tok, idx) =>
|
||||
tok.t === 'text'
|
||||
? <span key={idx}>{tok.s}</span>
|
||||
: <span key={idx} class={`lua-${tok.t}`}>{tok.s}</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
export default function LuaEditor({ value, onChange, rows = 12 }: Props) {
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
|
||||
function syncScroll(e: Event) {
|
||||
const ta = e.target as HTMLTextAreaElement;
|
||||
if (preRef.current) {
|
||||
preRef.current.scrollTop = ta.scrollTop;
|
||||
preRef.current.scrollLeft = ta.scrollLeft;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="lua-editor" style={`height:${rows * 1.5}em;`}>
|
||||
<pre ref={preRef} class="lua-pre" aria-hidden="true">
|
||||
{renderTokens(value)}
|
||||
{'\n'}
|
||||
</pre>
|
||||
<textarea
|
||||
class="lua-textarea"
|
||||
value={value}
|
||||
spellcheck={false}
|
||||
autocomplete="off"
|
||||
onInput={(e) => onChange((e.target as HTMLTextAreaElement).value)}
|
||||
onScroll={syncScroll}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { h } from 'preact';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import type { Interface, Widget, SignalRef, PlotLayout } from './lib/types';
|
||||
import PlotWidget from './widgets/PlotWidget';
|
||||
import SplitLayout from './SplitLayout';
|
||||
import { genWidgetId } from './EditCanvas';
|
||||
import { splitLeaf, removeLeaf, setRatioAtPath, countLeaves } from './lib/plotLayout';
|
||||
|
||||
interface Props {
|
||||
iface: Interface;
|
||||
selectedIds: string[];
|
||||
onSelect: (ids: string[]) => void;
|
||||
/** Apply a combined widgets + layout change as one undoable step. */
|
||||
onChange: (widgets: Widget[], layout: PlotLayout) => void;
|
||||
}
|
||||
|
||||
function newPlotWidget(): Widget {
|
||||
return {
|
||||
id: genWidgetId(),
|
||||
type: 'plot',
|
||||
x: 0, y: 0, w: 800, h: 500,
|
||||
signals: [],
|
||||
options: { plotType: 'timeseries', timeWindow: '60', legend: 'bottom' },
|
||||
};
|
||||
}
|
||||
|
||||
export default function PlotPanelCanvas({ iface, selectedIds, onSelect, onChange }: Props) {
|
||||
const layout = iface.layout ?? { type: 'leaf', widget: iface.widgets[0]?.id ?? '' };
|
||||
const byId = new Map(iface.widgets.map(w => [w.id, w]));
|
||||
const single = countLeaves(layout) <= 1;
|
||||
|
||||
// When there is only one pane, it is always the selected one. Also recover
|
||||
// from a selection that points at a widget no longer present in this panel.
|
||||
useEffect(() => {
|
||||
const onlyId = single ? (layout.type === 'leaf' ? layout.widget : iface.widgets[0]?.id) : null;
|
||||
if (onlyId && selectedIds[0] !== onlyId) {
|
||||
onSelect([onlyId]);
|
||||
} else if (!single && selectedIds.length === 1 && !byId.has(selectedIds[0])) {
|
||||
onSelect([]);
|
||||
}
|
||||
}, [single, layout, selectedIds.join(',')]);
|
||||
|
||||
function handleSplit(widgetId: string, dir: 'h' | 'v') {
|
||||
const w = newPlotWidget();
|
||||
const nextLayout = splitLeaf(layout, widgetId, dir, w.id);
|
||||
onChange([...iface.widgets, w], nextLayout);
|
||||
onSelect([w.id]);
|
||||
}
|
||||
|
||||
function handleClose(widgetId: string) {
|
||||
if (single) return;
|
||||
const nextLayout = removeLeaf(layout, widgetId);
|
||||
onChange(iface.widgets.filter(w => w.id !== widgetId), nextLayout);
|
||||
onSelect([]);
|
||||
}
|
||||
|
||||
function handleResize(path: number[], ratio: number) {
|
||||
onChange(iface.widgets, setRatioAtPath(layout, path, ratio));
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent, widget: Widget) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const json = e.dataTransfer?.getData('application/json');
|
||||
if (!json) return;
|
||||
let sig: SignalRef;
|
||||
try { sig = JSON.parse(json); } catch { return; }
|
||||
if (widget.signals.some(s => s.ds === sig.ds && s.name === sig.name)) return;
|
||||
const updated = { ...widget, signals: [...widget.signals, sig] };
|
||||
onChange(iface.widgets.map(w => w.id === updated.id ? updated : w), layout);
|
||||
onSelect([updated.id]);
|
||||
}
|
||||
|
||||
function renderLeaf(widgetId: string) {
|
||||
const widget = byId.get(widgetId);
|
||||
const selected = selectedIds.includes(widgetId);
|
||||
return (
|
||||
<div
|
||||
class={`plot-pane${selected ? ' plot-pane-selected' : ''}`}
|
||||
onMouseDownCapture={() => onSelect([widgetId])}
|
||||
onDragOver={(e: DragEvent) => { e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; }}
|
||||
onDrop={(e: DragEvent) => widget && handleDrop(e, widget)}
|
||||
>
|
||||
{selected && <div class="plot-pane-badge">editing</div>}
|
||||
{widget
|
||||
? <PlotWidget widget={widget} timeRange={null} />
|
||||
: <div class="plot-pane-empty">missing plot</div>}
|
||||
|
||||
<div class="plot-pane-overlay" onMouseDown={(e: MouseEvent) => e.stopPropagation()}>
|
||||
<button class="plot-pane-btn" title="Split left/right" onClick={() => handleSplit(widgetId, 'h')}>⬌</button>
|
||||
<button class="plot-pane-btn" title="Split top/bottom" onClick={() => handleSplit(widgetId, 'v')}>⬍</button>
|
||||
<button class="plot-pane-btn" title="Remove this plot" disabled={single} onClick={() => handleClose(widgetId)}>✕</button>
|
||||
</div>
|
||||
|
||||
{widget && widget.signals.length === 0 && (
|
||||
<div class="plot-pane-hint">Drag a signal here, then configure it in the properties pane →</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="plot-panel-edit">
|
||||
<SplitLayout layout={layout} renderLeaf={renderLeaf} onResize={handleResize} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+89
-39
@@ -1,5 +1,5 @@
|
||||
import { h } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import type { Widget, Interface } from './lib/types';
|
||||
|
||||
interface Props {
|
||||
@@ -8,6 +8,7 @@ interface Props {
|
||||
iface: Interface;
|
||||
onChange: (updated: Widget) => void;
|
||||
onIfaceChange: (updated: Interface) => void;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: any }) {
|
||||
@@ -20,29 +21,39 @@ function Field({ label, children }: { label: string; children: any }) {
|
||||
}
|
||||
|
||||
function TextInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) {
|
||||
const [local, setLocal] = useState(value);
|
||||
const [local, setLocal] = useState(value || '');
|
||||
|
||||
// Sync when external value changes (e.g. different widget selected)
|
||||
if (local !== value && document.activeElement?.tagName !== 'INPUT') {
|
||||
setLocal(value);
|
||||
}
|
||||
useEffect(() => {
|
||||
setLocal(value || '');
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<input
|
||||
class="prop-input"
|
||||
value={local}
|
||||
onInput={(e) => setLocal((e.target as HTMLInputElement).value)}
|
||||
onChange={(e) => onCommit((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
onCommit((e.target as HTMLInputElement).value);
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Widgets that show units from signal metadata (can be overridden)
|
||||
const UNIT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue']);
|
||||
// Widgets where the user can set a numeric format string
|
||||
const FORMAT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv']);
|
||||
// Widgets with a signal-based label (can be overridden)
|
||||
const LABEL_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue', 'led', 'multiled']);
|
||||
// Widgets with multiple signals
|
||||
const MULTI_SIGNAL_WIDGETS = new Set(['plot', 'multiled']);
|
||||
|
||||
export default function PropertiesPane({ selected, multiCount, iface, onChange, onIfaceChange }: Props) {
|
||||
export default function PropertiesPane({ selected, multiCount, iface, onChange, onIfaceChange, width }: Props) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
function setOpt(key: string, value: string) {
|
||||
@@ -64,7 +75,7 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
|
||||
const w = selected;
|
||||
|
||||
return (
|
||||
<aside class={`panel props-panel${collapsed ? ' collapsed' : ''}`} style="border-left:1px solid #2d3748;border-right:none;">
|
||||
<aside class={`panel props-panel${collapsed ? ' collapsed' : ''}`} style={`border-left:1px solid #2d3748;border-right:none;${!collapsed && width ? `width:${width}px;min-width:${width}px;` : ''}`}>
|
||||
<div class="panel-header">
|
||||
{!collapsed && <span class="panel-title">Properties</span>}
|
||||
<button class="icon-btn" onClick={() => setCollapsed(c => !c)} title={collapsed ? 'Expand' : 'Collapse'}>
|
||||
@@ -76,35 +87,41 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
|
||||
<div class="props-body">
|
||||
{/* Canvas-level properties */}
|
||||
<div class="props-section">
|
||||
<div class="props-section-title">Canvas</div>
|
||||
<div class="props-section-title">{iface.kind === 'plot' ? 'Plot panel' : 'Canvas'}</div>
|
||||
<Field label="Name">
|
||||
<TextInput
|
||||
value={iface.name}
|
||||
onCommit={(v) => onIfaceChange({ ...iface, name: v })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Width">
|
||||
<input
|
||||
class="prop-input prop-input-num"
|
||||
type="number"
|
||||
value={iface.w}
|
||||
min={100}
|
||||
onChange={(e) => onIfaceChange({ ...iface, w: parseInt((e.target as HTMLInputElement).value, 10) || iface.w })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Height">
|
||||
<input
|
||||
class="prop-input prop-input-num"
|
||||
type="number"
|
||||
value={iface.h}
|
||||
min={100}
|
||||
onChange={(e) => onIfaceChange({ ...iface, h: parseInt((e.target as HTMLInputElement).value, 10) || iface.h })}
|
||||
/>
|
||||
</Field>
|
||||
{/* Plot panels fill the viewport via the split layout — fixed px size
|
||||
is not meaningful, so width/height are hidden. */}
|
||||
{iface.kind !== 'plot' && (
|
||||
<div>
|
||||
<Field label="Width">
|
||||
<input
|
||||
class="prop-input prop-input-num"
|
||||
type="number"
|
||||
value={iface.w}
|
||||
min={100}
|
||||
onChange={(e) => onIfaceChange({ ...iface, w: parseInt((e.target as HTMLInputElement).value, 10) || iface.w })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Height">
|
||||
<input
|
||||
class="prop-input prop-input-num"
|
||||
type="number"
|
||||
value={iface.h}
|
||||
min={100}
|
||||
onChange={(e) => onIfaceChange({ ...iface, h: parseInt((e.target as HTMLInputElement).value, 10) || iface.h })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{w && (
|
||||
<div class="props-section">
|
||||
<div class="props-section" key={w.id}>
|
||||
<div class="props-section-title">{w.type}</div>
|
||||
|
||||
{/* Position / size */}
|
||||
@@ -154,24 +171,34 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
|
||||
signal widgets use it as header label */}
|
||||
{w.type !== 'image' && w.type !== 'link' && (
|
||||
<Field label={LABEL_WIDGETS.has(w.type) ? 'Label override' : 'Label'}>
|
||||
<TextInput
|
||||
value={w.options['label'] ?? ''}
|
||||
onCommit={(v) => setOpt('label', v)}
|
||||
/>
|
||||
{LABEL_WIDGETS.has(w.type) && (
|
||||
<span class="prop-hint">Empty = signal name</span>
|
||||
{LABEL_WIDGETS.has(w.type) ? (
|
||||
<div class="prop-field-col">
|
||||
<TextInput value={w.options['label'] ?? ''} onCommit={(v) => setOpt('label', v)} />
|
||||
<span class="prop-hint">Empty = signal name</span>
|
||||
</div>
|
||||
) : (
|
||||
<TextInput value={w.options['label'] ?? ''} onCommit={(v) => setOpt('label', v)} />
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Unit override for signal widgets */}
|
||||
{UNIT_WIDGETS.has(w.type) && (
|
||||
<Field label="Unit override">
|
||||
<TextInput
|
||||
value={w.options['unit'] ?? ''}
|
||||
onCommit={(v) => setOpt('unit', v)}
|
||||
/>
|
||||
<span class="prop-hint">Empty = from metadata · "none" = hide</span>
|
||||
<Field label="Unit">
|
||||
<div class="prop-field-col">
|
||||
<TextInput value={w.options['unit'] ?? ''} onCommit={(v) => setOpt('unit', v)} />
|
||||
<span class="prop-hint">Empty = from meta · "none" = hide</span>
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Format string for numeric display */}
|
||||
{FORMAT_WIDGETS.has(w.type) && (
|
||||
<Field label="Format">
|
||||
<div class="prop-field-col">
|
||||
<TextInput value={w.options['format'] ?? ''} onCommit={(v) => setOpt('format', v)} />
|
||||
<span class="prop-hint">3f · 2e · 4g · empty=auto</span>
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
@@ -240,6 +267,23 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
|
||||
<option value="true">Yes</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Action">
|
||||
<div class="prop-field-col">
|
||||
<select
|
||||
class="prop-input"
|
||||
value={w.options['action'] ?? ''}
|
||||
onChange={(e) => setOpt('action', (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="">(none)</option>
|
||||
{(iface.logic?.nodes ?? [])
|
||||
.filter(n => n.kind === 'trigger.button')
|
||||
.map(n => (
|
||||
<option key={n.id} value={n.params.name ?? ''}>{n.params.name || '(unnamed)'}</option>
|
||||
))}
|
||||
</select>
|
||||
<span class="prop-hint">Fires a Button trigger node (define flows in the Logic tab)</span>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -271,6 +315,12 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
|
||||
<Field label="Y max">
|
||||
<TextInput value={w.options['yMax'] ?? 'auto'} onCommit={(v) => setOpt('yMax', v)} />
|
||||
</Field>
|
||||
<Field label="Y format">
|
||||
<div class="prop-field-col">
|
||||
<TextInput value={w.options['format'] ?? ''} onCommit={(v) => setOpt('format', v)} />
|
||||
<span class="prop-hint">3f · 2e · 4g · empty=auto</span>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Legend">
|
||||
<select class="prop-select" value={w.options['legend'] ?? 'bottom'}
|
||||
onChange={(e) => setOpt('legend', (e.target as HTMLSelectElement).value)}>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useMemo } from 'preact/hooks';
|
||||
|
||||
// A dropdown with an inline search box. Extracted from SyntheticWizard so the
|
||||
// same searchable picker can be reused (e.g. the logic editor's signal field).
|
||||
export default function SearchableSelect({
|
||||
value,
|
||||
options,
|
||||
onSelect,
|
||||
placeholder = 'Select…',
|
||||
}: {
|
||||
value: string;
|
||||
options: string[];
|
||||
onSelect: (val: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const f = filter.toLowerCase();
|
||||
return options.filter(o => o.toLowerCase().includes(f));
|
||||
}, [options, filter]);
|
||||
|
||||
return (
|
||||
<div class="search-select">
|
||||
<div class="search-select-trigger" onClick={() => setOpen(!open)}>
|
||||
{value || <span class="search-select-placeholder">{placeholder}</span>}
|
||||
<span class="search-select-arrow">{open ? '▴' : '▾'}</span>
|
||||
</div>
|
||||
{open && (
|
||||
<div class="search-select-dropdown">
|
||||
<input
|
||||
class="prop-input"
|
||||
autoFocus
|
||||
placeholder="Search…"
|
||||
value={filter}
|
||||
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div class="search-select-list">
|
||||
{filtered.length === 0 && <div class="search-select-item hint">No matches</div>}
|
||||
{filtered.map(opt => (
|
||||
<div
|
||||
key={opt}
|
||||
class={`search-select-item${opt === value ? ' search-select-item-selected' : ''}`}
|
||||
onClick={() => { onSelect(opt); setOpen(false); setFilter(''); }}
|
||||
>
|
||||
{opt}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{open && <div class="search-select-backdrop" onClick={() => setOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import type { PanelACL, Grant, Folder } from './lib/types';
|
||||
|
||||
interface Props {
|
||||
ifaceId: string;
|
||||
ifaceName: string;
|
||||
folders: Folder[];
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
// ShareDialog edits a single panel's sharing settings: its folder, public
|
||||
// visibility, and explicit per-user / per-group grants. Only the owner can
|
||||
// reach it (the caller is gated upstream in InterfaceList).
|
||||
export default function ShareDialog({ ifaceId, ifaceName, folders, onClose, onSaved }: Props) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [owner, setOwner] = useState('');
|
||||
const [folder, setFolder] = useState('');
|
||||
const [pub, setPub] = useState<'' | 'read' | 'write'>('');
|
||||
const [grants, setGrants] = useState<Grant[]>([]);
|
||||
const [userGroups, setUserGroups] = useState<string[]>([]);
|
||||
|
||||
// New-grant row state.
|
||||
const [newKind, setNewKind] = useState<'user' | 'group'>('user');
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newPerm, setNewPerm] = useState<'read' | 'write'>('read');
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch(`/api/v1/interfaces/${encodeURIComponent(ifaceId)}/acl`).then(r => r.ok ? r.json() : null),
|
||||
fetch('/api/v1/usergroups').then(r => r.ok ? r.json() : []),
|
||||
])
|
||||
.then(([acl, groups]: [PanelACL | null, string[]]) => {
|
||||
if (acl) {
|
||||
setOwner(acl.owner || '');
|
||||
setFolder(acl.folder || '');
|
||||
setPub(acl.public || '');
|
||||
setGrants(Array.isArray(acl.grants) ? acl.grants : []);
|
||||
}
|
||||
setUserGroups(Array.isArray(groups) ? groups : []);
|
||||
})
|
||||
.catch(() => setError('Failed to load sharing settings.'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [ifaceId]);
|
||||
|
||||
function addGrant() {
|
||||
const name = newName.trim();
|
||||
if (!name) return;
|
||||
// Replace an existing grant for the same kind+name rather than duplicating.
|
||||
const next = grants.filter(g => !(g.kind === newKind && g.name === name));
|
||||
next.push({ kind: newKind, name, perm: newPerm });
|
||||
setGrants(next);
|
||||
setNewName('');
|
||||
}
|
||||
|
||||
function removeGrant(idx: number) {
|
||||
setGrants(grants.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(ifaceId)}/acl`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ folder, public: pub, grants }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const msg = await res.json().catch(() => null);
|
||||
throw new Error(msg?.error || `Save failed (${res.status})`);
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="wizard-backdrop" onClick={onClose}>
|
||||
<div class="wizard" onClick={e => e.stopPropagation()} style="max-width: 560px;">
|
||||
<div class="wizard-header">
|
||||
<span>Share — {ifaceName || ifaceId}</span>
|
||||
<button class="icon-btn" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div class="wizard-body">
|
||||
{loading ? (
|
||||
<p class="hint">Loading…</p>
|
||||
) : (
|
||||
<div>
|
||||
{owner && (
|
||||
<p class="hint" style="padding: 0 0 0.75rem 0;">Owner: <strong>{owner}</strong></p>
|
||||
)}
|
||||
|
||||
<div class="wizard-section-title">Folder</div>
|
||||
<div class="wizard-field">
|
||||
<select class="prop-select" value={folder} onChange={e => setFolder((e.target as HTMLSelectElement).value)}>
|
||||
<option value="">(root — no folder)</option>
|
||||
{folders.map(f => (
|
||||
<option key={f.id} value={f.id}>{f.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="wizard-section-title" style="margin-top: 1rem;">Public access</div>
|
||||
<div class="wizard-field">
|
||||
<select class="prop-select" value={pub} onChange={e => setPub((e.target as HTMLSelectElement).value as any)}>
|
||||
<option value="">Private (only owner & shares)</option>
|
||||
<option value="read">Everyone can view</option>
|
||||
<option value="write">Everyone can edit</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="wizard-section-title" style="margin-top: 1rem;">Shared with</div>
|
||||
{grants.length === 0 ? (
|
||||
<p class="hint" style="padding: 0 0 0.5rem 0;">Not shared with anyone yet.</p>
|
||||
) : (
|
||||
<ul class="iface-list" style="margin-bottom: 0.5rem;">
|
||||
{grants.map((g, idx) => (
|
||||
<li key={`${g.kind}:${g.name}`} class="iface-item" style="cursor: default;">
|
||||
<span>
|
||||
<span style="font-size: 0.65rem; background: #1e293b; color: #94a3b8; padding: 0 4px; border-radius: 3px; border: 1px solid #334155; margin-right: 6px;">
|
||||
{g.kind}
|
||||
</span>
|
||||
<span style="color: #e2e8f0;">{g.name}</span>
|
||||
<span style="color: #64748b; margin-left: 6px;">({g.perm})</span>
|
||||
</span>
|
||||
<button class="icon-btn iface-delete" title="Remove" onClick={() => removeGrant(idx)}>✕</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div class="wizard-field wizard-field-row" style="gap: 0.5rem; align-items: center;">
|
||||
<select class="prop-select" style="flex: 0 0 80px;" value={newKind} onChange={e => { setNewKind((e.target as HTMLSelectElement).value as any); setNewName(''); }}>
|
||||
<option value="user">User</option>
|
||||
<option value="group">Group</option>
|
||||
</select>
|
||||
{newKind === 'group' ? (
|
||||
<select class="prop-select" style="flex: 1;" value={newName} onChange={e => setNewName((e.target as HTMLSelectElement).value)}>
|
||||
<option value="">Select group…</option>
|
||||
{userGroups.map(g => <option key={g} value={g}>{g}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
class="prop-input"
|
||||
style="flex: 1;"
|
||||
placeholder="username"
|
||||
value={newName}
|
||||
onInput={e => setNewName((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={e => e.key === 'Enter' && addGrant()}
|
||||
/>
|
||||
)}
|
||||
<select class="prop-select" style="flex: 0 0 80px;" value={newPerm} onChange={e => setNewPerm((e.target as HTMLSelectElement).value as any)}>
|
||||
<option value="read">read</option>
|
||||
<option value="write">write</option>
|
||||
</select>
|
||||
<button class="panel-btn" onClick={addGrant} disabled={!newName.trim()}>Add</button>
|
||||
</div>
|
||||
|
||||
{error && <p class="wizard-error" style="margin-top: 0.75rem;">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="wizard-footer">
|
||||
<button class="toolbar-btn" onClick={onClose}>Cancel</button>
|
||||
<button class="toolbar-btn toolbar-btn-primary" onClick={save} disabled={saving || loading}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+418
-198
@@ -1,7 +1,13 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||
import type { SignalRef } from './lib/types';
|
||||
import { h, Fragment } from 'preact';
|
||||
import { useState, useEffect, useRef, useMemo } from 'preact/hooks';
|
||||
import type { SignalRef, StateVar } from './lib/types';
|
||||
import SyntheticWizard from './SyntheticWizard';
|
||||
import SyntheticGraphEditor from './SyntheticGraphEditor';
|
||||
import GroupsTree from './GroupsTree';
|
||||
import ChannelFinderModal from './ChannelFinderModal';
|
||||
|
||||
type TreeTab = 'sources' | 'groups';
|
||||
type GroupBy = 'datasource' | 'area' | 'system' | 'ioc';
|
||||
|
||||
interface SignalInfo {
|
||||
name: string;
|
||||
@@ -9,14 +15,17 @@ interface SignalInfo {
|
||||
unit?: string;
|
||||
description?: string;
|
||||
writable?: boolean;
|
||||
properties?: Record<string, string>;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
interface DsGroup {
|
||||
ds: string;
|
||||
signals: SignalInfo[];
|
||||
interface DisplayGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
signals: Array<SignalInfo & { ds: string }>;
|
||||
expanded: boolean;
|
||||
addingSignal: boolean; // show add-PV input
|
||||
addValue: string;
|
||||
addingSignal?: boolean;
|
||||
addValue?: string;
|
||||
}
|
||||
|
||||
// Custom signals persisted in localStorage
|
||||
@@ -36,41 +45,65 @@ function saveCustom(signals: Array<{ ds: string; name: string }>) {
|
||||
|
||||
interface Props {
|
||||
onDragStart?: (sig: SignalRef) => void;
|
||||
width?: number;
|
||||
// Current interface id — scopes panel-visibility synthetic signals.
|
||||
panelId?: string;
|
||||
// Panel-local state variables and a setter (edit mode only).
|
||||
statevars?: StateVar[];
|
||||
onStateVarsChange?: (vars: StateVar[]) => void;
|
||||
}
|
||||
|
||||
export default function SignalTree({ onDragStart }: Props) {
|
||||
export default function SignalTree({ onDragStart, width, panelId, statevars, onStateVarsChange }: Props) {
|
||||
const [treeTab, setTreeTab] = useState<TreeTab>('sources');
|
||||
const [groupBy, setGroupBy] = useState<GroupBy>('datasource');
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [groups, setGroups] = useState<DsGroup[]>([]);
|
||||
const [rawSignals, setRawSignals] = useState<Array<SignalInfo & { ds: string }>>([]);
|
||||
const [customSignals, setCustomSignals] = useState<Array<{ ds: string; name: string }>>(loadCustom);
|
||||
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
|
||||
const [filter, setFilter] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [cfAvailable, setCfAvailable] = useState(false);
|
||||
const [cfSearching, setCfSearching] = useState(false);
|
||||
const [showWizard, setShowWizard] = useState(false);
|
||||
const [showCfModal, setShowCfModal] = useState(false);
|
||||
const [showManualAdd, setShowManualAdd] = useState(false);
|
||||
const [manualDs, setManualDs] = useState('epics');
|
||||
const [manualName, setManualName] = useState('');
|
||||
const [addingTo, setAddingTo] = useState<string | null>(null);
|
||||
const [addValue, setAddValue] = useState('');
|
||||
const [editSynthetic, setEditSynthetic] = useState<string | null>(null);
|
||||
const csvRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function loadGroups() {
|
||||
async function loadAllSignals() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/v1/datasources');
|
||||
if (!res.ok) return;
|
||||
const dsList: { name: string }[] = await res.json();
|
||||
const loaded: DsGroup[] = await Promise.all(
|
||||
|
||||
const all: Array<SignalInfo & { ds: string }> = [];
|
||||
await Promise.all(
|
||||
dsList.map(async ({ name }) => {
|
||||
try {
|
||||
const r = await fetch(`/api/v1/signals?ds=${encodeURIComponent(name)}`);
|
||||
const sigs: SignalInfo[] = r.ok ? await r.json() : [];
|
||||
return { ds: name, signals: sigs, expanded: true, addingSignal: false, addValue: '' };
|
||||
} catch {
|
||||
return { ds: name, signals: [], expanded: true, addingSignal: false, addValue: '' };
|
||||
}
|
||||
// Synthetic signals are filtered server-side by panel scope.
|
||||
const url = name === 'synthetic' && panelId
|
||||
? `/api/v1/signals?ds=synthetic&panel=${encodeURIComponent(panelId)}`
|
||||
: `/api/v1/signals?ds=${encodeURIComponent(name)}`;
|
||||
const r = await fetch(url);
|
||||
if (r.ok) {
|
||||
const data = await r.json();
|
||||
const sigs = Array.isArray(data) ? data : (data && typeof data === 'object' && Array.isArray(data.signals) ? data.signals : []);
|
||||
all.push(...sigs.map(s => ({ ...s, ds: name })));
|
||||
}
|
||||
} catch (e) { console.error(`Failed to load ${name}:`, e); }
|
||||
})
|
||||
);
|
||||
setGroups(loaded);
|
||||
setRawSignals(all);
|
||||
|
||||
// Check if Channel Finder is available
|
||||
const cf = await fetch('/api/v1/channel-finder?q=').catch(() => null);
|
||||
setCfAvailable(cf?.status === 200);
|
||||
const cf = await fetch('/api/v1/channel-finder?q=')
|
||||
.then(r => (r.ok ? r.json() : null))
|
||||
.catch(() => null);
|
||||
setCfAvailable(!!cf?.available);
|
||||
} catch (e) {
|
||||
console.error('Failed to load signals:', e);
|
||||
} finally {
|
||||
@@ -78,53 +111,100 @@ export default function SignalTree({ onDragStart }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { loadGroups(); }, []);
|
||||
useEffect(() => { loadAllSignals(); }, [panelId]);
|
||||
|
||||
// Merge custom signals into groups
|
||||
const allGroups = groups.map(g => {
|
||||
const extra = customSignals.filter(c => c.ds === g.ds);
|
||||
const existing = new Set(g.signals.map(s => s.name));
|
||||
const merged = [
|
||||
...g.signals,
|
||||
...extra.filter(c => !existing.has(c.name)).map(c => ({ name: c.name, type: 'custom' } as SignalInfo)),
|
||||
];
|
||||
return { ...g, signals: merged };
|
||||
});
|
||||
// ── Local state variables (edit mode) ──────────────────────────────────────
|
||||
const [showAddLocal, setShowAddLocal] = useState(false);
|
||||
const [localOpen, setLocalOpen] = useState(true);
|
||||
const [lvName, setLvName] = useState('');
|
||||
const [lvType, setLvType] = useState<'number' | 'bool' | 'string'>('number');
|
||||
const [lvInitial, setLvInitial] = useState('0');
|
||||
|
||||
// Custom signals with no matching DS group get their own group
|
||||
const knownDs = new Set(groups.map(g => g.ds));
|
||||
const orphanCustom = customSignals.filter(c => !knownDs.has(c.ds));
|
||||
const orphanGroups: DsGroup[] = Object.entries(
|
||||
orphanCustom.reduce<Record<string, string[]>>((acc, { ds, name }) => {
|
||||
(acc[ds] ??= []).push(name);
|
||||
return acc;
|
||||
}, {})
|
||||
).map(([ds, names]) => ({
|
||||
ds, signals: names.map(name => ({ name, type: 'custom' } as SignalInfo)),
|
||||
expanded: true, addingSignal: false, addValue: '',
|
||||
}));
|
||||
|
||||
const visibleGroups = [...allGroups, ...orphanGroups];
|
||||
|
||||
function toggleGroup(ds: string) {
|
||||
setGroups(gs => gs.map(g => g.ds === ds ? { ...g, expanded: !g.expanded } : g));
|
||||
function addLocal() {
|
||||
const name = lvName.trim();
|
||||
if (!name || !onStateVarsChange) return;
|
||||
const next = (statevars ?? []).filter(v => v.name !== name);
|
||||
next.push({ name, type: lvType, initial: lvInitial });
|
||||
onStateVarsChange(next);
|
||||
setLvName('');
|
||||
setLvInitial('0');
|
||||
setShowAddLocal(false);
|
||||
}
|
||||
|
||||
function setAdding(ds: string, val: boolean) {
|
||||
setGroups(gs => gs.map(g => g.ds === ds ? { ...g, addingSignal: val, addValue: '' } : g));
|
||||
function removeLocal(name: string) {
|
||||
if (!onStateVarsChange) return;
|
||||
onStateVarsChange((statevars ?? []).filter(v => v.name !== name));
|
||||
}
|
||||
|
||||
function setAddValue(ds: string, val: string) {
|
||||
setGroups(gs => gs.map(g => g.ds === ds ? { ...g, addValue: val } : g));
|
||||
function makeLocalDraggable(name: string) {
|
||||
return {
|
||||
draggable: true as const,
|
||||
onDragStart: (e: DragEvent) => {
|
||||
const ref: SignalRef = { ds: 'local', name };
|
||||
e.dataTransfer?.setData('application/json', JSON.stringify(ref));
|
||||
onDragStart?.(ref);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function commitAdd(ds: string, name: string) {
|
||||
name = name.trim();
|
||||
if (!name) { setAdding(ds, false); return; }
|
||||
const next = [...customSignals, { ds, name }];
|
||||
// Merge custom signals that might not be in ListSignals yet
|
||||
const allSignals = useMemo(() => {
|
||||
const existing = new Set(rawSignals.map(s => `${s.ds}\0${s.name}`));
|
||||
const extra = customSignals
|
||||
.filter(c => !existing.has(`${c.ds}\0${c.name}`))
|
||||
.map(c => ({ name: c.name, ds: c.ds, type: 'custom' } as SignalInfo & { ds: string }));
|
||||
return [...rawSignals, ...extra];
|
||||
}, [rawSignals, customSignals]);
|
||||
|
||||
// Group signals based on selected criteria
|
||||
const visibleGroups = useMemo(() => {
|
||||
const lf = filter.toLowerCase();
|
||||
const filtered = allSignals.filter(s =>
|
||||
s.name.toLowerCase().includes(lf) ||
|
||||
(s.description ?? '').toLowerCase().includes(lf)
|
||||
);
|
||||
|
||||
const groups: Record<string, Array<SignalInfo & { ds: string }>> = {};
|
||||
|
||||
filtered.forEach(s => {
|
||||
let key = 'unknown';
|
||||
if (groupBy === 'datasource') {
|
||||
key = s.ds;
|
||||
} else if (groupBy === 'area') {
|
||||
key = s.properties?.area || 'no-area';
|
||||
} else if (groupBy === 'system') {
|
||||
key = s.properties?.system || 'no-system';
|
||||
} else if (groupBy === 'ioc') {
|
||||
key = s.properties?.iocName || 'no-ioc';
|
||||
}
|
||||
(groups[key] ??= []).push(s);
|
||||
});
|
||||
|
||||
return Object.entries(groups).map(([id, signals]) => ({
|
||||
id,
|
||||
label: id,
|
||||
signals: signals.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
expanded: expandedGroups[id] ?? true,
|
||||
})).sort((a, b) => a.label.localeCompare(b.label));
|
||||
}, [allSignals, groupBy, expandedGroups, filter]);
|
||||
|
||||
function toggleGroup(id: string) {
|
||||
setExpandedGroups(prev => ({ ...prev, [id]: !(prev[id] ?? true) }));
|
||||
}
|
||||
|
||||
function startAdding(ds: string) {
|
||||
setAddingTo(ds);
|
||||
setAddValue('');
|
||||
setExpandedGroups(prev => ({ ...prev, [ds]: true }));
|
||||
}
|
||||
|
||||
function commitAddManually() {
|
||||
const name = addValue.trim();
|
||||
if (!name || !addingTo) { setAddingTo(null); return; }
|
||||
const next = [...customSignals, { ds: addingTo, name }];
|
||||
setCustomSignals(next);
|
||||
saveCustom(next);
|
||||
setAdding(ds, false);
|
||||
setAddingTo(null);
|
||||
}
|
||||
|
||||
function removeCustom(ds: string, name: string) {
|
||||
@@ -133,26 +213,21 @@ export default function SignalTree({ onDragStart }: Props) {
|
||||
saveCustom(next);
|
||||
}
|
||||
|
||||
// ── Channel Finder search ────────────────────────────────────────────────
|
||||
function handleAddCfSignals(refs: SignalRef[]) {
|
||||
const existing = new Set(customSignals.map(c => `${c.ds}\0${c.name}`));
|
||||
const fresh = refs.filter(r => !existing.has(`${r.ds}\0${r.name}`));
|
||||
if (fresh.length === 0) return;
|
||||
const next = [...customSignals, ...fresh];
|
||||
setCustomSignals(next);
|
||||
saveCustom(next);
|
||||
loadAllSignals(); // Refresh to fetch metadata for new ones
|
||||
}
|
||||
|
||||
async function handleCfSearch() {
|
||||
if (!filter) return;
|
||||
setCfSearching(true);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/channel-finder?q=${encodeURIComponent(filter)}`);
|
||||
if (!res.ok) return;
|
||||
const names: string[] = await res.json();
|
||||
const next = [
|
||||
...customSignals,
|
||||
...names
|
||||
.filter(n => !customSignals.some(c => c.ds === 'epics' && c.name === n))
|
||||
.map(n => ({ ds: 'epics', name: n })),
|
||||
];
|
||||
setCustomSignals(next);
|
||||
saveCustom(next);
|
||||
} finally {
|
||||
setCfSearching(false);
|
||||
}
|
||||
function handleManualAdd() {
|
||||
if (!manualName.trim()) return;
|
||||
handleAddCfSignals([{ ds: manualDs, name: manualName.trim() }]);
|
||||
setManualName('');
|
||||
setShowManualAdd(false);
|
||||
}
|
||||
|
||||
// ── CSV import ────────────────────────────────────────────────────────────
|
||||
@@ -169,39 +244,19 @@ export default function SignalTree({ onDragStart }: Props) {
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
const parts = line.split(',').map(p => p.trim());
|
||||
if (parts.length === 1 && parts[0]) {
|
||||
// bare PV name → default to epics
|
||||
added.push({ ds: 'epics', name: parts[0] });
|
||||
} else if (parts.length >= 2 && parts[0] && parts[1]) {
|
||||
added.push({ ds: parts[0], name: parts[1] });
|
||||
}
|
||||
}
|
||||
const existing = new Set(customSignals.map(c => `${c.ds}\0${c.name}`));
|
||||
const fresh = added.filter(a => !existing.has(`${a.ds}\0${a.name}`));
|
||||
const next = [...customSignals, ...fresh];
|
||||
setCustomSignals(next);
|
||||
saveCustom(next);
|
||||
handleAddCfSignals(added);
|
||||
}
|
||||
|
||||
// ── Filter ────────────────────────────────────────────────────────────────
|
||||
|
||||
const lf = filter.toLowerCase();
|
||||
const filtered = visibleGroups
|
||||
.map(g => ({
|
||||
...g,
|
||||
signals: lf
|
||||
? g.signals.filter(s =>
|
||||
s.name.toLowerCase().includes(lf) ||
|
||||
(s.description ?? '').toLowerCase().includes(lf)
|
||||
)
|
||||
: g.signals,
|
||||
}))
|
||||
.filter(g => g.signals.length > 0 || g.addingSignal || !lf);
|
||||
|
||||
function makeDraggable(ds: string, sig: SignalInfo) {
|
||||
function makeDraggable(sig: SignalInfo & { ds: string }) {
|
||||
return {
|
||||
draggable: true as const,
|
||||
onDragStart: (e: DragEvent) => {
|
||||
const ref: SignalRef = { ds, name: sig.name };
|
||||
const ref: SignalRef = { ds: sig.ds, name: sig.name };
|
||||
e.dataTransfer?.setData('application/json', JSON.stringify(ref));
|
||||
onDragStart?.(ref);
|
||||
},
|
||||
@@ -209,7 +264,7 @@ export default function SignalTree({ onDragStart }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<aside class={`panel signal-tree-panel${collapsed ? ' collapsed' : ''}`}>
|
||||
<aside class={`panel signal-tree-panel${collapsed ? ' collapsed' : ''}`} style={!collapsed && width ? `width:${width}px;min-width:${width}px;` : undefined}>
|
||||
<div class="panel-header">
|
||||
{!collapsed && <span class="panel-title">Signals</span>}
|
||||
<button class="icon-btn" onClick={() => setCollapsed(c => !c)} title={collapsed ? 'Expand' : 'Collapse'}>
|
||||
@@ -219,117 +274,282 @@ export default function SignalTree({ onDragStart }: Props) {
|
||||
|
||||
{!collapsed && (
|
||||
<div class="signal-tree-body">
|
||||
{/* Search / filter bar */}
|
||||
<div class="signal-tree-search">
|
||||
<div class="signal-search-row">
|
||||
<input
|
||||
class="signal-filter"
|
||||
type="search"
|
||||
placeholder="Filter signals…"
|
||||
value={filter}
|
||||
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
{cfAvailable && filter && (
|
||||
<button class="icon-btn" title="Search Channel Finder" onClick={handleCfSearch} disabled={cfSearching}>
|
||||
{cfSearching ? '…' : '🔍'}
|
||||
</button>
|
||||
<div class="signal-tree-tabs">
|
||||
<button
|
||||
class={`stab${treeTab === 'sources' ? ' stab-active' : ''}`}
|
||||
onClick={() => setTreeTab('sources')}
|
||||
>Sources</button>
|
||||
<button
|
||||
class={`stab${treeTab === 'groups' ? ' stab-active' : ''}`}
|
||||
onClick={() => setTreeTab('groups')}
|
||||
>Groups</button>
|
||||
</div>
|
||||
|
||||
{treeTab === 'groups' && (
|
||||
<GroupsTree onDragStart={onDragStart} />
|
||||
)}
|
||||
|
||||
{treeTab === 'sources' && onStateVarsChange && (
|
||||
<div class="signal-group local-state-group">
|
||||
<div class="signal-group-header">
|
||||
<span class="signal-group-arrow" onClick={() => setLocalOpen(o => !o)}>
|
||||
{localOpen ? '▾' : '▸'}
|
||||
</span>
|
||||
<span class="signal-group-name" title="Panel-local state variables" onClick={() => setLocalOpen(o => !o)}>
|
||||
Local State
|
||||
</span>
|
||||
<span class="signal-group-count">{(statevars ?? []).length}</span>
|
||||
<button
|
||||
class="icon-btn signal-add-btn"
|
||||
title="Add local state variable"
|
||||
onClick={(e) => { e.stopPropagation(); setShowAddLocal(s => !s); setLocalOpen(true); }}
|
||||
>+</button>
|
||||
</div>
|
||||
{localOpen && (
|
||||
<div>
|
||||
{showAddLocal && (
|
||||
<div class="signal-add-row" style="flex-wrap: wrap; gap: 4px;">
|
||||
<input
|
||||
class="signal-add-input"
|
||||
placeholder="Variable name…"
|
||||
autoFocus
|
||||
value={lvName}
|
||||
onInput={(e) => setLvName((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') addLocal();
|
||||
if (e.key === 'Escape') setShowAddLocal(false);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
class="prop-select"
|
||||
style="font-size: 0.7rem; height: 1.6rem; padding: 0 4px;"
|
||||
value={lvType}
|
||||
onChange={(e) => setLvType((e.target as HTMLSelectElement).value as any)}
|
||||
>
|
||||
<option value="number">number</option>
|
||||
<option value="bool">bool</option>
|
||||
<option value="string">string</option>
|
||||
</select>
|
||||
<input
|
||||
class="signal-add-input"
|
||||
style="flex: 1;"
|
||||
placeholder="initial"
|
||||
value={lvInitial}
|
||||
onInput={(e) => setLvInitial((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={(e: KeyboardEvent) => { if (e.key === 'Enter') addLocal(); }}
|
||||
/>
|
||||
<button class="icon-btn" title="Add" onClick={addLocal}>✓</button>
|
||||
<button class="icon-btn" title="Cancel" onClick={() => setShowAddLocal(false)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
{(statevars ?? []).length === 0 && !showAddLocal && (
|
||||
<p class="hint" style="padding: 0.25rem 0.5rem;">No local variables.</p>
|
||||
)}
|
||||
{(statevars ?? []).map(v => (
|
||||
<div
|
||||
key={v.name}
|
||||
class="signal-item signal-custom"
|
||||
title={`local:${v.name} (${v.type ?? 'number'}, initial ${v.initial})`}
|
||||
{...makeLocalDraggable(v.name)}
|
||||
>
|
||||
<span class="signal-name">{v.name}</span>
|
||||
<span class="signal-unit">{v.type ?? 'number'}</span>
|
||||
<button
|
||||
class="icon-btn signal-remove-btn"
|
||||
title="Remove local variable"
|
||||
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
|
||||
onClick={(e: MouseEvent) => { e.stopPropagation(); removeLocal(v.name); }}
|
||||
>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toolbar: New Synthetic | Import CSV | Refresh */}
|
||||
<div class="signal-tree-toolbar">
|
||||
<button class="panel-btn" title="Create synthetic signal" onClick={() => setShowWizard(true)}>+ Synthetic</button>
|
||||
<button class="panel-btn" title="Import CSV (ds,name or bare PV names)" onClick={() => csvRef.current?.click()}>CSV</button>
|
||||
<button class="panel-btn" title="Refresh signal list" onClick={loadGroups}>↺</button>
|
||||
<input ref={csvRef} type="file" accept=".csv,text/csv" style="display:none" onChange={handleCsvImport} />
|
||||
</div>
|
||||
{treeTab === 'sources' && (
|
||||
<Fragment>
|
||||
<div class="signal-tree-search">
|
||||
<div class="signal-search-row">
|
||||
<input
|
||||
class="signal-filter"
|
||||
type="search"
|
||||
placeholder="Filter signals…"
|
||||
value={filter}
|
||||
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
{cfAvailable && (
|
||||
<button class="icon-btn" title="Advanced Search" onClick={() => setShowCfModal(true)}>
|
||||
🔍
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Signal groups */}
|
||||
<div class="panel-list signal-list">
|
||||
{loading ? (
|
||||
<p class="hint">Loading signals…</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p class="hint">No signals found.</p>
|
||||
) : (
|
||||
filtered.map(group => {
|
||||
const groupInGroups = groups.find(g => g.ds === group.ds);
|
||||
return (
|
||||
<div key={group.ds} class="signal-group">
|
||||
<div class="signal-group-header">
|
||||
<span class="signal-group-arrow" onClick={() => toggleGroup(group.ds)}>
|
||||
{group.expanded ? '▾' : '▸'}
|
||||
</span>
|
||||
<span class="signal-group-name" onClick={() => toggleGroup(group.ds)}>
|
||||
{group.ds}
|
||||
</span>
|
||||
<span class="signal-group-count">{group.signals.length}</span>
|
||||
<button
|
||||
class="icon-btn signal-add-btn"
|
||||
title={`Add custom signal to ${group.ds}`}
|
||||
onClick={() => setAdding(group.ds, true)}
|
||||
>+</button>
|
||||
</div>
|
||||
<div class="signal-tree-toolbar" style="gap: 4px;">
|
||||
<select
|
||||
class="prop-select"
|
||||
style="font-size: 0.7rem; height: 1.6rem; padding: 0 4px; flex: 1;"
|
||||
value={groupBy}
|
||||
onChange={e => setGroupBy((e.target as HTMLSelectElement).value as any)}
|
||||
>
|
||||
<option value="datasource">By Source</option>
|
||||
<option value="area">By Area</option>
|
||||
<option value="system">By System</option>
|
||||
<option value="ioc">By IOC</option>
|
||||
</select>
|
||||
<button class="panel-btn icon-only" title="Add Signal Manually" onClick={() => setShowManualAdd(true)}>+</button>
|
||||
<button class="panel-btn icon-only" title="Create synthetic signal" onClick={() => setShowWizard(true)}>Σ</button>
|
||||
<button class="panel-btn icon-only" title="Import CSV" onClick={() => csvRef.current?.click()}>CSV</button>
|
||||
<button class="panel-btn icon-only" title="Refresh signal list" onClick={loadAllSignals}>↺</button>
|
||||
<input ref={csvRef} type="file" accept=".csv,text/csv" style="display:none" onChange={handleCsvImport} />
|
||||
</div>
|
||||
|
||||
{group.expanded && (
|
||||
<div>
|
||||
{group.signals.map(sig => {
|
||||
const isCustom = !groups.find(g => g.ds === group.ds)?.signals.some(s => s.name === sig.name);
|
||||
return (
|
||||
<div
|
||||
key={sig.name}
|
||||
class={`signal-item${isCustom ? ' signal-custom' : ''}`}
|
||||
title={`${group.ds}:${sig.name}${sig.unit ? ` [${sig.unit}]` : ''}${sig.description ? ` — ${sig.description}` : ''}`}
|
||||
{...makeDraggable(group.ds, sig)}
|
||||
>
|
||||
<span class="signal-name">{sig.name}</span>
|
||||
{sig.unit && <span class="signal-unit">{sig.unit}</span>}
|
||||
{isCustom && (
|
||||
<button
|
||||
class="icon-btn signal-remove-btn"
|
||||
title="Remove custom signal"
|
||||
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
|
||||
onClick={(e: MouseEvent) => { e.stopPropagation(); removeCustom(group.ds, sig.name); }}
|
||||
>✕</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Add-signal input row */}
|
||||
{(groupInGroups?.addingSignal) && (
|
||||
<div class="signal-add-row">
|
||||
<input
|
||||
class="signal-add-input"
|
||||
placeholder="Signal / PV name…"
|
||||
autoFocus
|
||||
value={groupInGroups?.addValue ?? ''}
|
||||
onInput={(e) => setAddValue(group.ds, (e.target as HTMLInputElement).value)}
|
||||
onKeyDown={(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') commitAdd(group.ds, (e.target as HTMLInputElement).value);
|
||||
if (e.key === 'Escape') setAdding(group.ds, false);
|
||||
}}
|
||||
/>
|
||||
<button class="icon-btn" onClick={() => commitAdd(group.ds, groupInGroups?.addValue ?? '')}>✓</button>
|
||||
<button class="icon-btn" onClick={() => setAdding(group.ds, false)}>✕</button>
|
||||
</div>
|
||||
<div class="panel-list signal-list">
|
||||
{loading ? (
|
||||
<p class="hint">Loading signals…</p>
|
||||
) : visibleGroups.length === 0 ? (
|
||||
<p class="hint">No signals found.</p>
|
||||
) : (
|
||||
visibleGroups.map(group => (
|
||||
<div key={group.id} class="signal-group">
|
||||
<div class="signal-group-header">
|
||||
<span class="signal-group-arrow" onClick={() => toggleGroup(group.id)}>
|
||||
{group.expanded ? '▾' : '▸'}
|
||||
</span>
|
||||
<span class="signal-group-name" title={group.label} onClick={() => toggleGroup(group.id)}>
|
||||
{group.label}
|
||||
</span>
|
||||
<span class="signal-group-count">{group.signals.length}</span>
|
||||
{groupBy === 'datasource' && group.id !== 'synthetic' && (
|
||||
<button
|
||||
class="icon-btn signal-add-btn"
|
||||
title={`Add custom signal to ${group.id}`}
|
||||
onClick={(e) => { e.stopPropagation(); startAdding(group.id); }}
|
||||
>+</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{group.expanded && (
|
||||
<div>
|
||||
{addingTo === group.id && (
|
||||
<div class="signal-add-row">
|
||||
<input
|
||||
class="signal-add-input"
|
||||
placeholder="Signal / PV name…"
|
||||
autoFocus
|
||||
value={addValue}
|
||||
onInput={(e) => setAddValue((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') commitAddManually();
|
||||
if (e.key === 'Escape') setAddingTo(null);
|
||||
}}
|
||||
/>
|
||||
<button class="icon-btn" onClick={commitAddManually}>✓</button>
|
||||
<button class="icon-btn" onClick={() => setAddingTo(null)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
{group.signals.map(sig => {
|
||||
const isCustom = sig.type === 'custom' || !rawSignals.some(rs => rs.ds === sig.ds && rs.name === sig.name);
|
||||
return (
|
||||
<div
|
||||
key={`${sig.ds}:${sig.name}`}
|
||||
class={`signal-item${isCustom ? ' signal-custom' : ''}`}
|
||||
title={`${sig.ds}:${sig.name}${sig.unit ? ` [${sig.unit}]` : ''}${sig.description ? ` — ${sig.description}` : ''}`}
|
||||
{...makeDraggable(sig)}
|
||||
>
|
||||
<span class="signal-name">{sig.name}</span>
|
||||
{sig.unit && <span class="signal-unit">{sig.unit}</span>}
|
||||
{sig.ds === 'synthetic' && (
|
||||
<button
|
||||
class="icon-btn signal-edit-btn"
|
||||
title="Edit pipeline"
|
||||
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
|
||||
onClick={(e: MouseEvent) => { e.stopPropagation(); setEditSynthetic(sig.name); }}
|
||||
>✎</button>
|
||||
)}
|
||||
{isCustom && (
|
||||
<button
|
||||
class="icon-btn signal-remove-btn"
|
||||
title="Remove custom signal"
|
||||
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
|
||||
onClick={(e: MouseEvent) => { e.stopPropagation(); removeCustom(sig.ds, sig.name); }}
|
||||
>✕</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showWizard && (
|
||||
<SyntheticWizard
|
||||
currentIfaceId={panelId}
|
||||
onClose={() => setShowWizard(false)}
|
||||
onCreated={loadGroups}
|
||||
onCreated={loadAllSignals}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editSynthetic && (
|
||||
<SyntheticGraphEditor
|
||||
name={editSynthetic}
|
||||
onClose={() => setEditSynthetic(null)}
|
||||
onSaved={loadAllSignals}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCfModal && (
|
||||
<ChannelFinderModal
|
||||
cfURL="" // fetched by backend API
|
||||
onClose={() => setShowCfModal(false)}
|
||||
onAddSignals={handleAddCfSignals}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showManualAdd && (
|
||||
<div class="wizard-backdrop" onClick={() => setShowManualAdd(false)}>
|
||||
<div class="wizard" onClick={e => e.stopPropagation()} style="max-width: 400px;">
|
||||
<div class="wizard-header">
|
||||
<span>Add Signal Manually</span>
|
||||
<button class="icon-btn" onClick={() => setShowManualAdd(false)}>✕</button>
|
||||
</div>
|
||||
<div class="wizard-body">
|
||||
<div class="wizard-field">
|
||||
<label>Data Source</label>
|
||||
<select class="prop-select" value={manualDs} onChange={e => setManualDs((e.target as HTMLSelectElement).value)}>
|
||||
{Array.from(new Set(allSignals.map(s => s.ds))).map(ds => (
|
||||
<option key={ds} value={ds}>{ds}</option>
|
||||
))}
|
||||
{!allSignals.some(s => s.ds === 'epics') && <option value="epics">epics</option>}
|
||||
</select>
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Signal / PV Name</label>
|
||||
<input
|
||||
class="prop-input"
|
||||
autoFocus
|
||||
placeholder="e.g. MY:PV:NAME"
|
||||
value={manualName}
|
||||
onInput={e => setManualName((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleManualAdd()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wizard-footer">
|
||||
<button class="toolbar-btn" onClick={() => setShowManualAdd(false)}>Cancel</button>
|
||||
<button class="toolbar-btn toolbar-btn-primary" onClick={handleManualAdd}>Add Signal</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { h } from 'preact';
|
||||
import type { VNode } from 'preact';
|
||||
import type { PlotLayout } from './lib/types';
|
||||
|
||||
interface Props {
|
||||
layout: PlotLayout;
|
||||
/** Render the contents of a leaf pane for the given widget id. */
|
||||
renderLeaf: (widgetId: string) => VNode;
|
||||
/** When provided, dividers become draggable and report the new ratio for the
|
||||
* split node addressed by `path` (a list of 0/1 = a/b choices from the root). */
|
||||
onResize?: (path: number[], ratio: number) => void;
|
||||
}
|
||||
|
||||
const MIN_RATIO = 0.1;
|
||||
const MAX_RATIO = 0.9;
|
||||
|
||||
export default function SplitLayout({ layout, renderLeaf, onResize }: Props) {
|
||||
function startDrag(e: MouseEvent, container: HTMLElement | null, dir: 'h' | 'v', path: number[]) {
|
||||
if (!onResize || !container) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
function onMove(mv: MouseEvent) {
|
||||
const rect = container!.getBoundingClientRect();
|
||||
const r = dir === 'h'
|
||||
? (mv.clientX - rect.left) / rect.width
|
||||
: (mv.clientY - rect.top) / rect.height;
|
||||
onResize!(path, Math.min(MAX_RATIO, Math.max(MIN_RATIO, r)));
|
||||
}
|
||||
function onUp() {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
}
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}
|
||||
|
||||
function renderNode(node: PlotLayout, path: number[]): VNode {
|
||||
if (node.type === 'leaf') {
|
||||
return <div class="split-leaf">{renderLeaf(node.widget)}</div>;
|
||||
}
|
||||
const isRow = node.dir === 'h';
|
||||
let containerEl: HTMLElement | null = null;
|
||||
return (
|
||||
<div
|
||||
class={isRow ? 'split-row' : 'split-col'}
|
||||
ref={(el) => { containerEl = el as HTMLElement | null; }}
|
||||
>
|
||||
<div class="split-child" style={`flex:${node.ratio}`}>
|
||||
{renderNode(node.a, [...path, 0])}
|
||||
</div>
|
||||
<div
|
||||
class={`split-divider ${isRow ? 'split-divider-h' : 'split-divider-v'}${onResize ? '' : ' split-divider-static'}`}
|
||||
onMouseDown={(e: MouseEvent) => startDrag(e, containerEl, node.dir, path)}
|
||||
/>
|
||||
<div class="split-child" style={`flex:${1 - node.ratio}`}>
|
||||
{renderNode(node.b, [...path, 1])}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div class="split-root">{renderNode(layout, [])}</div>;
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||
import SearchableSelect from './SearchableSelect';
|
||||
import LuaEditor from './LuaEditor';
|
||||
import type { InputRef, PipelineNode, SignalDef } from './lib/types';
|
||||
|
||||
interface DataSource { name: string; }
|
||||
interface SignalInfo { name: string; }
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
// ── Node-type catalogue ──────────────────────────────────────────────────────
|
||||
// Mirrors the backend dsp node set (internal/dsp/nodes.go). The combine nodes
|
||||
// (add/subtract/…) and expr take several inputs, which only the FIRST node of a
|
||||
// pipeline receives — see the compile() note below.
|
||||
interface NodeParam { label: string; key: string; type: 'number' | 'text' | 'lua'; default: string; }
|
||||
interface OpDef { type: string; label: string; params: NodeParam[]; }
|
||||
|
||||
const OPS: OpDef[] = [
|
||||
{ type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] },
|
||||
{ type: 'offset', label: 'Offset', params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] },
|
||||
{ type: 'add', label: 'Add (Σ inputs)', params: [] },
|
||||
{ type: 'subtract', label: 'Subtract (a−b)', params: [] },
|
||||
{ type: 'multiply', label: 'Multiply (Π)', params: [] },
|
||||
{ type: 'divide', label: 'Divide (a÷b)', params: [] },
|
||||
{ type: 'moving_average', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
|
||||
{ type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
|
||||
{ type: 'lowpass', label: 'Low-pass', params: [
|
||||
{ label: 'Cutoff freq (Hz)', key: 'freq', type: 'number', default: '1' },
|
||||
{ label: 'Order (1–8)', key: 'order', type: 'number', default: '1' },
|
||||
]},
|
||||
{ type: 'derivative', label: 'Derivative', params: [] },
|
||||
{ type: 'integrate', label: 'Integrate', params: [] },
|
||||
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
|
||||
{ type: 'threshold', label: 'Threshold', params: [
|
||||
{ label: 'Threshold', key: 'threshold', type: 'number', default: '0' },
|
||||
{ label: 'High output', key: 'high', type: 'number', default: '1' },
|
||||
{ label: 'Low output', key: 'low', type: 'number', default: '0' },
|
||||
]},
|
||||
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a,b,c,d = inputs)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
|
||||
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a,b,c,d = inputs)', key: 'script', type: 'lua', default: 'return a' }] },
|
||||
];
|
||||
const OP_BY_TYPE = new Map(OPS.map(o => [o.type, o]));
|
||||
function opLabel(type: string): string { return OP_BY_TYPE.get(type)?.label ?? type; }
|
||||
function opParamDefs(type: string): NodeParam[] { return OP_BY_TYPE.get(type)?.params ?? []; }
|
||||
|
||||
// ── Graph model (UI only — compiled to SignalDef on save) ───────────────────
|
||||
type NodeKind = 'source' | 'op' | 'output';
|
||||
interface GNode {
|
||||
id: string;
|
||||
kind: NodeKind;
|
||||
x: number;
|
||||
y: number;
|
||||
ds?: string; // source
|
||||
signal?: string; // source
|
||||
op?: string; // op type
|
||||
params?: Record<string, any>; // op
|
||||
}
|
||||
interface GWire { from: string; to: string; }
|
||||
interface Graph { nodes: GNode[]; wires: GWire[]; }
|
||||
|
||||
// ── Geometry (rem-based, like the logic editor) ─────────────────────────────
|
||||
const REM = (() => {
|
||||
if (typeof document === 'undefined') return 16;
|
||||
return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
||||
})();
|
||||
const NODE_W = 10 * REM;
|
||||
const PORT_TOP = 1.4 * REM;
|
||||
const PORT_R = 0.375 * REM;
|
||||
|
||||
function genId(): string { return 'g_' + Math.random().toString(36).slice(2, 9); }
|
||||
function hasInput(k: NodeKind): boolean { return k !== 'source'; }
|
||||
function hasOutput(k: NodeKind): boolean { return k !== 'output'; }
|
||||
function inAnchor(n: GNode) { return { x: n.x, y: n.y + PORT_TOP }; }
|
||||
function outAnchor(n: GNode) { return { x: n.x + NODE_W, y: n.y + PORT_TOP }; }
|
||||
function wirePathStr(x1: number, y1: number, x2: number, y2: number): string {
|
||||
const dx = Math.max(40, Math.abs(x2 - x1) / 2);
|
||||
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
|
||||
}
|
||||
|
||||
// Build an initial graph by laying out an existing SignalDef: sources stacked on
|
||||
// the left, the linear pipeline as a row of op nodes, output on the right.
|
||||
function buildInitial(def: SignalDef): Graph {
|
||||
const inputs: InputRef[] = def.inputs && def.inputs.length > 0
|
||||
? def.inputs
|
||||
: (def.ds && def.signal ? [{ ds: def.ds, signal: def.signal }] : []);
|
||||
const pipeline = def.pipeline ?? [];
|
||||
const nodes: GNode[] = [];
|
||||
const wires: GWire[] = [];
|
||||
|
||||
const srcIds = inputs.map((inp, i) => {
|
||||
const id = genId();
|
||||
nodes.push({ id, kind: 'source', x: 2 * REM, y: (2 + i * 5) * REM, ds: inp.ds, signal: inp.signal });
|
||||
return id;
|
||||
});
|
||||
const opIds = pipeline.map((nd, i) => {
|
||||
const id = genId();
|
||||
nodes.push({ id, kind: 'op', x: (15 + i * 12) * REM, y: 3 * REM, op: nd.type, params: { ...nd.params } });
|
||||
return id;
|
||||
});
|
||||
const outId = genId();
|
||||
nodes.push({ id: outId, kind: 'output', x: (15 + pipeline.length * 12) * REM, y: 3 * REM });
|
||||
|
||||
const headId = opIds[0] ?? outId;
|
||||
srcIds.forEach(s => wires.push({ from: s, to: headId }));
|
||||
for (let i = 0; i < opIds.length - 1; i++) wires.push({ from: opIds[i], to: opIds[i + 1] });
|
||||
if (opIds.length > 0) wires.push({ from: opIds[opIds.length - 1], to: outId });
|
||||
|
||||
return { nodes, wires };
|
||||
}
|
||||
|
||||
// Compile the visual graph back into the backend's linear form. The pipeline is
|
||||
// a single chain ending at the output; the head node receives every source
|
||||
// signal (inputs[0]=a, inputs[1]=b, …), every later node the previous output.
|
||||
function compile(g: Graph): { inputs?: InputRef[]; pipeline?: PipelineNode[]; error?: string } {
|
||||
const output = g.nodes.find(n => n.kind === 'output');
|
||||
if (!output) return { error: 'missing output node' };
|
||||
const byId = new Map(g.nodes.map(n => [n.id, n]));
|
||||
const upstream = (id: string) => g.wires.filter(w => w.to === id).map(w => byId.get(w.from)).filter(Boolean) as GNode[];
|
||||
|
||||
const chain: GNode[] = [];
|
||||
const seen = new Set<string>();
|
||||
let cur: GNode = output;
|
||||
for (;;) {
|
||||
if (seen.has(cur.id)) return { error: 'the graph contains a cycle' };
|
||||
seen.add(cur.id);
|
||||
const ups = upstream(cur.id);
|
||||
const opUps = ups.filter(n => n.kind === 'op');
|
||||
const srcUps = ups.filter(n => n.kind === 'source');
|
||||
if (opUps.length > 1) return { error: 'a node may have only one upstream node — the pipeline must be a single chain' };
|
||||
if (opUps.length === 1) {
|
||||
if (srcUps.length > 0) return { error: 'only the first processing node may take input signals directly' };
|
||||
chain.unshift(opUps[0]);
|
||||
cur = opUps[0];
|
||||
continue;
|
||||
}
|
||||
// Reached the head of the chain: its source upstreams become the inputs.
|
||||
if (srcUps.length === 0) {
|
||||
return { error: cur.kind === 'output' ? 'connect at least one signal to the output' : 'the first node has no input signals' };
|
||||
}
|
||||
const inputs = srcUps
|
||||
.slice()
|
||||
.sort((a, b) => a.y - b.y)
|
||||
.map(n => ({ ds: n.ds || '', signal: n.signal || '' }));
|
||||
if (inputs.some(i => !i.ds || !i.signal)) return { error: 'every input signal needs a data source and a signal' };
|
||||
const pipeline = chain.map(n => ({ type: n.op!, params: n.params ?? {} }));
|
||||
return { inputs, pipeline };
|
||||
}
|
||||
}
|
||||
|
||||
function nodeSummary(n: GNode): string {
|
||||
if (n.kind === 'source') return n.ds && n.signal ? `${n.ds}:${n.signal}` : '(pick a signal)';
|
||||
if (n.kind === 'output') return 'synthetic result';
|
||||
const p = n.params ?? {};
|
||||
switch (n.op) {
|
||||
case 'gain': return `× ${p.gain ?? 1}`;
|
||||
case 'offset': return `+ ${p.offset ?? 0}`;
|
||||
case 'moving_average': return `avg ${p.window ?? 10}`;
|
||||
case 'rms': return `rms ${p.window ?? 10}`;
|
||||
case 'lowpass': return `≤ ${p.freq ?? 1} Hz`;
|
||||
case 'clamp': return `[${p.min ?? 0}, ${p.max ?? 100}]`;
|
||||
case 'threshold': return `> ${p.threshold ?? 0}`;
|
||||
case 'expr': return String(p.expr ?? '');
|
||||
case 'lua': return 'lua';
|
||||
default: return opLabel(n.op ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props) {
|
||||
const [def, setDef] = useState<SignalDef | null>(null);
|
||||
const [graph, setGraph] = useState<Graph>({ nodes: [], wires: [] });
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [selectedWire, setSelectedWire] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [dataSources, setDataSources] = useState<string[]>([]);
|
||||
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
|
||||
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const dragNode = useRef<{ id: string; dx: number; dy: number; pushed: boolean } | null>(null);
|
||||
const [pendingWire, setPendingWire] = useState<{ from: string; x: number; y: number } | null>(null);
|
||||
const pendingRef = useRef(pendingWire);
|
||||
pendingRef.current = pendingWire;
|
||||
|
||||
const graphRef = useRef(graph);
|
||||
graphRef.current = graph;
|
||||
const undoStack = useRef<Graph[]>([]);
|
||||
const redoStack = useRef<Graph[]>([]);
|
||||
const [, setTick] = useState(0);
|
||||
const bump = () => setTick(t => t + 1);
|
||||
const canUndo = undoStack.current.length > 0;
|
||||
const canRedo = redoStack.current.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/datasources')
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then((ds: DataSource[]) => setDataSources(ds.map(d => d.name)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function loadSignals(ds: string) {
|
||||
if (!ds || dsSignals[ds]) return;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/signals?ds=${encodeURIComponent(ds)}`);
|
||||
if (!res.ok) return;
|
||||
const sigs: SignalInfo[] = await res.json();
|
||||
setDsSignals(prev => ({ ...prev, [ds]: sigs.map(s => s.name) }));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`)
|
||||
.then(r => r.ok ? r.json() : Promise.reject(r.statusText))
|
||||
.then((d: SignalDef) => {
|
||||
setDef(d);
|
||||
const g = buildInitial(d);
|
||||
setGraph(g);
|
||||
g.nodes.forEach(n => { if (n.kind === 'source' && n.ds) loadSignals(n.ds); });
|
||||
})
|
||||
.catch(e => setError(String(e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [name]);
|
||||
|
||||
// ── History ────────────────────────────────────────────────────────────────
|
||||
function pushUndo() {
|
||||
undoStack.current = [...undoStack.current.slice(-49), graphRef.current];
|
||||
redoStack.current = [];
|
||||
}
|
||||
function commit(next: Graph, record = true) {
|
||||
if (record) pushUndo();
|
||||
setGraph(next);
|
||||
}
|
||||
function undo() {
|
||||
if (undoStack.current.length === 0) return;
|
||||
const prev = undoStack.current[undoStack.current.length - 1];
|
||||
redoStack.current = [graphRef.current, ...redoStack.current];
|
||||
undoStack.current = undoStack.current.slice(0, -1);
|
||||
setSelected(null); setSelectedWire(null);
|
||||
setGraph(prev); bump();
|
||||
}
|
||||
function redo() {
|
||||
if (redoStack.current.length === 0) return;
|
||||
const next = redoStack.current[0];
|
||||
undoStack.current = [...undoStack.current, graphRef.current];
|
||||
redoStack.current = redoStack.current.slice(1);
|
||||
setSelected(null); setSelectedWire(null);
|
||||
setGraph(next); bump();
|
||||
}
|
||||
|
||||
// ── Graph mutators ───────────────────────────────────────────────────────
|
||||
function addSource() {
|
||||
const node: GNode = { id: genId(), kind: 'source', x: 2 * REM, y: (2 + graph.nodes.filter(n => n.kind === 'source').length * 5) * REM, ds: dataSources[0] || '', signal: '' };
|
||||
if (node.ds) loadSignals(node.ds);
|
||||
commit({ nodes: [...graph.nodes, node], wires: graph.wires });
|
||||
setSelected(node.id); setSelectedWire(null);
|
||||
}
|
||||
function addOp(op: OpDef, x?: number, y?: number) {
|
||||
const params: Record<string, any> = {};
|
||||
for (const p of op.params) params[p.key] = p.type === 'number' ? parseFloat(p.default) : p.default;
|
||||
const node: GNode = { id: genId(), kind: 'op', op: op.type, params, x: x ?? (14 + (graph.nodes.length % 4) * 3) * REM, y: y ?? (3 + (graph.nodes.length % 4) * 2) * REM };
|
||||
commit({ nodes: [...graph.nodes, node], wires: graph.wires });
|
||||
setSelected(node.id); setSelectedWire(null);
|
||||
}
|
||||
function patchNode(id: string, patch: Partial<GNode>) {
|
||||
commit({ nodes: graph.nodes.map(n => (n.id === id ? { ...n, ...patch } : n)), wires: graph.wires });
|
||||
}
|
||||
function patchParam(id: string, key: string, val: string) {
|
||||
commit({
|
||||
nodes: graph.nodes.map(n => {
|
||||
if (n.id !== id) return n;
|
||||
const pd = opParamDefs(n.op ?? '').find(p => p.key === key);
|
||||
const typed: any = pd?.type === 'number' ? parseFloat(val) : val;
|
||||
return { ...n, params: { ...n.params, [key]: typed } };
|
||||
}),
|
||||
wires: graph.wires,
|
||||
});
|
||||
}
|
||||
function moveNode(id: string, x: number, y: number, record: boolean) {
|
||||
commit({ nodes: graph.nodes.map(n => (n.id === id ? { ...n, x, y } : n)), wires: graph.wires }, record);
|
||||
}
|
||||
function deleteNode(id: string) {
|
||||
const n = graph.nodes.find(x => x.id === id);
|
||||
if (!n || n.kind === 'output') return; // the output node is permanent
|
||||
commit({ nodes: graph.nodes.filter(x => x.id !== id), wires: graph.wires.filter(w => w.from !== id && w.to !== id) });
|
||||
if (selected === id) setSelected(null);
|
||||
}
|
||||
function addWire(from: string, to: string) {
|
||||
if (from === to) return;
|
||||
const fn = graph.nodes.find(n => n.id === from);
|
||||
const tn = graph.nodes.find(n => n.id === to);
|
||||
if (!fn || !tn || !hasOutput(fn.kind) || !hasInput(tn.kind)) return;
|
||||
if (graph.wires.some(w => w.from === from && w.to === to)) return;
|
||||
commit({ nodes: graph.nodes, wires: [...graph.wires, { from, to }] });
|
||||
}
|
||||
function deleteWire(idx: number) {
|
||||
commit({ nodes: graph.nodes, wires: graph.wires.filter((_, i) => i !== idx) });
|
||||
if (selectedWire === idx) setSelectedWire(null);
|
||||
}
|
||||
|
||||
// ── Pointer / drag / wire ──────────────────────────────────────────────────
|
||||
function toCanvas(e: MouseEvent) {
|
||||
const el = canvasRef.current!;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return { x: e.clientX - rect.left + el.scrollLeft, y: e.clientY - rect.top + el.scrollTop };
|
||||
}
|
||||
function startNodeDrag(e: MouseEvent, node: GNode) {
|
||||
e.stopPropagation();
|
||||
const p = toCanvas(e);
|
||||
dragNode.current = { id: node.id, dx: p.x - node.x, dy: p.y - node.y, pushed: false };
|
||||
setSelected(node.id); setSelectedWire(null);
|
||||
window.addEventListener('mousemove', onNodeDragMove);
|
||||
window.addEventListener('mouseup', onNodeDragUp);
|
||||
}
|
||||
function onNodeDragMove(e: MouseEvent) {
|
||||
const d = dragNode.current;
|
||||
if (!d) return;
|
||||
const p = toCanvas(e);
|
||||
const record = !d.pushed;
|
||||
d.pushed = true;
|
||||
moveNode(d.id, Math.max(0, p.x - d.dx), Math.max(0, p.y - d.dy), record);
|
||||
}
|
||||
function onNodeDragUp() {
|
||||
dragNode.current = null;
|
||||
window.removeEventListener('mousemove', onNodeDragMove);
|
||||
window.removeEventListener('mouseup', onNodeDragUp);
|
||||
}
|
||||
function startWire(e: MouseEvent, node: GNode) {
|
||||
e.stopPropagation();
|
||||
const p = toCanvas(e);
|
||||
setPendingWire({ from: node.id, x: p.x, y: p.y });
|
||||
window.addEventListener('mousemove', onWireMove);
|
||||
window.addEventListener('mouseup', onWireUp);
|
||||
}
|
||||
function onWireMove(e: MouseEvent) {
|
||||
const cur = pendingRef.current;
|
||||
if (!cur) return;
|
||||
const p = toCanvas(e);
|
||||
setPendingWire({ ...cur, x: p.x, y: p.y });
|
||||
}
|
||||
function endWire() {
|
||||
pendingRef.current = null;
|
||||
window.removeEventListener('mousemove', onWireMove);
|
||||
window.removeEventListener('mouseup', onWireUp);
|
||||
setPendingWire(null);
|
||||
}
|
||||
function onWireUp() { endWire(); }
|
||||
function finishWire(target: GNode) {
|
||||
const cur = pendingRef.current;
|
||||
if (cur && hasInput(target.kind)) addWire(cur.from, target.id);
|
||||
endWire();
|
||||
}
|
||||
|
||||
// ── Palette drag-and-drop ──────────────────────────────────────────────────
|
||||
const DRAG_MIME = 'application/x-uopi-synth-op';
|
||||
function onCanvasDragOver(e: DragEvent) {
|
||||
if (Array.from(e.dataTransfer?.types ?? []).includes(DRAG_MIME)) {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
}
|
||||
function onCanvasDrop(e: DragEvent) {
|
||||
const type = e.dataTransfer?.getData(DRAG_MIME);
|
||||
if (!type) return;
|
||||
e.preventDefault();
|
||||
const op = OP_BY_TYPE.get(type);
|
||||
if (!op) return;
|
||||
const p = toCanvas(e);
|
||||
addOp(op, Math.max(0, p.x - NODE_W / 2), Math.max(0, p.y - PORT_TOP));
|
||||
}
|
||||
|
||||
// ── Keyboard ────────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
const t = e.target as Element;
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return;
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; }
|
||||
if (mod && (e.key.toLowerCase() === 'y' || (e.key.toLowerCase() === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; }
|
||||
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
|
||||
if (selectedWire !== null) deleteWire(selectedWire);
|
||||
else if (selected) deleteNode(selected);
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [selected, selectedWire, graph]);
|
||||
|
||||
const byId = new Map(graph.nodes.map(n => [n.id, n]));
|
||||
const sel = graph.nodes.find(n => n.id === selected) ?? null;
|
||||
const compiled = compile(graph);
|
||||
|
||||
async function handleSave() {
|
||||
if (!def) return;
|
||||
if (compiled.error) { setError(compiled.error); return; }
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const body = { ...def, inputs: compiled.inputs, pipeline: compiled.pipeline, ds: undefined, signal: undefined };
|
||||
const res = await fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(j.error ?? res.statusText);
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function nodeClass(n: GNode): string {
|
||||
const cat = n.kind === 'source' ? 'trigger' : n.kind === 'output' ? 'action' : 'flow';
|
||||
return `flow-node flow-node-${cat}${selected === n.id ? ' flow-node-selected' : ''}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="wizard-backdrop" onClick={onClose}>
|
||||
<div class="synth-graph" onClick={(e) => e.stopPropagation()}>
|
||||
<div class="wizard-header">
|
||||
<span>Synthetic Signal — {name}</span>
|
||||
<button class="icon-btn" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div class="wizard-body"><p class="hint">Loading…</p></div>
|
||||
) : (
|
||||
<div class="flow-editor">
|
||||
<div class="flow-palette">
|
||||
<div class="flow-palette-toolbar">
|
||||
<button class="toolbar-btn" disabled={!canUndo} onClick={undo} title="Undo (Ctrl+Z)">↩</button>
|
||||
<button class="toolbar-btn" disabled={!canRedo} onClick={redo} title="Redo (Ctrl+Shift+Z)">↪</button>
|
||||
</div>
|
||||
<div class="flow-palette-title">Inputs</div>
|
||||
<button class="flow-palette-btn flow-palette-trigger" onClick={addSource}>+ Signal</button>
|
||||
<div class="flow-palette-title">Operations</div>
|
||||
{OPS.map(op => (
|
||||
<button key={op.type} class="flow-palette-btn flow-palette-flow"
|
||||
title={`${op.type} — drag onto the canvas or click to add`}
|
||||
draggable
|
||||
onDragStart={(e) => { e.dataTransfer?.setData(DRAG_MIME, op.type); if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy'; }}
|
||||
onClick={() => addOp(op)}>{op.label}</button>
|
||||
))}
|
||||
<div class="flow-palette-hint hint">
|
||||
Wire input signals into the first operation, chain operations, and connect the
|
||||
last one to <b>Output</b>. The first node receives every input as <code>a,b,c,d</code>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow-canvas" ref={canvasRef}
|
||||
onMouseDown={() => { setSelected(null); setSelectedWire(null); }}
|
||||
onDragOver={onCanvasDragOver}
|
||||
onDrop={onCanvasDrop}>
|
||||
<div class="flow-canvas-inner">
|
||||
<svg class="flow-wires">
|
||||
{graph.wires.map((w, idx) => {
|
||||
const a = byId.get(w.from); const b = byId.get(w.to);
|
||||
if (!a || !b) return null;
|
||||
const p1 = outAnchor(a); const p2 = inAnchor(b);
|
||||
return (
|
||||
<path key={idx}
|
||||
class={`flow-wire${selectedWire === idx ? ' flow-wire-selected' : ''}`}
|
||||
d={wirePathStr(p1.x, p1.y, p2.x, p2.y)}
|
||||
onClick={(e) => { e.stopPropagation(); setSelectedWire(idx); setSelected(null); }} />
|
||||
);
|
||||
})}
|
||||
{pendingWire && (() => {
|
||||
const a = byId.get(pendingWire.from);
|
||||
if (!a) return null;
|
||||
const p1 = outAnchor(a);
|
||||
return <path class="flow-wire flow-wire-pending" d={wirePathStr(p1.x, p1.y, pendingWire.x, pendingWire.y)} />;
|
||||
})()}
|
||||
</svg>
|
||||
|
||||
{graph.nodes.map(node => (
|
||||
<div key={node.id}
|
||||
class={nodeClass(node)}
|
||||
style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px;`}
|
||||
onMouseDown={(e) => startNodeDrag(e, node)}
|
||||
onMouseUp={() => { if (pendingRef.current) finishWire(node); }}>
|
||||
<div class="flow-node-header">
|
||||
<span class="flow-node-title">{node.kind === 'source' ? 'Signal' : node.kind === 'output' ? 'Output' : opLabel(node.op ?? '')}</span>
|
||||
{node.kind !== 'output' && (
|
||||
<button class="flow-node-del" title="Delete node"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); deleteNode(node.id); }}>✕</button>
|
||||
)}
|
||||
</div>
|
||||
<div class="flow-node-body hint">{nodeSummary(node)}</div>
|
||||
|
||||
{hasInput(node.kind) && (
|
||||
<div class="flow-port flow-port-in" title="Input"
|
||||
style={`top:${PORT_TOP - PORT_R}px; left:${-PORT_R}px;`}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onMouseUp={(e) => { e.stopPropagation(); finishWire(node); }} />
|
||||
)}
|
||||
{hasOutput(node.kind) && (
|
||||
<div class="flow-port flow-port-out" title="Output"
|
||||
style={`top:${PORT_TOP - PORT_R}px; right:${-PORT_R}px;`}
|
||||
onMouseDown={(e) => startWire(e, node)} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow-inspector">
|
||||
{!sel && <div class="hint">Select a node to edit it, or add one from the palette.</div>}
|
||||
|
||||
{sel?.kind === 'source' && (
|
||||
<Fragment>
|
||||
<div class="wizard-section-title">Input signal</div>
|
||||
<div class="wizard-field">
|
||||
<label>Data source</label>
|
||||
<SearchableSelect value={sel.ds ?? ''} options={dataSources}
|
||||
onSelect={(ds) => { loadSignals(ds); patchNode(sel.id, { ds, signal: '' }); }} />
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Signal</label>
|
||||
<SearchableSelect value={sel.signal ?? ''} options={dsSignals[sel.ds ?? ''] ?? []}
|
||||
onSelect={(signal) => patchNode(sel.id, { signal })}
|
||||
placeholder={sel.ds ? 'Search signals…' : 'Select data source first'} />
|
||||
</div>
|
||||
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(sel.id)}>Delete node</button>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{sel?.kind === 'op' && (
|
||||
<Fragment>
|
||||
<div class="wizard-section-title">{opLabel(sel.op ?? '')}</div>
|
||||
{opParamDefs(sel.op ?? '').length === 0 && (
|
||||
<p class="hint">No parameters — this operation transforms its input directly.</p>
|
||||
)}
|
||||
{opParamDefs(sel.op ?? '').map(pd => (
|
||||
<div key={pd.key} class="wizard-field">
|
||||
<label>{pd.label}</label>
|
||||
{pd.type === 'lua' ? (
|
||||
<LuaEditor value={String(sel.params?.[pd.key] ?? pd.default)}
|
||||
onChange={(v) => patchParam(sel.id, pd.key, v)} />
|
||||
) : (
|
||||
<input class="prop-input" type={pd.type === 'number' ? 'number' : 'text'}
|
||||
value={sel.params?.[pd.key] ?? pd.default}
|
||||
onInput={(e) => patchParam(sel.id, pd.key, (e.target as HTMLInputElement).value)} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(sel.id)}>Delete node</button>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{sel?.kind === 'output' && (
|
||||
<Fragment>
|
||||
<div class="wizard-section-title">Output</div>
|
||||
<p class="hint">The value wired into this node becomes the signal <b>{name}</b>.</p>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="wizard-footer">
|
||||
{error && <span class="wizard-error" style="flex:1;">{error}</span>}
|
||||
{!error && compiled.error && <span class="hint" style="flex:1;">{compiled.error}</span>}
|
||||
<button class="toolbar-btn" onClick={onClose}>Cancel</button>
|
||||
<button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving || loading || !!compiled.error}>
|
||||
{saving ? 'Saving…' : 'Save Signal'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+134
-37
@@ -1,37 +1,58 @@
|
||||
import { h } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import LuaEditor from './LuaEditor';
|
||||
import SearchableSelect from './SearchableSelect';
|
||||
import type { InputRef, SignalDef } from './lib/types';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
// Interface id the wizard was opened from — used to bind panel-scoped signals.
|
||||
currentIfaceId?: string;
|
||||
}
|
||||
|
||||
interface NodeParam {
|
||||
label: string;
|
||||
key: string;
|
||||
type: 'number' | 'text';
|
||||
type: 'number' | 'text' | 'lua';
|
||||
default: string;
|
||||
}
|
||||
|
||||
const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> = [
|
||||
{ type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'factor', type: 'number', default: '1' }] },
|
||||
{ type: 'offset', label: 'Offset', params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] },
|
||||
{ type: 'moving_avg', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'n', type: 'number', default: '10' }] },
|
||||
{ type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'n', type: 'number', default: '10' }] },
|
||||
{ type: 'lowpass', label: 'Lowpass Filter', params: [{ label: 'Cutoff Hz', key: 'cutoff', type: 'number', default: '1' }, { label: 'Sample Hz', key: 'sample_rate', type: 'number', default: '10' }] },
|
||||
{ type: 'highpass', label: 'Highpass Filter', params: [{ label: 'Cutoff Hz', key: 'cutoff', type: 'number', default: '1' }, { label: 'Sample Hz', key: 'sample_rate', type: 'number', default: '10' }] },
|
||||
{ type: 'derivative', label: 'Derivative', params: [] },
|
||||
{ type: 'integral', label: 'Integral', params: [] },
|
||||
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
|
||||
{ type: 'formula', label: 'Formula', params: [{ label: 'Expression (use "x")', key: 'expr', type: 'text', default: 'x * 1.0' }] },
|
||||
{ type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] },
|
||||
{ type: 'offset', label: 'Offset', params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] },
|
||||
{ type: 'moving_average', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
|
||||
{ type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
|
||||
{ type: 'lowpass', label: 'Low-pass Filter', params: [
|
||||
{ label: 'Cutoff freq (Hz)', key: 'freq', type: 'number', default: '1' },
|
||||
{ label: 'Order (1–8)', key: 'order', type: 'number', default: '1' },
|
||||
]},
|
||||
{ type: 'derivative', label: 'Derivative', params: [] },
|
||||
{ type: 'integrate', label: 'Integrate', params: [] },
|
||||
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
|
||||
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a,b,c,d = inputs)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
|
||||
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a,b,c,d = inputs)', key: 'script', type: 'lua', default: 'return a' }] },
|
||||
];
|
||||
|
||||
export default function SyntheticWizard({ onClose, onCreated }: Props) {
|
||||
interface DataSource {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface SignalInfo {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ── Main Wizard ──────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SyntheticWizard({ onClose, onCreated, currentIfaceId }: Props) {
|
||||
const [name, setName] = useState('');
|
||||
const [inputDs, setInputDs] = useState('stub');
|
||||
const [inputSig, setInputSig] = useState('sine_1hz');
|
||||
const [inputs, setInputs] = useState<InputRef[]>([{ ds: 'stub', signal: 'sine_1hz' }]);
|
||||
const [nodeType, setNodeType] = useState('gain');
|
||||
const [params, setParams] = useState<Record<string, string>>({});
|
||||
// Visibility scope. Panel scope requires an interface to bind to, so when the
|
||||
// wizard is opened outside a saved panel it falls back to 'user'.
|
||||
const [visibility, setVisibility] = useState<'panel' | 'user' | 'global'>(currentIfaceId ? 'panel' : 'user');
|
||||
const [unit, setUnit] = useState('');
|
||||
const [desc, setDesc] = useState('');
|
||||
const [dispLow, setDispLow] = useState('0');
|
||||
@@ -39,6 +60,27 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Loaded data
|
||||
const [dataSources, setDataSources] = useState<string[]>([]);
|
||||
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/datasources')
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then((ds: DataSource[]) => setDataSources(ds.map(d => d.name)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function loadSignals(ds: string) {
|
||||
if (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 {}
|
||||
}
|
||||
|
||||
const nodeDef = NODE_TYPES.find(n => n.type === nodeType)!;
|
||||
|
||||
function getParam(key: string, def: string): string {
|
||||
@@ -49,9 +91,29 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) {
|
||||
setParams(p => ({ ...p, [key]: val }));
|
||||
}
|
||||
|
||||
function updateInput(idx: number, patch: Partial<InputRef>) {
|
||||
setInputs(prev => prev.map((inp, i) => {
|
||||
if (i !== idx) return inp;
|
||||
const next = { ...inp, ...patch };
|
||||
if (patch.ds) {
|
||||
next.signal = '';
|
||||
loadSignals(patch.ds);
|
||||
}
|
||||
return next;
|
||||
}));
|
||||
}
|
||||
|
||||
function addInput() {
|
||||
setInputs(prev => [...prev, { ds: dataSources[0] || '', signal: '' }]);
|
||||
}
|
||||
|
||||
function removeInput(idx: number) {
|
||||
setInputs(prev => prev.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!name.trim()) { setError('Name is required'); return; }
|
||||
if (!inputSig.trim()) { setError('Input signal is required'); return; }
|
||||
if (inputs.some(i => !i.ds || !i.signal)) { setError('All inputs must have a data source and signal'); return; }
|
||||
|
||||
const nodeParams: Record<string, any> = {};
|
||||
for (const p of nodeDef.params) {
|
||||
@@ -59,10 +121,9 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) {
|
||||
nodeParams[p.key] = p.type === 'number' ? parseFloat(raw) : raw;
|
||||
}
|
||||
|
||||
const def = {
|
||||
const def: SignalDef = {
|
||||
name: name.trim(),
|
||||
ds: inputDs,
|
||||
signal: inputSig.trim(),
|
||||
inputs,
|
||||
pipeline: [{ type: nodeType, params: nodeParams }],
|
||||
meta: {
|
||||
unit: unit || undefined,
|
||||
@@ -70,6 +131,8 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) {
|
||||
displayLow: parseFloat(dispLow) || 0,
|
||||
displayHigh: parseFloat(dispHigh) || 100,
|
||||
},
|
||||
visibility,
|
||||
...(visibility === 'panel' && currentIfaceId ? { panel: currentIfaceId } : {}),
|
||||
};
|
||||
|
||||
setSaving(true);
|
||||
@@ -112,23 +175,50 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) {
|
||||
onInput={(e) => setName((e.target as HTMLInputElement).value)} />
|
||||
</div>
|
||||
|
||||
<div class="wizard-section-title">Input signal</div>
|
||||
<div class="wizard-field wizard-field-row">
|
||||
<div class="wizard-field">
|
||||
<label>Data source</label>
|
||||
<input class="prop-input" value={inputDs}
|
||||
placeholder="stub"
|
||||
onInput={(e) => setInputDs((e.target as HTMLInputElement).value)} />
|
||||
</div>
|
||||
<div class="wizard-field" style="flex:2;">
|
||||
<label>Signal name</label>
|
||||
<input class="prop-input" value={inputSig}
|
||||
placeholder="sine_1hz"
|
||||
onInput={(e) => setInputSig((e.target as HTMLInputElement).value)} />
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Visibility</label>
|
||||
<select class="prop-select" value={visibility}
|
||||
onChange={(e) => setVisibility((e.target as HTMLSelectElement).value as any)}>
|
||||
<option value="panel" disabled={!currentIfaceId}>
|
||||
This panel only{currentIfaceId ? '' : ' (save panel first)'}
|
||||
</option>
|
||||
<option value="user">All my panels</option>
|
||||
<option value="global">All panels (global)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="wizard-section-title">Processing</div>
|
||||
<div class="wizard-section-title">Input signals</div>
|
||||
{inputs.map((inp, idx) => (
|
||||
<div key={idx} class="wizard-field wizard-field-row" style="align-items: flex-end; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||
<div class="wizard-field" style="flex:1;">
|
||||
<label>Data source</label>
|
||||
<SearchableSelect
|
||||
value={inp.ds}
|
||||
options={dataSources}
|
||||
onSelect={(ds) => updateInput(idx, { ds })}
|
||||
/>
|
||||
</div>
|
||||
<div class="wizard-field" style="flex:2;">
|
||||
<label>Signal</label>
|
||||
<SearchableSelect
|
||||
value={inp.signal}
|
||||
options={dsSignals[inp.ds] || []}
|
||||
onSelect={(signal) => updateInput(idx, { signal })}
|
||||
placeholder={inp.ds ? 'Search signals…' : 'Select data source first'}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="icon-btn"
|
||||
style="margin-bottom: 0.25rem;"
|
||||
title="Remove input"
|
||||
onClick={() => removeInput(idx)}
|
||||
disabled={inputs.length === 1}
|
||||
>✕</button>
|
||||
</div>
|
||||
))}
|
||||
<button class="panel-btn" style="margin-top: 0.5rem;" onClick={addInput}>+ Add input</button>
|
||||
|
||||
<div class="wizard-section-title" style="margin-top: 1.5rem;">Processing</div>
|
||||
<div class="wizard-field">
|
||||
<label>Node type</label>
|
||||
<select class="prop-select" value={nodeType}
|
||||
@@ -139,9 +229,16 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) {
|
||||
{nodeDef.params.map(p => (
|
||||
<div key={p.key} class="wizard-field">
|
||||
<label>{p.label}</label>
|
||||
<input class="prop-input" type={p.type === 'number' ? 'number' : 'text'}
|
||||
value={getParam(p.key, p.default)}
|
||||
onInput={(e) => setParam(p.key, (e.target as HTMLInputElement).value)} />
|
||||
{p.type === 'lua' ? (
|
||||
<LuaEditor
|
||||
value={getParam(p.key, p.default)}
|
||||
onChange={(v) => setParam(p.key, v)}
|
||||
/>
|
||||
) : (
|
||||
<input class="prop-input" type={p.type === 'number' ? 'number' : 'text'}
|
||||
value={getParam(p.key, p.default)}
|
||||
onInput={(e) => setParam(p.key, (e.target as HTMLInputElement).value)} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
+85
-11
@@ -4,12 +4,19 @@ import InterfaceList from './InterfaceList';
|
||||
import Canvas from './Canvas';
|
||||
import { wsClient } from './lib/ws';
|
||||
import { parseInterface } from './lib/xml';
|
||||
import { newPlotPanel } from './lib/templates';
|
||||
import { useAuth, canWrite } from './lib/auth';
|
||||
import type { Interface } from './lib/types';
|
||||
import ContextualHelp from './ContextualHelp';
|
||||
import HelpModal from './HelpModal';
|
||||
import ZoomControl from './ZoomControl';
|
||||
import ControlLogicEditor from './ControlLogicEditor';
|
||||
|
||||
interface Props {
|
||||
onEdit?: (iface?: Interface) => void;
|
||||
initialInterface?: Interface | null;
|
||||
/** Notifies the parent which interface is currently shown (for edit return). */
|
||||
onView?: (iface: Interface | null) => void;
|
||||
}
|
||||
|
||||
/** Format a Date as a datetime-local input value (YYYY-MM-DDTHH:MM) */
|
||||
@@ -18,13 +25,33 @@ function toLocalInput(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
export default function ViewMode({ onEdit }: Props) {
|
||||
const [currentInterface, setCurrentInterface] = useState<Interface | null>(null);
|
||||
export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
||||
const [currentInterface, setCurrentInterface] = useState<Interface | null>(initialInterface ?? null);
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
const [wsStatus, setWsStatus] = useState('connecting');
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [helpSection, setHelpSection] = useState('start');
|
||||
const [showTimeNav, setShowTimeNav] = useState(false);
|
||||
const [showControlLogic, setShowControlLogic] = useState(false);
|
||||
const [leftW, setLeftW] = useState(220);
|
||||
const [listCollapsed, setListCollapsed] = useState(false);
|
||||
const me = useAuth();
|
||||
const writable = canWrite(me.level);
|
||||
|
||||
function startResize(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startW = leftW;
|
||||
function onMove(mv: MouseEvent) {
|
||||
setLeftW(Math.max(140, startW + mv.clientX - startX));
|
||||
}
|
||||
function onUp() {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
}
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
}
|
||||
|
||||
function openHelp(section = 'start') { setHelpSection(section); setShowHelp(true); }
|
||||
|
||||
@@ -39,6 +66,18 @@ export default function ViewMode({ onEdit }: Props) {
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
// When returning from edit mode, show the edited interface
|
||||
useEffect(() => {
|
||||
if (initialInterface) setCurrentInterface(initialInterface);
|
||||
}, [initialInterface]);
|
||||
|
||||
// Auto-hide the interface-selection pane whenever a panel is opened, and keep
|
||||
// the parent informed of what's shown (so closing the editor can return here).
|
||||
useEffect(() => {
|
||||
if (currentInterface) setListCollapsed(true);
|
||||
onView?.(currentInterface);
|
||||
}, [currentInterface]);
|
||||
|
||||
function handleLoad(xml: string) {
|
||||
try {
|
||||
setParseError(null);
|
||||
@@ -100,7 +139,7 @@ export default function ViewMode({ onEdit }: Props) {
|
||||
<div class="toolbar-right">
|
||||
<div class={`status-chip ${wsStatus}`}>
|
||||
<span class="status-dot"></span>
|
||||
{wsStatus}
|
||||
{wsStatus === 'connected' && me.user ? `connected as ${me.user}` : wsStatus}
|
||||
</div>
|
||||
<button
|
||||
class={`toolbar-btn${showTimeNav ? ' toolbar-btn-active' : ''}`}
|
||||
@@ -117,13 +156,30 @@ export default function ViewMode({ onEdit }: Props) {
|
||||
>
|
||||
📖
|
||||
</button>
|
||||
<button
|
||||
class="btn-edit"
|
||||
onClick={() => onEdit?.(currentInterface ?? undefined)}
|
||||
title="Switch to Edit mode"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<ZoomControl />
|
||||
{writable && me.canEditLogic && (
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
onClick={() => setShowControlLogic(true)}
|
||||
title="Open server-side control logic"
|
||||
>
|
||||
⚙ Control logic
|
||||
</button>
|
||||
)}
|
||||
{writable && (
|
||||
<button
|
||||
class="btn-edit"
|
||||
onClick={() => onEdit?.(currentInterface ?? undefined)}
|
||||
title="Switch to Edit mode"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
{me.user && (
|
||||
<span class="user-chip" title={`Signed in as ${me.user}${writable ? '' : ' (read-only)'}`}>
|
||||
{me.user}{!writable && ' (read-only)'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -166,7 +222,11 @@ export default function ViewMode({ onEdit }: Props) {
|
||||
<InterfaceList
|
||||
onLoad={handleLoad}
|
||||
onEdit={onEdit}
|
||||
onNewPlot={() => onEdit?.(newPlotPanel())}
|
||||
onEditId={handleEditById}
|
||||
width={leftW}
|
||||
collapsed={listCollapsed}
|
||||
onToggleCollapse={() => setListCollapsed(c => !c)}
|
||||
onSelect={async (id) => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}`);
|
||||
@@ -177,12 +237,26 @@ export default function ViewMode({ onEdit }: Props) {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Canvas iface={currentInterface} onNavigate={handleNavigate} timeRange={timeRange} />
|
||||
<div class="panel-resize-handle" onMouseDown={startResize} />
|
||||
|
||||
<div class="view-content-area">
|
||||
<div class="view-panel-container active">
|
||||
<Canvas
|
||||
iface={currentInterface}
|
||||
onNavigate={handleNavigate}
|
||||
timeRange={timeRange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showHelp && (
|
||||
<HelpModal initialSection={helpSection} onClose={() => setShowHelp(false)} />
|
||||
)}
|
||||
|
||||
{showControlLogic && (
|
||||
<ControlLogicEditor onClose={() => setShowControlLogic(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { h } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
|
||||
const LS_KEY = 'uopi:ui-zoom';
|
||||
const STEPS = [0.5, 0.6, 0.75, 0.85, 1.0, 1.15, 1.3, 1.5, 1.75, 2.0, 2.5];
|
||||
|
||||
export function getStoredZoom(): number {
|
||||
const v = parseFloat(localStorage.getItem(LS_KEY) ?? '1');
|
||||
return isFinite(v) ? Math.max(STEPS[0], Math.min(STEPS[STEPS.length - 1], v)) : 1;
|
||||
}
|
||||
|
||||
export function applyZoom(z: number): number {
|
||||
const clamped = Math.max(STEPS[0], Math.min(STEPS[STEPS.length - 1], z));
|
||||
localStorage.setItem(LS_KEY, String(clamped));
|
||||
document.documentElement.style.fontSize = `${Math.round(16 * clamped)}px`;
|
||||
return clamped;
|
||||
}
|
||||
|
||||
export default function ZoomControl() {
|
||||
const [zoom, setZoom] = useState(getStoredZoom);
|
||||
|
||||
function step(dir: number) {
|
||||
const idx = STEPS.findIndex(s => s >= zoom - 0.01);
|
||||
const nextIdx = Math.max(0, Math.min(STEPS.length - 1, idx + dir));
|
||||
setZoom(applyZoom(STEPS[nextIdx]));
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="zoom-control" title="UI zoom">
|
||||
<button class="icon-btn zoom-btn" onClick={() => step(-1)} title="Zoom out (smaller UI)">A−</button>
|
||||
<span class="zoom-pct">{Math.round(zoom * 100)}%</span>
|
||||
<button class="icon-btn zoom-btn" onClick={() => step(1)} title="Zoom in (larger UI)">A+</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createContext } from 'preact';
|
||||
import { useContext } from 'preact/hooks';
|
||||
import type { Me, AccessLevel } from './types';
|
||||
|
||||
// Default identity used before /api/v1/me resolves (or if it fails): assume a
|
||||
// trusted LAN user with full access, matching the backend default.
|
||||
export const DEFAULT_ME: Me = { user: '', level: 'write', groups: [], canEditLogic: true };
|
||||
|
||||
// AuthContext carries the resolved identity + global access level for the
|
||||
// current user throughout the app.
|
||||
export const AuthContext = createContext<Me>(DEFAULT_ME);
|
||||
|
||||
export function useAuth(): Me {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
|
||||
// canWrite reports whether the given level permits creating/modifying panels
|
||||
// and writing signal values.
|
||||
export function canWrite(level: AccessLevel): boolean {
|
||||
return level === 'write';
|
||||
}
|
||||
|
||||
// canRead reports whether the given level permits viewing panels at all.
|
||||
export function canRead(level: AccessLevel): boolean {
|
||||
return level !== 'none';
|
||||
}
|
||||
|
||||
// fetchMe loads the caller's identity from the backend. On failure it falls
|
||||
// back to the trusted-LAN default so the UI stays usable in dev/unproxied mode.
|
||||
export async function fetchMe(): Promise<Me> {
|
||||
try {
|
||||
const res = await fetch('/api/v1/me');
|
||||
if (!res.ok) return DEFAULT_ME;
|
||||
const data = await res.json();
|
||||
return {
|
||||
user: typeof data.user === 'string' ? data.user : '',
|
||||
level: (data.level as AccessLevel) ?? 'write',
|
||||
groups: Array.isArray(data.groups) ? data.groups : [],
|
||||
canEditLogic: data.canEditLogic !== false,
|
||||
};
|
||||
} catch {
|
||||
return DEFAULT_ME;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// Small, safe expression evaluator for panel logic.
|
||||
//
|
||||
// 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 panel-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 touches the DOM or eval;
|
||||
// it walks a parsed AST against a caller-supplied resolver.
|
||||
|
||||
export interface RefLite { ds: string; name: string }
|
||||
|
||||
type Node =
|
||||
| { t: 'num'; v: number }
|
||||
| { t: 'sig'; ds: string; name: string }
|
||||
| { t: 'var'; name: string }
|
||||
| { t: 'un'; op: string; a: Node }
|
||||
| { t: 'bin'; op: string; a: Node; b: Node }
|
||||
| { t: 'tern'; c: Node; a: Node; b: Node }
|
||||
| { t: 'call'; fn: string; args: Node[] };
|
||||
|
||||
const FUNCS: Record<string, (a: number[]) => number> = {
|
||||
abs: a => Math.abs(a[0]),
|
||||
min: a => Math.min(...a),
|
||||
max: a => Math.max(...a),
|
||||
sqrt: a => Math.sqrt(a[0]),
|
||||
floor: a => Math.floor(a[0]),
|
||||
ceil: a => Math.ceil(a[0]),
|
||||
round: a => Math.round(a[0]),
|
||||
sign: a => Math.sign(a[0]),
|
||||
pow: a => Math.pow(a[0], a[1]),
|
||||
log: a => Math.log(a[0]),
|
||||
exp: a => Math.exp(a[0]),
|
||||
sin: a => Math.sin(a[0]),
|
||||
cos: a => Math.cos(a[0]),
|
||||
};
|
||||
|
||||
// ── Tokenizer ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Tok { k: string; v?: string }
|
||||
|
||||
function tokenize(src: string): Tok[] {
|
||||
const toks: Tok[] = [];
|
||||
let i = 0;
|
||||
const two = ['<=', '>=', '==', '!=', '&&', '||'];
|
||||
while (i < src.length) {
|
||||
const c = src[i];
|
||||
if (c === ' ' || c === '\t' || c === '\n' || c === '\r') { i++; continue; }
|
||||
// signal ref {ds:name}
|
||||
if (c === '{') {
|
||||
const end = src.indexOf('}', i);
|
||||
if (end < 0) throw new Error('unterminated { in expression');
|
||||
toks.push({ k: 'sig', v: src.slice(i + 1, end) });
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
// number
|
||||
if ((c >= '0' && c <= '9') || (c === '.' && /[0-9]/.test(src[i + 1] ?? ''))) {
|
||||
let j = i + 1;
|
||||
while (j < src.length && /[0-9.]/.test(src[j])) j++;
|
||||
toks.push({ k: 'num', v: src.slice(i, j) });
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
// identifier
|
||||
if (/[A-Za-z_]/.test(c)) {
|
||||
let j = i + 1;
|
||||
while (j < src.length && /[A-Za-z0-9_]/.test(src[j])) j++;
|
||||
toks.push({ k: 'ident', v: src.slice(i, j) });
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
// two-char operators
|
||||
const pair = src.slice(i, i + 2);
|
||||
if (two.includes(pair)) { toks.push({ k: pair }); i += 2; continue; }
|
||||
// single-char operators / punctuation
|
||||
if ('+-*/%<>!()?:,'.includes(c)) { toks.push({ k: c }); i++; continue; }
|
||||
throw new Error(`unexpected character '${c}' in expression`);
|
||||
}
|
||||
return toks;
|
||||
}
|
||||
|
||||
// ── Parser (recursive descent) ────────────────────────────────────────────────
|
||||
|
||||
function parse(src: string): Node {
|
||||
const toks = tokenize(src);
|
||||
let p = 0;
|
||||
const peek = () => toks[p];
|
||||
const eat = (k?: string): Tok => {
|
||||
const t = toks[p];
|
||||
if (!t || (k && t.k !== k)) throw new Error(`expected '${k}' in expression`);
|
||||
p++;
|
||||
return t;
|
||||
};
|
||||
|
||||
function primary(): Node {
|
||||
const t = peek();
|
||||
if (!t) throw new Error('unexpected end of expression');
|
||||
if (t.k === 'num') { eat(); return { t: 'num', v: parseFloat(t.v!) }; }
|
||||
if (t.k === 'sig') {
|
||||
eat();
|
||||
const raw = t.v!;
|
||||
const idx = raw.indexOf(':');
|
||||
const ds = idx < 0 ? raw : raw.slice(0, idx);
|
||||
const name = idx < 0 ? '' : raw.slice(idx + 1);
|
||||
return { t: 'sig', ds, name };
|
||||
}
|
||||
if (t.k === 'ident') {
|
||||
eat();
|
||||
const id = t.v!;
|
||||
if (id === 'true') return { t: 'num', v: 1 };
|
||||
if (id === 'false') return { t: 'num', v: 0 };
|
||||
if (peek()?.k === '(') {
|
||||
eat('(');
|
||||
const args: Node[] = [];
|
||||
if (peek()?.k !== ')') {
|
||||
args.push(ternary());
|
||||
while (peek()?.k === ',') { eat(','); args.push(ternary()); }
|
||||
}
|
||||
eat(')');
|
||||
return { t: 'call', fn: id, args };
|
||||
}
|
||||
return { t: 'var', name: id };
|
||||
}
|
||||
if (t.k === '(') { eat('('); const e = ternary(); eat(')'); return e; }
|
||||
throw new Error(`unexpected token '${t.k}' in expression`);
|
||||
}
|
||||
|
||||
function unary(): Node {
|
||||
const t = peek();
|
||||
if (t && (t.k === '-' || t.k === '!')) { eat(); return { t: 'un', op: t.k, a: unary() }; }
|
||||
return primary();
|
||||
}
|
||||
|
||||
function bin(next: () => Node, ops: string[]): () => Node {
|
||||
return () => {
|
||||
let a = next();
|
||||
while (peek() && ops.includes(peek().k)) {
|
||||
const op = eat().k;
|
||||
a = { t: 'bin', op, a, b: next() };
|
||||
}
|
||||
return a;
|
||||
};
|
||||
}
|
||||
|
||||
const mul = bin(unary, ['*', '/', '%']);
|
||||
const add = bin(mul, ['+', '-']);
|
||||
const cmp = bin(add, ['<', '<=', '>', '>=']);
|
||||
const eq = bin(cmp, ['==', '!=']);
|
||||
const and = bin(eq, ['&&']);
|
||||
const or = bin(and, ['||']);
|
||||
|
||||
function ternary(): Node {
|
||||
const c = or();
|
||||
if (peek()?.k === '?') {
|
||||
eat('?');
|
||||
const a = ternary();
|
||||
eat(':');
|
||||
const b = ternary();
|
||||
return { t: 'tern', c, a, b };
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
const root = ternary();
|
||||
if (p < toks.length) throw new Error('trailing tokens in expression');
|
||||
return root;
|
||||
}
|
||||
|
||||
// Parsed-AST cache so the engine can re-evaluate hot expressions cheaply.
|
||||
const cache = new Map<string, Node | Error>();
|
||||
|
||||
function parseCached(src: string): Node {
|
||||
let n = cache.get(src);
|
||||
if (n === undefined) {
|
||||
try { n = parse(src); } catch (e) { n = e as Error; }
|
||||
cache.set(src, n);
|
||||
}
|
||||
if (n instanceof Error) throw n;
|
||||
return n;
|
||||
}
|
||||
|
||||
// ── Evaluation ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type Resolver = (ds: string, name: string) => number;
|
||||
|
||||
function ev(n: Node, R: Resolver): number {
|
||||
switch (n.t) {
|
||||
case 'num': return n.v;
|
||||
case 'sig': return R(n.ds, n.name);
|
||||
case 'var': return R('local', n.name);
|
||||
case 'un': return n.op === '-' ? -ev(n.a, R) : (ev(n.a, R) === 0 ? 1 : 0);
|
||||
case 'tern': return ev(n.c, R) !== 0 ? ev(n.a, R) : ev(n.b, R);
|
||||
case 'call': {
|
||||
const fn = FUNCS[n.fn];
|
||||
if (!fn) throw new Error(`unknown function '${n.fn}'`);
|
||||
return fn(n.args.map(a => ev(a, R)));
|
||||
}
|
||||
case 'bin': {
|
||||
const a = ev(n.a, R), b = ev(n.b, R);
|
||||
switch (n.op) {
|
||||
case '+': return a + b;
|
||||
case '-': return a - b;
|
||||
case '*': return a * b;
|
||||
case '/': return a / b;
|
||||
case '%': return a % b;
|
||||
case '<': return a < b ? 1 : 0;
|
||||
case '<=': return a <= b ? 1 : 0;
|
||||
case '>': return a > b ? 1 : 0;
|
||||
case '>=': return a >= b ? 1 : 0;
|
||||
case '==': return a === b ? 1 : 0;
|
||||
case '!=': return a !== b ? 1 : 0;
|
||||
case '&&': return (a !== 0 && b !== 0) ? 1 : 0;
|
||||
case '||': return (a !== 0 || b !== 0) ? 1 : 0;
|
||||
default: throw new Error(`unknown operator '${n.op}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Evaluate an expression string. Returns NaN if it cannot be parsed/evaluated. */
|
||||
export function evalExpr(src: string, resolve: Resolver): number {
|
||||
try {
|
||||
return ev(parseCached(src), resolve);
|
||||
} catch {
|
||||
return NaN;
|
||||
}
|
||||
}
|
||||
|
||||
/** True if the expression evaluates to a truthy (nonzero, non-NaN) value. */
|
||||
export function evalBool(src: string, resolve: Resolver): boolean {
|
||||
const v = evalExpr(src, resolve);
|
||||
return !isNaN(v) && v !== 0;
|
||||
}
|
||||
|
||||
/** Collect every signal/local reference an expression reads, for subscription. */
|
||||
export function collectRefs(src: string): RefLite[] {
|
||||
const out: RefLite[] = [];
|
||||
let root: Node;
|
||||
try { root = parseCached(src); } catch { return out; }
|
||||
const seen = new Set<string>();
|
||||
const add = (ds: string, name: string) => {
|
||||
const k = `${ds}\0${name}`;
|
||||
if (!seen.has(k)) { seen.add(k); out.push({ ds, name }); }
|
||||
};
|
||||
const walk = (n: Node) => {
|
||||
switch (n.t) {
|
||||
case 'sig': add(n.ds, n.name); break;
|
||||
case 'var': add('local', n.name); break;
|
||||
case 'un': walk(n.a); break;
|
||||
case 'bin': walk(n.a); walk(n.b); break;
|
||||
case 'tern': walk(n.c); walk(n.a); walk(n.b); break;
|
||||
case 'call': n.args.forEach(walk); break;
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Validate an expression; returns an error message or null if it parses. */
|
||||
export function checkExpr(src: string): string | null {
|
||||
if (!src.trim()) return null;
|
||||
try { parse(src); return null; } catch (e) { return e instanceof Error ? e.message : String(e); }
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Small FFT helpers used by the FFT and waterfall (spectrogram) plot types.
|
||||
// Scalar live signals are irregularly sampled, so callers first resample onto a
|
||||
// uniform grid, then take the magnitude spectrum.
|
||||
|
||||
function nextPow2(n: number): number {
|
||||
let p = 1;
|
||||
while (p < n) p <<= 1;
|
||||
return p;
|
||||
}
|
||||
|
||||
// In-place iterative Cooley-Tukey FFT. Array length must be a power of two.
|
||||
function transform(re: number[], im: number[]): void {
|
||||
const n = re.length;
|
||||
if (n <= 1) return;
|
||||
|
||||
// Bit-reversal permutation.
|
||||
for (let i = 1, j = 0; i < n; i++) {
|
||||
let bit = n >> 1;
|
||||
for (; j & bit; bit >>= 1) j ^= bit;
|
||||
j ^= bit;
|
||||
if (i < j) {
|
||||
const tr = re[i]; re[i] = re[j]; re[j] = tr;
|
||||
const ti = im[i]; im[i] = im[j]; im[j] = ti;
|
||||
}
|
||||
}
|
||||
|
||||
for (let len = 2; len <= n; len <<= 1) {
|
||||
const ang = -2 * Math.PI / len;
|
||||
const wr = Math.cos(ang), wi = Math.sin(ang);
|
||||
for (let i = 0; i < n; i += len) {
|
||||
let cr = 1, ci = 0;
|
||||
for (let k = 0; k < len >> 1; k++) {
|
||||
const a = i + k, b = i + k + (len >> 1);
|
||||
const tr = re[b] * cr - im[b] * ci;
|
||||
const ti = re[b] * ci + im[b] * cr;
|
||||
re[b] = re[a] - tr; im[b] = im[a] - ti;
|
||||
re[a] += tr; im[a] += ti;
|
||||
const ncr = cr * wr - ci * wi;
|
||||
ci = cr * wi + ci * wr;
|
||||
cr = ncr;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Magnitude spectrum (first half, single-sided) of a uniformly sampled signal.
|
||||
// DC is removed and a Hann window applied to reduce spectral leakage.
|
||||
export function fftMag(values: number[]): number[] {
|
||||
const len = values.length;
|
||||
if (len < 2) return [];
|
||||
const n = nextPow2(len);
|
||||
const re = new Array<number>(n).fill(0);
|
||||
const im = new Array<number>(n).fill(0);
|
||||
let mean = 0;
|
||||
for (let i = 0; i < len; i++) mean += values[i];
|
||||
mean /= len;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const w = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (len - 1));
|
||||
re[i] = (values[i] - mean) * w;
|
||||
}
|
||||
transform(re, im);
|
||||
const half = n >> 1;
|
||||
const mag = new Array<number>(half);
|
||||
for (let k = 0; k < half; k++) mag[k] = Math.hypot(re[k], im[k]) / half;
|
||||
return mag;
|
||||
}
|
||||
|
||||
// Linearly resample (ts, vals) onto n evenly spaced points spanning the data.
|
||||
// Returns the sampled series and the uniform sample interval dt (seconds).
|
||||
export function resampleUniform(ts: number[], vals: number[], n: number): { sampled: number[]; dt: number } {
|
||||
if (ts.length < 2) return { sampled: vals.slice(0, n), dt: 0 };
|
||||
const t0 = ts[0];
|
||||
const t1 = ts[ts.length - 1];
|
||||
const dt = (t1 - t0) / (n - 1);
|
||||
const sampled = new Array<number>(n);
|
||||
let j = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const t = t0 + i * dt;
|
||||
while (j < ts.length - 1 && ts[j + 1] <= t) j++;
|
||||
if (j >= ts.length - 1) {
|
||||
sampled[i] = vals[ts.length - 1];
|
||||
} else {
|
||||
const span = ts[j + 1] - ts[j];
|
||||
const frac = span > 0 ? (t - ts[j]) / span : 0;
|
||||
sampled[i] = vals[j] + (vals[j + 1] - vals[j]) * frac;
|
||||
}
|
||||
}
|
||||
return { sampled, dt };
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* formatValue formats a numeric value according to a format string.
|
||||
*
|
||||
* Format string grammar: [width.][precision]type
|
||||
* type f — fixed-point: toFixed(precision)
|
||||
* e — exponential: toExponential(precision)
|
||||
* g — significant: toPrecision(precision), trailing zeros stripped
|
||||
*
|
||||
* Examples: "3f", "0.3f", "2e", "0.1e", "4g"
|
||||
* The optional leading "0." is ignored — only the digits before the type
|
||||
* letter are used as precision.
|
||||
*
|
||||
* An empty or unrecognised format falls back to the default display
|
||||
* (4 significant figures, trailing zeros stripped, exponential for very
|
||||
* large or very small values).
|
||||
*/
|
||||
export function formatValue(v: number | null | undefined, fmt: string): string {
|
||||
if (v === null || v === undefined || typeof v !== 'number') return '---';
|
||||
if (!Number.isFinite(v)) return String(v);
|
||||
|
||||
const s = fmt.trim();
|
||||
if (s) {
|
||||
// Match optional "0." prefix then digits then type letter
|
||||
const m = s.match(/^(?:\d+\.)?(\d+)([fFeEgG])$/);
|
||||
if (m) {
|
||||
const prec = Math.max(0, parseInt(m[1], 10));
|
||||
const type = m[2].toLowerCase();
|
||||
try {
|
||||
switch (type) {
|
||||
case 'f': return v.toFixed(prec);
|
||||
case 'e': return v.toExponential(prec);
|
||||
case 'g': return v.toPrecision(Math.max(1, prec)).replace(/\.?0+$/, '');
|
||||
}
|
||||
} catch {
|
||||
// Fall through to default
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default: 4 significant figures, exponential for extreme magnitudes
|
||||
const a = Math.abs(v);
|
||||
if (a !== 0 && (a >= 1e5 || a < 1e-3)) return v.toExponential(3);
|
||||
return v.toPrecision(4).replace(/\.?0+$/, '');
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Panel-local state variables (ds === 'local').
|
||||
//
|
||||
// Definitions live in the interface XML and travel with the panel, but the live
|
||||
// value is instantiated per browser/panel instance starting from the variable's
|
||||
// initial value — nothing is persisted server-side and no WebSocket traffic is
|
||||
// involved. The panel logic (a future feature) and write-capable widgets can
|
||||
// update these values locally.
|
||||
//
|
||||
// This module deliberately depends only on the store primitives and types so it
|
||||
// can be imported by both stores.ts and ws.ts without creating an import cycle.
|
||||
|
||||
import { writable, type Readable, type Writable } from './store';
|
||||
import type { SignalValue, SignalMeta, StateVar } from './types';
|
||||
|
||||
const valueStores = new Map<string, Writable<SignalValue>>();
|
||||
const metaStores = new Map<string, Writable<SignalMeta | null>>();
|
||||
|
||||
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||
|
||||
function valueW(name: string): Writable<SignalValue> {
|
||||
let s = valueStores.get(name);
|
||||
if (!s) {
|
||||
s = writable<SignalValue>(DEFAULT_VALUE);
|
||||
valueStores.set(name, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function metaW(name: string): Writable<SignalMeta | null> {
|
||||
let s = metaStores.get(name);
|
||||
if (!s) {
|
||||
s = writable<SignalMeta | null>(null);
|
||||
metaStores.set(name, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// coerce parses a state variable's stored string into a live value.
|
||||
function coerce(v: StateVar): any {
|
||||
switch (v.type) {
|
||||
case 'bool':
|
||||
return v.initial === 'true' || v.initial === '1';
|
||||
case 'string':
|
||||
return v.initial;
|
||||
default: {
|
||||
const n = parseFloat(v.initial);
|
||||
return isNaN(n) ? 0 : n;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// initLocalState (re)instantiates every state variable to its initial value and
|
||||
// publishes metadata. Call this whenever a panel is loaded.
|
||||
export function initLocalState(vars: StateVar[] | undefined): void {
|
||||
for (const v of vars ?? []) {
|
||||
valueW(v.name).set({
|
||||
value: coerce(v),
|
||||
quality: 'good',
|
||||
ts: new Date().toISOString(),
|
||||
});
|
||||
metaW(v.name).set({
|
||||
type: v.type ?? 'number',
|
||||
unit: v.unit,
|
||||
displayLow: v.low ?? 0,
|
||||
displayHigh: v.high ?? 100,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function getLocalValueStore(name: string): Readable<SignalValue> {
|
||||
return valueW(name);
|
||||
}
|
||||
|
||||
export function getLocalMetaStore(name: string): Readable<SignalMeta | null> {
|
||||
return metaW(name);
|
||||
}
|
||||
|
||||
// writeLocalState updates a local variable's live value in place.
|
||||
export function writeLocalState(name: string, value: any): void {
|
||||
valueW(name).set({ value, quality: 'good', ts: new Date().toISOString() });
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
// Panel logic engine.
|
||||
//
|
||||
// A panel may define a LogicGraph (see types.ts): a Node-RED-style flow of
|
||||
// trigger, gate, control-flow and action nodes connected by wires. This
|
||||
// singleton engine runs the graph client-side in view mode only: Canvas calls
|
||||
// `load(iface.logic)` when a panel mounts and `clear()` when it unmounts.
|
||||
//
|
||||
// Triggers are flow entry points. When one activates, the engine follows the
|
||||
// outgoing wires executing downstream nodes:
|
||||
// gate.and — passes only when all incoming triggers are satisfied.
|
||||
// flow.if — evaluates `cond`, continues on the 'then' or 'else' port.
|
||||
// flow.loop — repeats the 'body' port (count / while, capped), then 'done'.
|
||||
// action.write— evaluates `expr` and writes the result to `target`.
|
||||
// action.delay— awaits `ms` before continuing.
|
||||
// action.accumulate / export / clear — collect expression values into a named
|
||||
// in-memory array and download it as CSV.
|
||||
// action.log — logs an expression value to the console.
|
||||
//
|
||||
// Expressions reference signals inline as {ds:name} and panel-local vars as
|
||||
// bare identifiers; their values are read from a live cache kept up to date by
|
||||
// subscriptions started at load() for every referenced signal. Two built-in
|
||||
// system signals are served synthetically (never subscribed): {sys:time} is the
|
||||
// current epoch time in seconds and {sys:dt} is the seconds elapsed since the
|
||||
// firing trigger last fired (useful in On change / Timer / Panel loop flows,
|
||||
// e.g. to compute a rate).
|
||||
|
||||
import { wsClient } from './ws';
|
||||
import { getSignalStore } from './stores';
|
||||
import { writable } from './store';
|
||||
import { setWidgetDisabled, setWidgetHidden, setPlotPaused, clearPlot, resetWidgetCmds } from './widgetCommands';
|
||||
import type { SignalRef, SignalValue, LogicGraph, LogicNode } from './types';
|
||||
import { evalExpr, evalBool, collectRefs, type Resolver } from './expr';
|
||||
|
||||
// A single row in a dialog: either an `input` (prompts the operator for a value
|
||||
// that is later written to its target) or a `display` (a read-only live value
|
||||
// shown to the operator). A dialog may carry any number of both.
|
||||
export interface DialogField {
|
||||
type: 'input' | 'display';
|
||||
label: string;
|
||||
// input: the pre-filled default text; display: the resolved value to show.
|
||||
value: string;
|
||||
}
|
||||
|
||||
// A pending user-interaction dialog requested by an action.dialog.* node. The
|
||||
// engine pushes one to `logicDialogs` and awaits the user's response; the
|
||||
// LogicDialogs component renders it and calls `resolve` with the entered values
|
||||
// for the input fields (in field order), or null for Cancel. Info/error
|
||||
// dialogs have no inputs and resolve with an empty array on OK.
|
||||
export interface DialogRequest {
|
||||
id: string;
|
||||
kind: 'info' | 'error' | 'setpoint';
|
||||
title: string;
|
||||
message: string;
|
||||
fields: DialogField[];
|
||||
resolve: (entered: string[] | null) => void;
|
||||
}
|
||||
|
||||
// Active dialogs, rendered by <LogicDialogs/>. Cleared when the panel unmounts.
|
||||
export const logicDialogs = writable<DialogRequest[]>([]);
|
||||
|
||||
// Guards against runaway flows (cycles / pathological loops).
|
||||
const MAX_STEPS = 100000;
|
||||
const MAX_LOOP = 100000;
|
||||
|
||||
// Split a "ds:name" reference on the FIRST ':' only (EPICS PV names contain ':').
|
||||
function parseRef(target: string): SignalRef | null {
|
||||
const t = target.trim();
|
||||
if (!t) return null;
|
||||
const i = t.indexOf(':');
|
||||
if (i < 0) return { ds: 'local', name: t }; // bare name → panel-local var
|
||||
return { ds: t.slice(0, i), name: t.slice(i + 1) };
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function toNum(v: any): number {
|
||||
if (typeof v === 'number') return v;
|
||||
if (typeof v === 'boolean') return v ? 1 : 0;
|
||||
const n = parseFloat(v);
|
||||
return isNaN(n) ? NaN : n;
|
||||
}
|
||||
|
||||
function refKey(ds: string, name: string): string {
|
||||
return `${ds}\0${name}`;
|
||||
}
|
||||
|
||||
// A dialog field as configured on a node (before its value is resolved).
|
||||
interface DialogFieldSpec {
|
||||
type: 'input' | 'display';
|
||||
label: string;
|
||||
target?: string; // input: "ds:name" (or bare local var) to write on OK
|
||||
default?: string; // input: expression pre-filling the input
|
||||
expr?: string; // display: expression evaluated and shown
|
||||
}
|
||||
|
||||
// Linearly interpolate a value at time `t` from timestamp-sorted samples. Returns
|
||||
// null when `t` falls outside the samples' own time range (no extrapolation).
|
||||
function interpAt(samples: Array<{ t: number; v: number }>, t: number): number | null {
|
||||
const n = samples.length;
|
||||
if (n === 0 || t < samples[0].t || t > samples[n - 1].t) return null;
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
const a = samples[i], b = samples[i + 1];
|
||||
if (t >= a.t && t <= b.t) {
|
||||
if (b.t === a.t) return a.v;
|
||||
return a.v + (b.v - a.v) * (t - a.t) / (b.t - a.t);
|
||||
}
|
||||
}
|
||||
return samples[n - 1].v;
|
||||
}
|
||||
|
||||
// Escape a CSV cell: quote and double inner quotes when it contains a comma,
|
||||
// quote, or newline.
|
||||
function csvEsc(s: string): string {
|
||||
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
||||
}
|
||||
|
||||
interface WireOut { to: string; port: string }
|
||||
// One flow run, started by an activating trigger. `dt` is the seconds elapsed
|
||||
// since that trigger last fired (0 on its first fire); `resolve` is an
|
||||
// expression resolver that exposes the system signals {sys:time}/{sys:dt}.
|
||||
interface RunCtx { firedTrigger: string; steps: { n: number }; dt: number; resolve: Resolver }
|
||||
|
||||
class LogicEngine {
|
||||
private graph: LogicGraph = { nodes: [], wires: [] };
|
||||
private byId = new Map<string, LogicNode>();
|
||||
// Outgoing wires grouped by source node id.
|
||||
private out = new Map<string, WireOut[]>();
|
||||
// Incoming source node ids grouped by target node id (for gate evaluation).
|
||||
private incoming = new Map<string, string[]>();
|
||||
// Live signal value cache (key "ds\0name"), kept fresh by subscriptions.
|
||||
private live = new Map<string, any>();
|
||||
// Current truth of each level-style trigger (threshold), for AND-gates.
|
||||
private levelState = new Map<string, boolean>();
|
||||
// Per-node previous values for edge/change detection.
|
||||
private prevBool = new Map<string, boolean>();
|
||||
private prevVal = new Map<string, any>();
|
||||
// signal key → trigger node ids that react to it (threshold / change).
|
||||
private watchers = new Map<string, string[]>();
|
||||
// Named in-memory data arrays, filled by action.accumulate and dumped by
|
||||
// action.export. Each sample keeps the wall-clock time it was recorded.
|
||||
private arrays = new Map<string, Array<{ t: number; v: number }>>();
|
||||
// Wall-clock time (ms) each trigger last activated, for the {sys:dt} signal.
|
||||
private lastFire = new Map<string, number>();
|
||||
private cleanups: Array<() => void> = [];
|
||||
|
||||
// Resolve an expression reference. Two built-in system signals are served
|
||||
// synthetically (never subscribed): {sys:time} = current epoch seconds,
|
||||
// {sys:dt} = seconds since the firing trigger last fired. Everything else
|
||||
// reads from the live signal cache.
|
||||
private sysResolve(ds: string, name: string, dt: number): number {
|
||||
if (ds === 'sys') {
|
||||
if (name === 'time') return Date.now() / 1000;
|
||||
if (name === 'dt') return dt;
|
||||
return NaN;
|
||||
}
|
||||
return toNum(this.live.get(refKey(ds, name)));
|
||||
}
|
||||
|
||||
/** Activate a panel's logic graph. Replaces any previously loaded graph. */
|
||||
load(graph: LogicGraph | undefined): void {
|
||||
this.clear();
|
||||
this.graph = graph ?? { nodes: [], wires: [] };
|
||||
|
||||
this.byId = new Map(this.graph.nodes.map(n => [n.id, n]));
|
||||
this.out = new Map();
|
||||
this.incoming = new Map();
|
||||
for (const w of this.graph.wires) {
|
||||
(this.out.get(w.from) ?? this.out.set(w.from, []).get(w.from)!)
|
||||
.push({ to: w.to, port: w.fromPort ?? 'out' });
|
||||
(this.incoming.get(w.to) ?? this.incoming.set(w.to, []).get(w.to)!)
|
||||
.push(w.from);
|
||||
}
|
||||
|
||||
this.subscribeRefs();
|
||||
|
||||
for (const node of this.graph.nodes) {
|
||||
switch (node.kind) {
|
||||
case 'trigger.timer': {
|
||||
const id = setInterval(() => this.activate(node.id), this.intervalOf(node));
|
||||
this.cleanups.push(() => clearInterval(id));
|
||||
break;
|
||||
}
|
||||
case 'trigger.loop': {
|
||||
this.activate(node.id); // run once on load
|
||||
const id = setInterval(() => this.activate(node.id), this.intervalOf(node));
|
||||
this.cleanups.push(() => clearInterval(id));
|
||||
break;
|
||||
}
|
||||
// button/threshold/change are driven imperatively / by subscriptions.
|
||||
}
|
||||
}
|
||||
|
||||
// Panel-open lifecycle: fire every "On open" trigger once now that the
|
||||
// graph is wired and subscriptions are live.
|
||||
for (const node of this.graph.nodes) {
|
||||
if (node.kind === 'trigger.start') this.activate(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Deactivate all triggers and subscriptions. Called when a panel unmounts. */
|
||||
clear(): void {
|
||||
// Panel-close lifecycle: fire every "On close" trigger before tearing down,
|
||||
// while the live cache and wiring are still intact (synchronous writes run
|
||||
// immediately; anything past an await is truncated as the graph resets).
|
||||
for (const node of this.graph.nodes) {
|
||||
if (node.kind === 'trigger.stop') this.activate(node.id);
|
||||
}
|
||||
// Dismiss any dialogs left open by the panel being closed (resolve null).
|
||||
for (const d of logicDialogs.get()) { try { d.resolve(null); } catch {} }
|
||||
logicDialogs.set([]);
|
||||
|
||||
for (const c of this.cleanups) { try { c(); } catch {} }
|
||||
this.cleanups = [];
|
||||
this.graph = { nodes: [], wires: [] };
|
||||
this.byId = new Map();
|
||||
this.out = new Map();
|
||||
this.incoming = new Map();
|
||||
this.live = new Map();
|
||||
this.levelState = new Map();
|
||||
this.prevBool = new Map();
|
||||
this.prevVal = new Map();
|
||||
this.watchers = new Map();
|
||||
this.arrays = new Map();
|
||||
this.lastFire = new Map();
|
||||
// Restore every widget to its default state so reopening a panel (whose
|
||||
// widget ids are stable) does not inherit disabled/hidden/paused flags.
|
||||
resetWidgetCmds();
|
||||
}
|
||||
|
||||
/** Fire every button-trigger node whose `name` param matches. */
|
||||
runAction(name: string): void {
|
||||
if (!name) return;
|
||||
for (const node of this.graph.nodes) {
|
||||
if (node.kind === 'trigger.button' && (node.params.name ?? '') === name) {
|
||||
this.activate(node.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Subscriptions ──────────────────────────────────────────────────────────
|
||||
|
||||
// Subscribe to every signal referenced anywhere in the graph (explicit
|
||||
// threshold/change signals + inline {ds:name}/local refs in expressions),
|
||||
// feeding the live cache and waking threshold/change triggers.
|
||||
private subscribeRefs(): void {
|
||||
const refs = new Map<string, SignalRef>();
|
||||
// {sys:*} signals are served synthetically by sysResolve — never subscribed.
|
||||
const want = (r: SignalRef) => { if (r.ds === 'sys') return; if (r.name || r.ds === 'local') refs.set(refKey(r.ds, r.name), r); };
|
||||
|
||||
for (const node of this.graph.nodes) {
|
||||
switch (node.kind) {
|
||||
case 'trigger.threshold':
|
||||
case 'trigger.change': {
|
||||
const r = parseRef(node.params.signal ?? '');
|
||||
if (r) {
|
||||
want(r);
|
||||
const key = refKey(r.ds, r.name);
|
||||
(this.watchers.get(key) ?? this.watchers.set(key, []).get(key)!).push(node.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'flow.if': collectRefs(node.params.cond ?? '').forEach(want); break;
|
||||
case 'flow.loop': if ((node.params.mode ?? 'count') === 'while') collectRefs(node.params.cond ?? '').forEach(want); break;
|
||||
case 'action.write':
|
||||
case 'action.accumulate':
|
||||
case 'action.log': collectRefs(node.params.expr ?? '').forEach(want); break;
|
||||
case 'action.dialog.info':
|
||||
case 'action.dialog.error':
|
||||
case 'action.dialog.setpoint': {
|
||||
this.messageRefs(node.params.message ?? '').forEach(want);
|
||||
this.messageRefs(node.params.title ?? '').forEach(want);
|
||||
for (const f of this.dialogFieldSpecs(node)) {
|
||||
if (f.type === 'display') collectRefs(f.expr ?? '').forEach(want);
|
||||
else collectRefs(f.default ?? '').forEach(want);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, ref] of refs) {
|
||||
const unsub = getSignalStore(ref).subscribe((v: SignalValue) => {
|
||||
this.live.set(key, v.value);
|
||||
this.onSignal(key, v.value);
|
||||
});
|
||||
this.cleanups.push(unsub);
|
||||
}
|
||||
}
|
||||
|
||||
// Drive threshold (rising edge) and change triggers when a watched signal updates.
|
||||
private onSignal(key: string, value: any): void {
|
||||
for (const id of this.watchers.get(key) ?? []) {
|
||||
const node = this.byId.get(id);
|
||||
if (!node) continue;
|
||||
if (node.kind === 'trigger.threshold') {
|
||||
const cur = this.testThreshold(toNum(value), node.params.op ?? '>', toNum(node.params.value ?? '0'));
|
||||
this.levelState.set(id, cur);
|
||||
if (cur && !(this.prevBool.get(id) ?? false)) this.activate(id);
|
||||
this.prevBool.set(id, cur);
|
||||
} else if (node.kind === 'trigger.change') {
|
||||
const had = this.prevVal.has(id);
|
||||
const prev = this.prevVal.get(id);
|
||||
this.prevVal.set(id, value);
|
||||
if (had && value !== prev) this.activate(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private testThreshold(val: number, op: string, cmp: number): boolean {
|
||||
if (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;
|
||||
case '!=': return val !== cmp;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
private intervalOf(node: LogicNode): number {
|
||||
return Math.max(50, parseInt(node.params.interval ?? '1000', 10) || 1000);
|
||||
}
|
||||
|
||||
// ── Execution ───────────────────────────────────────────────────────────────
|
||||
|
||||
// A trigger activated: walk its outgoing 'out' wires.
|
||||
private activate(triggerId: string): void {
|
||||
const now = Date.now();
|
||||
const last = this.lastFire.get(triggerId);
|
||||
const dt = last === undefined ? 0 : (now - last) / 1000;
|
||||
this.lastFire.set(triggerId, now);
|
||||
const ctx: RunCtx = {
|
||||
firedTrigger: triggerId,
|
||||
steps: { n: 0 },
|
||||
dt,
|
||||
resolve: (ds, name) => this.sysResolve(ds, name, dt),
|
||||
};
|
||||
void this.follow(triggerId, 'out', ctx);
|
||||
}
|
||||
|
||||
// Run every wire leaving `fromId` on output port `port`.
|
||||
private async follow(fromId: string, port: string, ctx: RunCtx): Promise<void> {
|
||||
for (const w of this.out.get(fromId) ?? []) {
|
||||
if (w.port === port) await this.run(w.to, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute one node, then continue downstream as appropriate.
|
||||
private async run(nodeId: string, ctx: RunCtx): Promise<void> {
|
||||
if (ctx.steps.n++ > MAX_STEPS) return;
|
||||
const node = this.byId.get(nodeId);
|
||||
if (!node) return;
|
||||
|
||||
switch (node.kind) {
|
||||
case 'gate.and':
|
||||
if (this.gateSatisfied(node.id, ctx.firedTrigger)) await this.follow(node.id, 'out', ctx);
|
||||
return;
|
||||
|
||||
case 'flow.if': {
|
||||
const branch = evalBool(node.params.cond ?? '', ctx.resolve) ? 'then' : 'else';
|
||||
await this.follow(node.id, branch, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'flow.loop': {
|
||||
const mode = node.params.mode ?? 'count';
|
||||
if (mode === 'count') {
|
||||
const n = Math.min(MAX_LOOP, Math.max(0, Math.floor(toNum(node.params.count ?? '0')) || 0));
|
||||
for (let i = 0; i < n && ctx.steps.n <= MAX_STEPS; i++) await this.follow(node.id, 'body', ctx);
|
||||
} else {
|
||||
let i = 0;
|
||||
while (i < MAX_LOOP && ctx.steps.n <= MAX_STEPS && evalBool(node.params.cond ?? '', ctx.resolve)) {
|
||||
await this.follow(node.id, 'body', ctx);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
await this.follow(node.id, 'done', ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'action.write': {
|
||||
const ref = parseRef(node.params.target ?? '');
|
||||
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
|
||||
if (ref && !isNaN(val)) wsClient.write(ref, val);
|
||||
await this.follow(node.id, 'out', ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'action.delay':
|
||||
await sleep(Math.max(0, parseInt(node.params.ms ?? '0', 10) || 0));
|
||||
await this.follow(node.id, 'out', ctx);
|
||||
return;
|
||||
|
||||
case 'action.accumulate': {
|
||||
const name = (node.params.array ?? '').trim();
|
||||
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
|
||||
if (name && !isNaN(val)) {
|
||||
const arr = this.arrays.get(name) ?? this.arrays.set(name, []).get(name)!;
|
||||
arr.push({ t: Date.now(), v: val });
|
||||
}
|
||||
await this.follow(node.id, 'out', ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'action.export': {
|
||||
this.exportArrays(this.exportColumns(node), node.params.align ?? 'common', node.params.filename ?? '');
|
||||
await this.follow(node.id, 'out', ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'action.clear': {
|
||||
const name = (node.params.array ?? '').trim();
|
||||
if (name) this.arrays.delete(name);
|
||||
await this.follow(node.id, 'out', ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'action.log': {
|
||||
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
|
||||
const label = (node.params.label ?? '').trim();
|
||||
console.log(`[logic]${label ? ' ' + label + ':' : ''}`, val);
|
||||
await this.follow(node.id, 'out', ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'action.widget': {
|
||||
const wid = (node.params.widget ?? '').trim();
|
||||
if (wid) {
|
||||
switch (node.params.op ?? '') {
|
||||
case 'enable': setWidgetDisabled(wid, false); break;
|
||||
case 'disable': setWidgetDisabled(wid, true); break;
|
||||
case 'show': setWidgetHidden(wid, false); break;
|
||||
case 'hide': setWidgetHidden(wid, true); break;
|
||||
case 'pauseplot': setPlotPaused(wid, true); break;
|
||||
case 'resumeplot': setPlotPaused(wid, false); break;
|
||||
case 'clearplot': clearPlot(wid); break;
|
||||
}
|
||||
}
|
||||
await this.follow(node.id, 'out', ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'action.dialog.info':
|
||||
case 'action.dialog.error': {
|
||||
const specs = this.dialogFieldSpecs(node);
|
||||
await this.showDialog({
|
||||
kind: node.kind === 'action.dialog.error' ? 'error' : 'info',
|
||||
title: this.interpolate(node.params.title ?? '', ctx.resolve),
|
||||
message: this.interpolate(node.params.message ?? '', ctx.resolve),
|
||||
fields: this.dialogFieldViews(specs, ctx),
|
||||
});
|
||||
await this.follow(node.id, 'out', ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'action.dialog.setpoint': {
|
||||
const specs = this.dialogFieldSpecs(node);
|
||||
const result = await this.showDialog({
|
||||
kind: 'setpoint',
|
||||
title: this.interpolate(node.params.title ?? '', ctx.resolve),
|
||||
message: this.interpolate(node.params.message ?? '', ctx.resolve),
|
||||
fields: this.dialogFieldViews(specs, ctx),
|
||||
});
|
||||
// Cancel (null) aborts the flow; OK writes every input value and continues.
|
||||
if (result === null) return;
|
||||
let i = 0;
|
||||
for (const spec of specs) {
|
||||
if (spec.type !== 'input') continue;
|
||||
const ref = parseRef(spec.target ?? '');
|
||||
const num = parseFloat(result[i++] ?? '');
|
||||
if (ref && !isNaN(num)) wsClient.write(ref, num);
|
||||
}
|
||||
await this.follow(node.id, 'out', ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
default:
|
||||
// A trigger reached mid-walk (unusual): just pass through.
|
||||
await this.follow(node.id, 'out', ctx);
|
||||
}
|
||||
}
|
||||
|
||||
// The columns an action.export node should emit. Reads the JSON `columns`
|
||||
// param (a list of { array, label }); falls back to the legacy single `array`.
|
||||
private exportColumns(node: LogicNode): Array<{ array: string; label: string }> {
|
||||
const raw = (node.params.columns ?? '').trim();
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed
|
||||
.map((c: any) => ({ array: String(c?.array ?? '').trim(), label: String(c?.label ?? '').trim() }))
|
||||
.filter(c => c.array);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
const a = (node.params.array ?? '').trim();
|
||||
return a ? [{ array: a, label: a }] : [];
|
||||
}
|
||||
|
||||
// Dump one or more named arrays to a CSV the browser downloads. Each column is
|
||||
// a captured array; rows are keyed by sample timestamp. `align` controls how
|
||||
// arrays with differing timestamps are combined:
|
||||
// common — only timestamps present in every array (intersection).
|
||||
// any — the union of all timestamps; cells with no sample are blank.
|
||||
// interpolate — the union of all timestamps; a missing cell is linearly
|
||||
// interpolated between the array's surrounding samples (blank
|
||||
// outside the array's own time range).
|
||||
private exportArrays(cols: Array<{ array: string; label: string }>, align: string, filename: string): void {
|
||||
if (cols.length === 0) return;
|
||||
const series = cols.map((c, i) => ({
|
||||
label: c.label || c.array || `col${i + 1}`,
|
||||
samples: (this.arrays.get(c.array) ?? []).slice().sort((a, b) => a.t - b.t),
|
||||
map: new Map<number, number>(),
|
||||
}));
|
||||
for (const s of series) for (const p of s.samples) s.map.set(p.t, p.v);
|
||||
|
||||
const tsSet = new Set<number>();
|
||||
for (const s of series) for (const p of s.samples) tsSet.add(p.t);
|
||||
let times = Array.from(tsSet).sort((a, b) => a - b);
|
||||
if (align === 'common') times = times.filter(t => series.every(s => s.map.has(t)));
|
||||
|
||||
const cell = (s: typeof series[number], t: number): string => {
|
||||
const exact = s.map.get(t);
|
||||
if (exact !== undefined) return String(exact);
|
||||
if (align === 'interpolate') {
|
||||
const v = interpAt(s.samples, t);
|
||||
return v === null ? '' : String(v);
|
||||
}
|
||||
return ''; // 'any' (and 'common' never reaches here)
|
||||
};
|
||||
|
||||
const header = ['timestamp_ms', 'iso', ...series.map(s => s.label)];
|
||||
const rows = times.map(t => [String(t), new Date(t).toISOString(), ...series.map(s => cell(s, t))]);
|
||||
const csv = [header, ...rows].map(r => r.map(csvEsc).join(',')).join('\n') + '\n';
|
||||
|
||||
const base = filename || series.map(s => s.label).join('_') || 'export';
|
||||
const safe = base.replace(/[^a-z0-9_.-]/gi, '_');
|
||||
const fname = safe.toLowerCase().endsWith('.csv') ? safe : `${safe}.csv`;
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fname;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ── Dialogs (user interaction) ───────────────────────────────────────────────
|
||||
|
||||
// The field specs of a dialog node. Reads the JSON `fields` param (a list of
|
||||
// { type:'input'|'display', label, target?, default?, expr? }); for a legacy
|
||||
// set-point node with no `fields` it falls back to the single target/default.
|
||||
private dialogFieldSpecs(node: LogicNode): DialogFieldSpec[] {
|
||||
const raw = (node.params.fields ?? '').trim();
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((f: any) => ({
|
||||
type: f?.type === 'display' ? 'display' : 'input',
|
||||
label: String(f?.label ?? ''),
|
||||
target: String(f?.target ?? ''),
|
||||
default: String(f?.default ?? ''),
|
||||
expr: String(f?.expr ?? ''),
|
||||
}));
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (node.kind === 'action.dialog.setpoint') {
|
||||
return [{ type: 'input', label: '', target: node.params.target ?? '', default: node.params.default ?? '', expr: '' }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// Resolve a node's field specs into displayable views: display fields get their
|
||||
// expression evaluated to text; input fields get their default expression
|
||||
// evaluated to a pre-fill string ('' when no default is set).
|
||||
private dialogFieldViews(specs: DialogFieldSpec[], ctx: RunCtx): DialogField[] {
|
||||
return specs.map(s => {
|
||||
if (s.type === 'display') {
|
||||
const v = evalExpr(s.expr ?? '', ctx.resolve);
|
||||
return { type: 'display' as const, label: s.label, value: isNaN(v) ? '?' : String(v) };
|
||||
}
|
||||
const def = (s.default ?? '').trim() ? String(evalExpr(s.default ?? '', ctx.resolve)) : '';
|
||||
return { type: 'input' as const, label: s.label, value: def };
|
||||
});
|
||||
}
|
||||
|
||||
// Push a dialog request to the shared store and resolve when the user responds
|
||||
// (the entered input values on OK, null on Cancel).
|
||||
private showDialog(req: Omit<DialogRequest, 'id' | 'resolve'>): Promise<string[] | null> {
|
||||
return new Promise((resolve) => {
|
||||
const id = 'dlg_' + Math.random().toString(36).slice(2, 9);
|
||||
const full: DialogRequest = {
|
||||
...req,
|
||||
id,
|
||||
resolve: (r) => { logicDialogs.update(list => list.filter(d => d.id !== id)); resolve(r); },
|
||||
};
|
||||
logicDialogs.update(list => [...list, full]);
|
||||
});
|
||||
}
|
||||
|
||||
// Substitute {ds:name} (and {sys:*}) tokens in dialog text with live values.
|
||||
private interpolate(text: string, resolve: Resolver): string {
|
||||
return (text ?? '').replace(/\{([^}]*)\}/g, (_m, inner) => {
|
||||
const v = evalExpr('{' + inner + '}', resolve);
|
||||
return isNaN(v) ? '?' : String(v);
|
||||
});
|
||||
}
|
||||
|
||||
// Signals referenced by {…} tokens in dialog message/title text.
|
||||
private messageRefs(text: string): SignalRef[] {
|
||||
const out: SignalRef[] = [];
|
||||
for (const m of (text ?? '').matchAll(/\{([^}]*)\}/g)) {
|
||||
const r = parseRef(m[1]);
|
||||
if (r) out.push(r);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// An AND-gate fires when every incoming trigger is currently satisfied: the
|
||||
// activating trigger counts as satisfied, and level triggers (threshold) use
|
||||
// their current truth. Momentary triggers that are not firing now are false.
|
||||
private gateSatisfied(gateId: string, firedTrigger: string): boolean {
|
||||
const inputs = this.incoming.get(gateId) ?? [];
|
||||
if (inputs.length === 0) return false;
|
||||
return inputs.every(src => src === firedTrigger || this.levelState.get(src) === true);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance, mirrored on wsClient's module-level pattern.
|
||||
export const logicEngine = new LogicEngine();
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { PlotLayout } from './types';
|
||||
|
||||
/** Collect all widget ids referenced by leaves, in layout order. */
|
||||
export function collectWidgetIds(layout: PlotLayout): string[] {
|
||||
if (layout.type === 'leaf') return [layout.widget];
|
||||
return [...collectWidgetIds(layout.a), ...collectWidgetIds(layout.b)];
|
||||
}
|
||||
|
||||
/** Count the number of leaves (panes). */
|
||||
export function countLeaves(layout: PlotLayout): number {
|
||||
if (layout.type === 'leaf') return 1;
|
||||
return countLeaves(layout.a) + countLeaves(layout.b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the leaf for `widgetId` with a split: the existing leaf becomes child
|
||||
* `a` and a new leaf for `newWidgetId` becomes child `b` (ratio 0.5).
|
||||
*/
|
||||
export function splitLeaf(
|
||||
layout: PlotLayout,
|
||||
widgetId: string,
|
||||
dir: 'h' | 'v',
|
||||
newWidgetId: string,
|
||||
): PlotLayout {
|
||||
if (layout.type === 'leaf') {
|
||||
if (layout.widget !== widgetId) return layout;
|
||||
return {
|
||||
type: 'split',
|
||||
dir,
|
||||
ratio: 0.5,
|
||||
a: { type: 'leaf', widget: widgetId },
|
||||
b: { type: 'leaf', widget: newWidgetId },
|
||||
};
|
||||
}
|
||||
return {
|
||||
...layout,
|
||||
a: splitLeaf(layout.a, widgetId, dir, newWidgetId),
|
||||
b: splitLeaf(layout.b, widgetId, dir, newWidgetId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the leaf for `widgetId`, collapsing its parent split into the sibling
|
||||
* subtree. Returns the original layout if the leaf is the sole remaining pane.
|
||||
*/
|
||||
export function removeLeaf(layout: PlotLayout, widgetId: string): PlotLayout {
|
||||
if (layout.type === 'leaf') return layout; // can't remove the root leaf
|
||||
// If a direct child is the target leaf, collapse to the sibling.
|
||||
if (layout.a.type === 'leaf' && layout.a.widget === widgetId) return layout.b;
|
||||
if (layout.b.type === 'leaf' && layout.b.widget === widgetId) return layout.a;
|
||||
return {
|
||||
...layout,
|
||||
a: removeLeaf(layout.a, widgetId),
|
||||
b: removeLeaf(layout.b, widgetId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the ratio of the split node addressed by `path` (a list of 0/1 = a/b
|
||||
* choices from the root).
|
||||
*/
|
||||
export function setRatioAtPath(
|
||||
layout: PlotLayout,
|
||||
path: number[],
|
||||
ratio: number,
|
||||
): PlotLayout {
|
||||
if (layout.type === 'leaf') return layout;
|
||||
if (path.length === 0) return { ...layout, ratio };
|
||||
const [head, ...rest] = path;
|
||||
if (head === 0) return { ...layout, a: setRatioAtPath(layout.a, rest, ratio) };
|
||||
return { ...layout, b: setRatioAtPath(layout.b, rest, ratio) };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readable, writable, type Readable } from './store';
|
||||
import { wsClient } from './ws';
|
||||
import { getLocalValueStore, getLocalMetaStore } from './localstate';
|
||||
import type { SignalRef, SignalValue, SignalMeta } from './types';
|
||||
|
||||
// ── Default values ────────────────────────────────────────────────────────────
|
||||
@@ -52,6 +53,9 @@ function refKey(ref: SignalRef): string {
|
||||
* The store stays alive once created (simplest correct behaviour for Phase 4).
|
||||
*/
|
||||
export function getSignalStore(ref: SignalRef): Readable<SignalValue> {
|
||||
// Panel-local variables are served entirely client-side (no WS subscription).
|
||||
if (ref.ds === 'local') return getLocalValueStore(ref.name);
|
||||
|
||||
const key = refKey(ref);
|
||||
const existing = valueStores.get(key);
|
||||
if (existing) return existing;
|
||||
@@ -72,6 +76,8 @@ export function getSignalStore(ref: SignalRef): Readable<SignalValue> {
|
||||
* Metadata is sent once by the server on subscribe.
|
||||
*/
|
||||
export function getMetaStore(ref: SignalRef): Readable<SignalMeta | null> {
|
||||
if (ref.ds === 'local') return getLocalMetaStore(ref.name);
|
||||
|
||||
const key = refKey(ref);
|
||||
const existing = metaStores.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Interface } from './types';
|
||||
|
||||
/**
|
||||
* Build a blank "plot panel": a special interface whose viewport is filled by
|
||||
* plots arranged in a recursive split layout. It starts with a single
|
||||
* full-viewport plot; the user splits panes and drops signals in the editor.
|
||||
*/
|
||||
export function newPlotPanel(): Interface {
|
||||
const wid = `w${Date.now()}`;
|
||||
return {
|
||||
id: '',
|
||||
name: 'New Plot Panel',
|
||||
version: 1,
|
||||
w: 1200,
|
||||
h: 800,
|
||||
kind: 'plot',
|
||||
layout: { type: 'leaf', widget: wid },
|
||||
widgets: [
|
||||
{
|
||||
id: wid,
|
||||
type: 'plot',
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 800,
|
||||
h: 500,
|
||||
signals: [],
|
||||
options: { plotType: 'timeseries', timeWindow: '60', legend: 'bottom' },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -20,6 +20,8 @@ export interface SignalMeta {
|
||||
writable: boolean;
|
||||
enumStrings?: string[];
|
||||
description?: string;
|
||||
properties?: Record<string, string>;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
// A single widget in an interface
|
||||
@@ -40,6 +42,139 @@ export interface HistoryPoint {
|
||||
value: any;
|
||||
}
|
||||
|
||||
// A node in the user-defined signal group tree.
|
||||
// Folders contain children (other folders or signals); signals are leaf nodes.
|
||||
export type GroupNode =
|
||||
| { kind: 'folder'; id: string; label: string; children: GroupNode[] }
|
||||
| { kind: 'signal'; ds: string; name: string };
|
||||
|
||||
// Recursive split-layout tree for plot panels. Leaves reference a plot widget
|
||||
// by id; splits divide their container into child `a` (fraction `ratio`) and
|
||||
// child `b` (the remainder). dir 'h' = left/right (vertical divider), 'v' =
|
||||
// top/bottom (horizontal divider).
|
||||
export type PlotLayout =
|
||||
| { type: 'leaf'; widget: string }
|
||||
| { type: 'split'; dir: 'h' | 'v'; ratio: number; a: PlotLayout; b: PlotLayout };
|
||||
|
||||
// A panel-local state variable. Its definition travels with the interface XML,
|
||||
// but its live value is instantiated per browser/panel instance starting from
|
||||
// `initial` (the value is NOT persisted server-side). Referenced as a signal
|
||||
// via ds 'local'.
|
||||
export interface StateVar {
|
||||
name: string;
|
||||
type?: 'number' | 'bool' | 'string'; // default 'number'
|
||||
initial: string; // initial value, stored as a string
|
||||
unit?: string;
|
||||
low?: number;
|
||||
high?: number;
|
||||
}
|
||||
|
||||
// ── Panel logic (Node-RED–style flow graph) ──────────────────────────────────
|
||||
|
||||
// The kind of a logic node.
|
||||
//
|
||||
// Triggers are flow entry points:
|
||||
// trigger.button — fired when a button widget's Action names this node
|
||||
// (params: name).
|
||||
// trigger.threshold — fired on the rising edge of (signal `op` value)
|
||||
// (params: signal, op, value).
|
||||
// trigger.timer — fired every `interval` ms while the panel is in view
|
||||
// (params: interval).
|
||||
// trigger.loop — fired once on panel load, then every `interval` ms
|
||||
// (params: interval).
|
||||
// trigger.change — fired whenever a signal's value changes
|
||||
// (params: signal).
|
||||
// trigger.start — fired once when the panel is opened (no params).
|
||||
// trigger.stop — fired once when the panel is closed (no params).
|
||||
//
|
||||
// Gates combine/guard flow:
|
||||
// gate.and — passes to its output only when ALL incoming triggers
|
||||
// are currently satisfied (no params).
|
||||
//
|
||||
// Control flow (use labelled output ports on the wires):
|
||||
// flow.if — evaluate `cond`; continue on the 'then' or 'else' port
|
||||
// (params: cond).
|
||||
// flow.loop — repeat the 'body' port then continue on 'done'
|
||||
// (params: mode 'count'|'while', count, cond).
|
||||
//
|
||||
// Actions:
|
||||
// action.write — evaluate `expr` and write the result to `target`
|
||||
// ("ds:name" or a bare local var) (params: target, expr).
|
||||
// action.delay — pause `ms` before continuing downstream (params: ms).
|
||||
// action.accumulate — evaluate `expr` and append the value (with a timestamp)
|
||||
// to the named in-memory data array (params: array, expr).
|
||||
// action.export — download one or more named arrays as a CSV file, one
|
||||
// column per array (params: columns = JSON list of
|
||||
// {array,label}, align 'common'|'any'|'interpolate',
|
||||
// filename). Legacy single-array nodes use `array`.
|
||||
// action.clear — empty the named array (params: array).
|
||||
// action.log — evaluate `expr` and log it to the browser console for
|
||||
// debugging a flow (params: expr, label).
|
||||
// action.widget — drive a panel widget by id (params: widget, op). `op` is
|
||||
// one of enable/disable (interaction), show/hide
|
||||
// (visibility), or for plot widgets clearplot (one-shot
|
||||
// buffer clear) / pauseplot / resumeplot (live stream).
|
||||
// action.dialog.info — show an info dialog and wait for the user to dismiss
|
||||
// it before continuing (params: title, message, fields).
|
||||
// action.dialog.error — like dialog.info but styled as an error (params:
|
||||
// title, message, fields).
|
||||
// action.dialog.setpoint — prompt the user for one or more values; on OK write
|
||||
// each input to its target and continue, on Cancel abort
|
||||
// the flow (params: title, message, fields).
|
||||
// All dialogs may carry a `fields` param: a JSON list of
|
||||
// {type:'input'|'display', label, target, default, expr}. `input` fields
|
||||
// prompt for a value written to `target`; `display` fields show the resolved
|
||||
// `expr`. Legacy set-point nodes use `target`/`default` for a single input.
|
||||
// Dialog `message`/`title` text may embed signal values as {ds:name} tokens.
|
||||
//
|
||||
// Expressions (cond/expr) may reference signals inline as {ds:name} and panel-
|
||||
// local state variables as bare identifiers. See lib/expr.ts.
|
||||
export type LogicNodeKind =
|
||||
| 'trigger.button'
|
||||
| 'trigger.threshold'
|
||||
| 'trigger.timer'
|
||||
| 'trigger.loop'
|
||||
| 'trigger.change'
|
||||
| 'trigger.start'
|
||||
| 'trigger.stop'
|
||||
| 'gate.and'
|
||||
| 'flow.if'
|
||||
| 'flow.loop'
|
||||
| 'action.write'
|
||||
| 'action.delay'
|
||||
| 'action.accumulate'
|
||||
| 'action.export'
|
||||
| 'action.clear'
|
||||
| 'action.log'
|
||||
| 'action.widget'
|
||||
| 'action.dialog.info'
|
||||
| 'action.dialog.error'
|
||||
| 'action.dialog.setpoint';
|
||||
|
||||
// A block on the flow canvas. `params` holds kind-specific fields as strings.
|
||||
export interface LogicNode {
|
||||
id: string;
|
||||
kind: LogicNodeKind;
|
||||
x: number;
|
||||
y: number;
|
||||
params: Record<string, string>;
|
||||
}
|
||||
|
||||
// A directed connection from one node's output to another node's input.
|
||||
// `fromPort` selects which labelled output it leaves (default 'out'); flow.if
|
||||
// uses 'then'/'else' and flow.loop uses 'body'/'done'.
|
||||
export interface LogicWire {
|
||||
from: string; // source node id
|
||||
fromPort?: string; // source output port (default 'out')
|
||||
to: string; // target node id
|
||||
}
|
||||
|
||||
// The panel's complete logic flow.
|
||||
export interface LogicGraph {
|
||||
nodes: LogicNode[];
|
||||
wires: LogicWire[];
|
||||
}
|
||||
|
||||
// A complete HMI interface definition
|
||||
export interface Interface {
|
||||
id: string;
|
||||
@@ -48,4 +183,108 @@ export interface Interface {
|
||||
w: number;
|
||||
h: number;
|
||||
widgets: Widget[];
|
||||
// 'panel' (default) = free-form canvas; 'plot' = split-layout plot panel.
|
||||
kind?: 'panel' | 'plot';
|
||||
// Present only when kind === 'plot': the viewport split tree.
|
||||
layout?: PlotLayout;
|
||||
// Panel-local state variable definitions (live value instantiated client-side).
|
||||
statevars?: StateVar[];
|
||||
// Panel logic flow graph (run client-side in view mode).
|
||||
logic?: LogicGraph;
|
||||
}
|
||||
|
||||
// ── Access control ───────────────────────────────────────────────────────────
|
||||
|
||||
// Global access level for the current user, as reported by /api/v1/me.
|
||||
// 'write' = full access (the default for any non-blacklisted user)
|
||||
// 'readonly' = may view but not create/modify/delete panels or write signals
|
||||
// 'none' = no access
|
||||
export type AccessLevel = 'none' | 'readonly' | 'write';
|
||||
|
||||
// Identity + access info for the calling user (from /api/v1/me).
|
||||
export interface Me {
|
||||
user: string;
|
||||
level: AccessLevel;
|
||||
groups: string[];
|
||||
// Whether the user may add/edit panel logic and server-side control logic.
|
||||
// False only when a logic-editors allowlist is configured and excludes them.
|
||||
canEditLogic: boolean;
|
||||
}
|
||||
|
||||
// Effective permission a user holds on a single panel or folder.
|
||||
// 'none' = invisible
|
||||
// 'read' = may view
|
||||
// 'write' = may view and modify
|
||||
export type Perm = 'none' | 'read' | 'write';
|
||||
|
||||
// A share grant: gives a single user or a named user-group read/write access to
|
||||
// a panel or folder.
|
||||
export interface Grant {
|
||||
kind: 'user' | 'group';
|
||||
name: string;
|
||||
perm: 'read' | 'write';
|
||||
}
|
||||
|
||||
// Sharing record for a panel (from GET /api/v1/interfaces/{id}/acl).
|
||||
export interface PanelACL {
|
||||
owner: string;
|
||||
folder: string;
|
||||
public: '' | 'read' | 'write';
|
||||
grants: Grant[];
|
||||
perm: Perm; // the caller's own effective permission
|
||||
}
|
||||
|
||||
// A folder in the panel-organisation hierarchy (from GET /api/v1/folders).
|
||||
export interface Folder {
|
||||
id: string;
|
||||
name: string;
|
||||
parent: string;
|
||||
owner: string;
|
||||
public?: '' | 'read' | 'write';
|
||||
grants?: Grant[];
|
||||
perm: Perm; // the caller's own effective permission
|
||||
}
|
||||
|
||||
// An entry in the interface list (GET /api/v1/interfaces), enriched with the
|
||||
// owner, containing folder, and the caller's permission.
|
||||
export interface InterfaceListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
version: number;
|
||||
owner?: string;
|
||||
folder?: string;
|
||||
order?: number;
|
||||
perm: Perm;
|
||||
}
|
||||
|
||||
// ── Synthetic signals ────────────────────────────────────────────────────────
|
||||
|
||||
export interface InputRef {
|
||||
ds: string;
|
||||
signal: string;
|
||||
}
|
||||
|
||||
export interface PipelineNode {
|
||||
type: string;
|
||||
params: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface SignalDef {
|
||||
name: string;
|
||||
ds?: string; // legacy single input
|
||||
signal?: string; // legacy single input
|
||||
inputs?: InputRef[];
|
||||
pipeline: PipelineNode[];
|
||||
meta: {
|
||||
unit?: string;
|
||||
description?: string;
|
||||
displayLow?: number;
|
||||
displayHigh?: number;
|
||||
writable?: boolean;
|
||||
};
|
||||
// Visibility scope (default 'panel'). 'owner'/'panel' are stamped/filtered
|
||||
// server-side; owner is set from the trusted identity on create.
|
||||
visibility?: 'panel' | 'user' | 'global';
|
||||
owner?: string;
|
||||
panel?: string; // bound interface id when visibility === 'panel'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Per-widget command bus.
|
||||
//
|
||||
// Panel logic (logic.ts) runs in view mode and can drive individual widgets via
|
||||
// the `action.widget` node: enable/disable, show/hide, and — for plot widgets —
|
||||
// clear or pause/resume the live stream. Each widget id gets a small reactive
|
||||
// store; widget components subscribe to their own store (Canvas wraps every
|
||||
// widget in a <WidgetView> that honours disabled/hidden, and PlotWidget reacts
|
||||
// to paused/clear). The engine writes commands through the setters below.
|
||||
|
||||
import { writable, type Writable } from './store';
|
||||
|
||||
export interface WidgetCmd {
|
||||
disabled: boolean; // interaction blocked + dimmed
|
||||
hidden: boolean; // removed from the view
|
||||
paused: boolean; // plot: stop ingesting live samples
|
||||
clearNonce: number; // plot: bumped to request a one-shot buffer clear
|
||||
}
|
||||
|
||||
const DEFAULT: WidgetCmd = { disabled: false, hidden: false, paused: false, clearNonce: 0 };
|
||||
|
||||
const stores = new Map<string, Writable<WidgetCmd>>();
|
||||
|
||||
export function getWidgetCmdStore(id: string): Writable<WidgetCmd> {
|
||||
let s = stores.get(id);
|
||||
if (!s) { s = writable({ ...DEFAULT }); stores.set(id, s); }
|
||||
return s;
|
||||
}
|
||||
|
||||
export function setWidgetDisabled(id: string, v: boolean): void {
|
||||
getWidgetCmdStore(id).update(c => ({ ...c, disabled: v }));
|
||||
}
|
||||
export function setWidgetHidden(id: string, v: boolean): void {
|
||||
getWidgetCmdStore(id).update(c => ({ ...c, hidden: v }));
|
||||
}
|
||||
export function setPlotPaused(id: string, v: boolean): void {
|
||||
getWidgetCmdStore(id).update(c => ({ ...c, paused: v }));
|
||||
}
|
||||
export function clearPlot(id: string): void {
|
||||
getWidgetCmdStore(id).update(c => ({ ...c, clearNonce: c.clearNonce + 1 }));
|
||||
}
|
||||
|
||||
// Reset every widget back to its default state. Called when a panel unmounts so
|
||||
// that re-opening the same panel (whose widget ids are stable) starts fresh
|
||||
// rather than inheriting disabled/hidden/paused flags from the previous view.
|
||||
export function resetWidgetCmds(): void {
|
||||
for (const s of stores.values()) s.set({ ...DEFAULT });
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { writable, type Readable } from './store';
|
||||
import { writeLocalState } from './localstate';
|
||||
import type { SignalRef, SignalValue, SignalMeta, HistoryPoint } from './types';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -259,6 +260,11 @@ class WsClient {
|
||||
}
|
||||
|
||||
write(ref: SignalRef, value: any): void {
|
||||
// Panel-local variables never go to the server — update them in place.
|
||||
if (ref.ds === 'local') {
|
||||
writeLocalState(ref.name, value);
|
||||
return;
|
||||
}
|
||||
console.log('[ws] write', ref.ds, ref.name, value, 'ws state:', this.ws?.readyState);
|
||||
this._send({ type: 'write', ds: ref.ds, name: ref.name, value });
|
||||
}
|
||||
|
||||
+144
-3
@@ -1,4 +1,4 @@
|
||||
import type { Interface, Widget, SignalRef } from './types';
|
||||
import type { Interface, Widget, SignalRef, PlotLayout, StateVar, LogicGraph } from './types';
|
||||
|
||||
/** Escape a string for use as an XML attribute value. */
|
||||
function xmlEsc(s: string): string {
|
||||
@@ -9,14 +9,67 @@ function xmlEsc(s: string): string {
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/** Serialize a panel-logic flow graph to indented XML lines. */
|
||||
function serializeLogic(graph: LogicGraph, lines: string[]): void {
|
||||
lines.push(` <logic>`);
|
||||
for (const node of graph.nodes) {
|
||||
lines.push(
|
||||
` <node id="${xmlEsc(node.id)}" kind="${xmlEsc(node.kind)}" ` +
|
||||
`x="${node.x}" y="${node.y}">`
|
||||
);
|
||||
for (const [key, value] of Object.entries(node.params)) {
|
||||
lines.push(` <param key="${xmlEsc(key)}" value="${xmlEsc(value)}"/>`);
|
||||
}
|
||||
lines.push(` </node>`);
|
||||
}
|
||||
for (const wire of graph.wires) {
|
||||
const portAttr = wire.fromPort && wire.fromPort !== 'out'
|
||||
? ` port="${xmlEsc(wire.fromPort)}"` : '';
|
||||
lines.push(` <wire from="${xmlEsc(wire.from)}"${portAttr} to="${xmlEsc(wire.to)}"/>`);
|
||||
}
|
||||
lines.push(` </logic>`);
|
||||
}
|
||||
|
||||
/** Serialize a PlotLayout subtree to indented XML lines. */
|
||||
function serializeLayout(node: PlotLayout, indent: string, lines: string[]): void {
|
||||
if (node.type === 'leaf') {
|
||||
lines.push(`${indent}<leaf widget="${xmlEsc(node.widget)}"/>`);
|
||||
return;
|
||||
}
|
||||
lines.push(`${indent}<split dir="${node.dir}" ratio="${node.ratio}">`);
|
||||
serializeLayout(node.a, indent + ' ', lines);
|
||||
serializeLayout(node.b, indent + ' ', lines);
|
||||
lines.push(`${indent}</split>`);
|
||||
}
|
||||
|
||||
/** Serialize an Interface object to an XML string. */
|
||||
export function serializeInterface(iface: Interface): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`<?xml version="1.0" encoding="UTF-8"?>`);
|
||||
const kindAttr = iface.kind === 'plot' ? ` kind="plot"` : '';
|
||||
lines.push(
|
||||
`<interface id="${xmlEsc(iface.id)}" name="${xmlEsc(iface.name)}" ` +
|
||||
`version="${iface.version}" width="${iface.w}" height="${iface.h}">`
|
||||
`version="${iface.version}" width="${iface.w}" height="${iface.h}"${kindAttr}>`
|
||||
);
|
||||
if (iface.kind === 'plot' && iface.layout) {
|
||||
lines.push(` <layout>`);
|
||||
serializeLayout(iface.layout, ' ', lines);
|
||||
lines.push(` </layout>`);
|
||||
}
|
||||
for (const sv of iface.statevars ?? []) {
|
||||
const attrs = [
|
||||
`name="${xmlEsc(sv.name)}"`,
|
||||
`type="${xmlEsc(sv.type ?? 'number')}"`,
|
||||
`initial="${xmlEsc(sv.initial ?? '')}"`,
|
||||
];
|
||||
if (sv.unit) attrs.push(`unit="${xmlEsc(sv.unit)}"`);
|
||||
if (sv.low !== undefined) attrs.push(`low="${sv.low}"`);
|
||||
if (sv.high !== undefined) attrs.push(`high="${sv.high}"`);
|
||||
lines.push(` <statevar ${attrs.join(' ')}/>`);
|
||||
}
|
||||
if (iface.logic && (iface.logic.nodes.length > 0 || iface.logic.wires.length > 0)) {
|
||||
serializeLogic(iface.logic, lines);
|
||||
}
|
||||
for (const w of iface.widgets) {
|
||||
lines.push(
|
||||
` <widget id="${xmlEsc(w.id)}" type="${xmlEsc(w.type)}" ` +
|
||||
@@ -46,6 +99,56 @@ export function serializeInterface(iface: Interface): string {
|
||||
* </widget>
|
||||
* </interface>
|
||||
*/
|
||||
/** Parse a <split>/<leaf> element into a PlotLayout node. */
|
||||
function parseLayout(el: Element): PlotLayout {
|
||||
if (el.tagName === 'leaf') {
|
||||
return { type: 'leaf', widget: el.getAttribute('widget') ?? '' };
|
||||
}
|
||||
// split
|
||||
const dir = el.getAttribute('dir') === 'v' ? 'v' : 'h';
|
||||
const ratio = parseFloat(el.getAttribute('ratio') ?? '0.5');
|
||||
const children = Array.from(el.children).filter(
|
||||
c => c.tagName === 'split' || c.tagName === 'leaf'
|
||||
);
|
||||
const a = children[0] ? parseLayout(children[0]) : { type: 'leaf', widget: '' } as PlotLayout;
|
||||
const b = children[1] ? parseLayout(children[1]) : { type: 'leaf', widget: '' } as PlotLayout;
|
||||
return { type: 'split', dir, ratio: isNaN(ratio) ? 0.5 : ratio, a, b };
|
||||
}
|
||||
|
||||
/** Parse a <logic> element into a LogicGraph (nodes + wires). */
|
||||
function parseLogic(logicEl: Element): LogicGraph {
|
||||
const nodes: LogicGraph['nodes'] = [];
|
||||
const wires: LogicGraph['wires'] = [];
|
||||
|
||||
for (const el of Array.from(logicEl.children)) {
|
||||
if (el.tagName === 'node') {
|
||||
const id = el.getAttribute('id') ?? '';
|
||||
if (!id) continue;
|
||||
const params: Record<string, string> = {};
|
||||
for (const child of Array.from(el.children)) {
|
||||
if (child.tagName !== 'param') continue;
|
||||
const key = child.getAttribute('key');
|
||||
const value = child.getAttribute('value');
|
||||
if (key !== null && value !== null) params[key] = value;
|
||||
}
|
||||
nodes.push({
|
||||
id,
|
||||
kind: (el.getAttribute('kind') ?? 'action.write') as LogicGraph['nodes'][number]['kind'],
|
||||
x: parseFloat(el.getAttribute('x') ?? '0'),
|
||||
y: parseFloat(el.getAttribute('y') ?? '0'),
|
||||
params,
|
||||
});
|
||||
} else if (el.tagName === 'wire') {
|
||||
const from = el.getAttribute('from');
|
||||
const to = el.getAttribute('to');
|
||||
const port = el.getAttribute('port');
|
||||
if (from && to) wires.push({ from, to, ...(port ? { fromPort: port } : {}) });
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, wires };
|
||||
}
|
||||
|
||||
export function parseInterface(xml: string): Interface {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(xml, 'application/xml');
|
||||
@@ -66,6 +169,38 @@ export function parseInterface(xml: string): Interface {
|
||||
const version = parseInt(root.getAttribute('version') ?? '1', 10);
|
||||
const w = parseInt(root.getAttribute('width') ?? '1200', 10);
|
||||
const h = parseInt(root.getAttribute('height') ?? '800', 10);
|
||||
const kind = root.getAttribute('kind') === 'plot' ? 'plot' : undefined;
|
||||
|
||||
let layout: PlotLayout | undefined;
|
||||
if (kind === 'plot') {
|
||||
const layoutEl = Array.from(root.children).find(el => el.tagName === 'layout');
|
||||
const rootNode = layoutEl
|
||||
? Array.from(layoutEl.children).find(el => el.tagName === 'split' || el.tagName === 'leaf')
|
||||
: undefined;
|
||||
if (rootNode) layout = parseLayout(rootNode);
|
||||
}
|
||||
|
||||
const statevars: StateVar[] = [];
|
||||
for (const el of Array.from(root.children)) {
|
||||
if (el.tagName !== 'statevar') continue;
|
||||
const name = el.getAttribute('name') ?? '';
|
||||
if (!name) continue;
|
||||
const type = el.getAttribute('type');
|
||||
const low = el.getAttribute('low');
|
||||
const high = el.getAttribute('high');
|
||||
const unit = el.getAttribute('unit');
|
||||
statevars.push({
|
||||
name,
|
||||
type: type === 'bool' || type === 'string' ? type : 'number',
|
||||
initial: el.getAttribute('initial') ?? '',
|
||||
...(unit ? { unit } : {}),
|
||||
...(low !== null ? { low: parseFloat(low) } : {}),
|
||||
...(high !== null ? { high: parseFloat(high) } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const logicEl = Array.from(root.children).find(el => el.tagName === 'logic');
|
||||
const logic = logicEl ? parseLogic(logicEl) : null;
|
||||
|
||||
const widgets: Widget[] = [];
|
||||
|
||||
@@ -100,5 +235,11 @@ export function parseInterface(xml: string): Interface {
|
||||
widgets.push({ id, type, x, y, w, h, signals, options });
|
||||
}
|
||||
|
||||
return { id: ifaceId, name, version, w, h, widgets };
|
||||
return {
|
||||
id: ifaceId, name, version, w, h, widgets,
|
||||
...(kind ? { kind } : {}),
|
||||
...(layout ? { layout } : {}),
|
||||
...(statevars.length > 0 ? { statevars } : {}),
|
||||
...(logic && (logic.nodes.length > 0 || logic.wires.length > 0) ? { logic } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
+1590
-6
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||
import { formatValue } from '../lib/format';
|
||||
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
||||
|
||||
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||
@@ -34,6 +35,7 @@ export default function BarH({ widget, onContextMenu }: Props) {
|
||||
const minVal = parseFloat(widget.options['min'] ?? String(meta?.displayLow ?? 0));
|
||||
const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100));
|
||||
const quality = sv.quality;
|
||||
const fmt = widget.options['format'] ?? '';
|
||||
|
||||
const rawV = sv.value;
|
||||
const rawValue: number | null = rawV === null || rawV === undefined ? null
|
||||
@@ -41,7 +43,7 @@ export default function BarH({ widget, onContextMenu }: Props) {
|
||||
|
||||
function displayValue(): string {
|
||||
if (rawValue === null || isNaN(rawValue)) return '---';
|
||||
return Number.isFinite(rawValue) ? rawValue.toPrecision(4).replace(/\.?0+$/, '') : String(rawValue);
|
||||
return formatValue(rawValue, fmt);
|
||||
}
|
||||
|
||||
function fillPercent(): number {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||
import { formatValue } from '../lib/format';
|
||||
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
||||
|
||||
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||
@@ -34,6 +35,7 @@ export default function BarV({ widget, onContextMenu }: Props) {
|
||||
const minVal = parseFloat(widget.options['min'] ?? String(meta?.displayLow ?? 0));
|
||||
const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100));
|
||||
const quality = sv.quality;
|
||||
const fmt = widget.options['format'] ?? '';
|
||||
|
||||
const rawV = sv.value;
|
||||
const rawValue: number | null = rawV === null || rawV === undefined ? null
|
||||
@@ -41,7 +43,7 @@ export default function BarV({ widget, onContextMenu }: Props) {
|
||||
|
||||
function displayValue(): string {
|
||||
if (rawValue === null || isNaN(rawValue)) return '---';
|
||||
return Number.isFinite(rawValue) ? rawValue.toPrecision(4).replace(/\.?0+$/, '') : String(rawValue);
|
||||
return formatValue(rawValue, fmt);
|
||||
}
|
||||
|
||||
function fillPercent(): number {
|
||||
|
||||
+38
-11
@@ -1,8 +1,10 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { wsClient } from '../lib/ws';
|
||||
import { getSignalStore } from '../lib/stores';
|
||||
import type { Widget } from '../lib/types';
|
||||
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||
import { logicEngine } from '../lib/logic';
|
||||
import { useAuth, canWrite } from '../lib/auth';
|
||||
import type { Widget, SignalMeta } from '../lib/types';
|
||||
|
||||
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||
|
||||
@@ -15,8 +17,12 @@ export default function Button({ widget, onContextMenu }: Props) {
|
||||
const mode = widget.options['mode'] ?? 'oneshot';
|
||||
const resetTimeSec = parseFloat(widget.options['resetTime'] ?? '1');
|
||||
const confirmOpt = widget.options['confirm'] === 'true';
|
||||
// Optional panel-logic sequence fired on click (in addition to any signal write).
|
||||
const action = widget.options['action'] ?? '';
|
||||
|
||||
const me = useAuth();
|
||||
const [signalVal, setSignalVal] = useState<any>(null);
|
||||
const [meta, setMeta] = useState<SignalMeta | null>(null);
|
||||
|
||||
// Subscribe to signal only for persistent mode (to track whether it is active)
|
||||
useEffect(() => {
|
||||
@@ -24,6 +30,20 @@ export default function Button({ widget, onContextMenu }: Props) {
|
||||
return getSignalStore(sigRef).subscribe(sv => setSignalVal(sv.value));
|
||||
}, [sigRef?.ds, sigRef?.name, mode]);
|
||||
|
||||
// Track the target signal's writability so the button can be disabled when
|
||||
// the user has no permission to write it (real signals only; local panel
|
||||
// vars are written client-side and have no backend metadata).
|
||||
useEffect(() => {
|
||||
if (!sigRef || sigRef.ds === 'local') return;
|
||||
return getMetaStore(sigRef).subscribe(setMeta);
|
||||
}, [sigRef?.ds, sigRef?.name]);
|
||||
|
||||
// A button that drives a real signal is disabled when the user cannot write
|
||||
// it (read-only access, or the signal reports it is not writable). Buttons
|
||||
// that only run a logic action — or target a local var — stay enabled.
|
||||
const drivesRealSignal = !!sigRef && sigRef.ds !== 'local';
|
||||
const writeAllowed = !drivesRealSignal || (canWrite(me.level) && meta?.writable !== false);
|
||||
|
||||
const parsedSend = isNaN(parseFloat(sendValue)) ? sendValue : parseFloat(sendValue);
|
||||
const parsedReset = isNaN(parseFloat(resetValue)) ? resetValue : parseFloat(resetValue);
|
||||
|
||||
@@ -38,18 +58,23 @@ export default function Button({ widget, onContextMenu }: Props) {
|
||||
}
|
||||
|
||||
function execute() {
|
||||
if (mode === 'setReset') {
|
||||
doWrite(parsedSend);
|
||||
setTimeout(() => doWrite(parsedReset), Math.max(0, resetTimeSec) * 1000);
|
||||
} else if (mode === 'persistent') {
|
||||
doWrite(isPressed ? parsedReset : parsedSend);
|
||||
} else {
|
||||
doWrite(parsedSend);
|
||||
if (sigRef) {
|
||||
if (mode === 'setReset') {
|
||||
doWrite(parsedSend);
|
||||
setTimeout(() => doWrite(parsedReset), Math.max(0, resetTimeSec) * 1000);
|
||||
} else if (mode === 'persistent') {
|
||||
doWrite(isPressed ? parsedReset : parsedSend);
|
||||
} else {
|
||||
doWrite(parsedSend);
|
||||
}
|
||||
}
|
||||
if (action) logicEngine.runAction(action);
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
if (!sigRef) return;
|
||||
// A button may drive a signal, a logic sequence, or both.
|
||||
if (!sigRef && !action) return;
|
||||
if (!writeAllowed) return;
|
||||
if (confirmOpt) {
|
||||
if (window.confirm(`Send ${label}?`)) execute();
|
||||
} else {
|
||||
@@ -63,7 +88,9 @@ export default function Button({ widget, onContextMenu }: Props) {
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<button class={`btn${isPressed ? ' btn-pressed' : ''}`} onClick={handleClick}>
|
||||
<button class={`btn${isPressed ? ' btn-pressed' : ''}`} onClick={handleClick}
|
||||
disabled={!writeAllowed}
|
||||
title={writeAllowed ? '' : 'You do not have permission to write this signal'}>
|
||||
{label}
|
||||
{mode === 'setReset' && (
|
||||
<span class="btn-mode-hint">{resetTimeSec}s</span>
|
||||
|
||||
+16
-11
@@ -1,6 +1,7 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||
import { formatValue } from '../lib/format';
|
||||
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
||||
|
||||
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||
@@ -14,11 +15,12 @@ function qualityColor(q: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Arc: -135deg to +135deg (270deg total)
|
||||
const START_DEG = -135;
|
||||
const END_DEG = 135;
|
||||
// Arc: 135° (lower-left, 7 o'clock = min) → 45° (lower-right, 5 o'clock = max),
|
||||
// sweeping clockwise through the top (12 o'clock). 270° total sweep.
|
||||
const START_DEG = 135;
|
||||
const END_DEG = 45;
|
||||
const TOTAL_DEG = 270;
|
||||
const cx = 50, cy = 54, r = 38;
|
||||
const cx = 50, cy = 50, r = 38;
|
||||
|
||||
function degToRad(deg: number) { return (deg * Math.PI) / 180; }
|
||||
function polarToXY(deg: number, radius: number) {
|
||||
@@ -28,7 +30,9 @@ function polarToXY(deg: number, radius: number) {
|
||||
function arcPath(startDeg: number, endDeg: number, radius: number): string {
|
||||
const s = polarToXY(startDeg, radius);
|
||||
const e = polarToXY(endDeg, radius);
|
||||
const largeArc = endDeg - startDeg > 180 ? 1 : 0;
|
||||
// Compute the clockwise angular span so wrap-around works correctly.
|
||||
const span = ((endDeg - startDeg) % 360 + 360) % 360;
|
||||
const largeArc = span > 180 ? 1 : 0;
|
||||
return `M ${s.x} ${s.y} A ${radius} ${radius} 0 ${largeArc} 1 ${e.x} ${e.y}`;
|
||||
}
|
||||
function valueToDeg(v: number, minVal: number, maxVal: number): number {
|
||||
@@ -60,13 +64,14 @@ export default function Gauge({ widget, onContextMenu }: Props) {
|
||||
const thresholdHigh = widget.options['thresholdHigh'] ? parseFloat(widget.options['thresholdHigh']) : null;
|
||||
|
||||
const quality = sv.quality;
|
||||
const fmt = widget.options['format'] ?? '';
|
||||
const rawV = sv.value;
|
||||
const rawValue: number | null = rawV === null || rawV === undefined ? null
|
||||
: typeof rawV === 'number' ? rawV : parseFloat(String(rawV));
|
||||
|
||||
function displayValue(): string {
|
||||
if (rawValue === null || isNaN(rawValue)) return '---';
|
||||
return Number.isFinite(rawValue) ? rawValue.toPrecision(4).replace(/\.?0+$/, '') : String(rawValue);
|
||||
return formatValue(rawValue, fmt);
|
||||
}
|
||||
|
||||
function fillColor(): string {
|
||||
@@ -89,16 +94,16 @@ export default function Gauge({ widget, onContextMenu }: Props) {
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<span class="quality-dot" style={`background:${qualityColor(quality)};`} title={`Quality: ${quality}`} />
|
||||
<svg viewBox="0 0 100 80" class="gauge-svg">
|
||||
<svg viewBox="0 0 100 90" class="gauge-svg">
|
||||
<path d={bgPath} fill="none" stroke="#2d3748" stroke-width="6" stroke-linecap="round" />
|
||||
{fillPath && (
|
||||
<path d={fillPath} fill="none" stroke={fc} stroke-width="6" stroke-linecap="round" />
|
||||
)}
|
||||
<circle cx={needlePos.x} cy={needlePos.y} r="3" fill={fc} />
|
||||
<text x={cx} y={cy + 10} text-anchor="middle" class="gauge-value">{displayValue()}</text>
|
||||
{unit && <text x={cx} y={cy + 20} text-anchor="middle" class="gauge-unit">{unit}</text>}
|
||||
<text x="12" y="76" text-anchor="middle" class="gauge-range">{minVal}</text>
|
||||
<text x="88" y="76" text-anchor="middle" class="gauge-range">{maxVal}</text>
|
||||
<text x={cx} y={cy + 6} text-anchor="middle" class="gauge-value">{displayValue()}</text>
|
||||
{unit && <text x={cx} y={cy + 17} text-anchor="middle" class="gauge-unit">{unit}</text>}
|
||||
<text x="14" y="80" text-anchor="middle" class="gauge-range">{minVal}</text>
|
||||
<text x="86" y="80" text-anchor="middle" class="gauge-range">{maxVal}</text>
|
||||
</svg>
|
||||
{label && <div class="gauge-label">{label}</div>}
|
||||
</div>
|
||||
|
||||
+172
-68
@@ -3,7 +3,10 @@ import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import uPlot from 'uplot';
|
||||
import * as echarts from 'echarts';
|
||||
import { getSignalStore } from '../lib/stores';
|
||||
import { getWidgetCmdStore } from '../lib/widgetCommands';
|
||||
import { wsClient } from '../lib/ws';
|
||||
import { formatValue } from '../lib/format';
|
||||
import { fftMag, resampleUniform } from '../lib/fft';
|
||||
import type { Widget, SignalRef } from '../lib/types';
|
||||
|
||||
interface Props {
|
||||
@@ -17,7 +20,7 @@ interface SeriesBuffer {
|
||||
values: number[];
|
||||
}
|
||||
|
||||
const RING_MAX = 4000;
|
||||
const RING_MAX = 200_000;
|
||||
const COLORS = ['#4a9eff', '#22c55e', '#f59e0b', '#ef4444', '#a78bfa', '#f472b6', '#34d399', '#fb923c'];
|
||||
|
||||
const ECHARTS_DARK = {
|
||||
@@ -67,6 +70,7 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
||||
// Stable primitive deps so the effect re-runs when plotType or signals change.
|
||||
const timeRangeKey = timeRange ? `${timeRange.start}|${timeRange.end}` : 'live';
|
||||
const plotType = widget.options['plotType'] ?? 'timeseries';
|
||||
const legendPos = widget.options['legend'] ?? 'bottom';
|
||||
const signalsKey = widget.signals.map(s => `${s.ds}:${s.name}${s.color ?? ''}`).join(',');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -80,8 +84,8 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
||||
const showLegend = legendPos !== 'none';
|
||||
|
||||
const buffers: SeriesBuffer[] = signals.map(() => ({ timestamps: [], values: [] }));
|
||||
const histValues: number[][] = signals.map(() => []);
|
||||
const unsubs: (() => void)[] = [];
|
||||
const fmt = widget.options['format'] ?? '';
|
||||
|
||||
// ── uPlot (timeseries) ──────────────────────────────────────────────────
|
||||
let uplot: uPlot | null = null;
|
||||
@@ -89,6 +93,8 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
||||
// windowSec: apply rolling window for live mode; 0 = use full buffer (historical)
|
||||
function uplotData(windowSec = 0): uPlot.AlignedData {
|
||||
if (signals.length === 0) return [[]];
|
||||
const cutoff = windowSec > 0 ? Date.now() / 1000 - windowSec : -Infinity;
|
||||
|
||||
const allTs = new Set<number>();
|
||||
buffers.forEach(b => {
|
||||
const pts = windowSec > 0 ? windowedSlice(b, windowSec).ts : b.timestamps;
|
||||
@@ -98,18 +104,71 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
||||
if (tsSorted.length === 0) {
|
||||
return [[], ...signals.map(() => [])] as uPlot.AlignedData;
|
||||
}
|
||||
const tsMap = buffers.map(b => {
|
||||
const m = new Map<number, number>();
|
||||
const pts = windowSec > 0 ? windowedSlice(b, windowSec) : { ts: b.timestamps, vals: b.values };
|
||||
pts.ts.forEach((t, i) => m.set(t, pts.vals[i]));
|
||||
return m;
|
||||
});
|
||||
|
||||
// Step-hold interpolation: carry the most recent sample forward so signals
|
||||
// with different update rates align correctly on the shared time axis.
|
||||
return [
|
||||
tsSorted,
|
||||
...tsMap.map(m => tsSorted.map(t => m.get(t) ?? null)),
|
||||
...buffers.map(b => {
|
||||
if (b.timestamps.length === 0) return tsSorted.map(() => null);
|
||||
let bi = 0;
|
||||
let hold: number | null = null;
|
||||
while (bi < b.timestamps.length && b.timestamps[bi] < cutoff) {
|
||||
hold = b.values[bi++];
|
||||
}
|
||||
return tsSorted.map(t => {
|
||||
while (bi < b.timestamps.length && b.timestamps[bi] <= t) {
|
||||
hold = b.values[bi++];
|
||||
}
|
||||
return hold;
|
||||
});
|
||||
}),
|
||||
] as uPlot.AlignedData;
|
||||
}
|
||||
|
||||
function makeUplotOpts(): uPlot.Options {
|
||||
const yAxis: uPlot.Axis = {
|
||||
stroke: '#94a3b8',
|
||||
grid: { stroke: '#2d3748' },
|
||||
ticks: { stroke: '#475569' },
|
||||
size: 55,
|
||||
};
|
||||
if (fmt) {
|
||||
yAxis.values = (_u: uPlot, vals: (number | null)[]) =>
|
||||
vals.map(v => (v === null || v === undefined) ? '' : formatValue(v, fmt));
|
||||
}
|
||||
const scaleYRange = (_u: uPlot, min: number, max: number): [number, number] => {
|
||||
if (!isFinite(min) || !isFinite(max) || min === max) {
|
||||
const mid = isFinite(min) ? min : 0;
|
||||
return [mid - 1, mid + 1];
|
||||
}
|
||||
return [min, max];
|
||||
};
|
||||
const seriesConf: uPlot.Series[] = [
|
||||
{},
|
||||
...signals.map((s, i) => {
|
||||
const color = s.color ?? COLORS[i % COLORS.length];
|
||||
return {
|
||||
label: s.name,
|
||||
stroke: color,
|
||||
width: 1.5,
|
||||
points: { show: true, size: 5, stroke: color, fill: color },
|
||||
};
|
||||
}),
|
||||
];
|
||||
return {
|
||||
width: w,
|
||||
height: Math.max(50, h - legendH),
|
||||
series: seriesConf,
|
||||
legend: { show: showLegend },
|
||||
axes: [
|
||||
{ stroke: '#94a3b8', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } },
|
||||
yAxis,
|
||||
],
|
||||
scales: { x: { time: true }, y: { ...scaleY, range: scaleYRange } },
|
||||
};
|
||||
}
|
||||
|
||||
// ── ECharts ────────────────────────────────────────────────────────────
|
||||
let echart: echarts.EChartsType | null = null;
|
||||
|
||||
@@ -123,12 +182,14 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
||||
|
||||
switch (plotType) {
|
||||
case 'histogram': {
|
||||
const { labels, counts } = buildHistogram(histValues);
|
||||
// Distribution over all buffered samples (ring-buffer bounded).
|
||||
const { labels, counts } = buildHistogram(buffers.map(b => b.values));
|
||||
return {
|
||||
backgroundColor: ECHARTS_DARK.backgroundColor,
|
||||
legend: legendOpt,
|
||||
xAxis: { type: 'category', data: labels, axisLine, axisLabel, splitLine },
|
||||
yAxis: { type: 'value', axisLine, axisLabel, splitLine },
|
||||
grid: { left: 56, right: 16, top: showLegend ? 28 : 12, bottom: 36 },
|
||||
xAxis: { type: 'category', name: 'Value', nameLocation: 'middle', nameGap: 24, data: labels, axisLine, axisLabel, splitLine },
|
||||
yAxis: { type: 'value', name: 'Count', axisLine, axisLabel, splitLine },
|
||||
series: counts.map((d, i) => ({
|
||||
name: signals[i]?.name ?? `S${i}`,
|
||||
type: 'bar',
|
||||
@@ -141,6 +202,7 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
||||
return {
|
||||
backgroundColor: ECHARTS_DARK.backgroundColor,
|
||||
legend: legendOpt,
|
||||
grid: { left: 56, right: 16, top: showLegend ? 28 : 12, bottom: 36 },
|
||||
xAxis: { type: 'category', data: signals.map(s => s.name), axisLine, axisLabel },
|
||||
yAxis: { type: 'value', axisLine, axisLabel, splitLine },
|
||||
series: [{
|
||||
@@ -153,32 +215,62 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
||||
};
|
||||
}
|
||||
case 'fft': {
|
||||
// Single-sided magnitude spectrum per signal, computed from the
|
||||
// windowed samples resampled onto a uniform grid.
|
||||
const N = 256;
|
||||
return {
|
||||
backgroundColor: ECHARTS_DARK.backgroundColor,
|
||||
legend: legendOpt,
|
||||
xAxis: { type: 'category', name: 'Freq Index', axisLine, axisLabel },
|
||||
yAxis: { type: 'value', axisLine, axisLabel, splitLine },
|
||||
series: buffers.map((b, i) => ({
|
||||
name: signals[i]?.name ?? `S${i}`,
|
||||
type: 'line',
|
||||
data: b.values.length ? b.values[b.values.length - 1] : [],
|
||||
lineStyle: { color: signals[i]?.color ?? COLORS[i % COLORS.length] },
|
||||
showSymbol: false,
|
||||
})),
|
||||
grid: { left: 60, right: 16, top: showLegend ? 28 : 12, bottom: 40 },
|
||||
xAxis: { type: 'value', name: 'Hz', nameLocation: 'middle', nameGap: 24, min: 0, axisLine, axisLabel, splitLine },
|
||||
yAxis: { type: 'value', name: 'Mag', axisLine, axisLabel, splitLine },
|
||||
series: buffers.map((b, i) => {
|
||||
const { ts, vals } = windowedSlice(b, timeWindow);
|
||||
let data: [number, number][] = [];
|
||||
if (vals.length >= 4) {
|
||||
const { sampled, dt } = resampleUniform(ts, vals, N);
|
||||
const mag = fftMag(sampled);
|
||||
const fs = dt > 0 ? 1 / dt : 1; // sample rate (Hz)
|
||||
data = mag.map((m, k) => [(k * fs) / N, m]);
|
||||
}
|
||||
return {
|
||||
name: signals[i]?.name ?? `S${i}`,
|
||||
type: 'line',
|
||||
data,
|
||||
lineStyle: { color: signals[i]?.color ?? COLORS[i % COLORS.length] },
|
||||
showSymbol: false,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
case 'logic': {
|
||||
// Logic-analyser style: each signal is a 0/1 step trace stacked on its
|
||||
// own lane, with a relative-time x-axis (seconds before now).
|
||||
const nowSec = Date.now() / 1000;
|
||||
const lane = 1.4;
|
||||
return {
|
||||
backgroundColor: ECHARTS_DARK.backgroundColor,
|
||||
legend: legendOpt,
|
||||
xAxis: { type: 'value', min: 'dataMin', max: 'dataMax', axisLine, axisLabel },
|
||||
yAxis: { type: 'value', min: -0.1, max: 1.1, axisLine, axisLabel, splitLine },
|
||||
grid: { left: 90, right: 16, top: showLegend ? 28 : 12, bottom: 36 },
|
||||
xAxis: {
|
||||
type: 'value', min: 'dataMin', max: 0, axisLine, splitLine,
|
||||
axisLabel: { color: '#64748b', formatter: (v: number) => `${v.toFixed(0)}s` },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value', min: 0, max: Math.max(lane, signals.length * lane),
|
||||
interval: lane, axisLine, splitLine,
|
||||
axisLabel: {
|
||||
color: '#64748b',
|
||||
formatter: (v: number) => signals[Math.round(v / lane)]?.name ?? '',
|
||||
},
|
||||
},
|
||||
series: buffers.map((b, i) => {
|
||||
const { ts, vals } = windowedSlice(b, timeWindow);
|
||||
const base = i * lane;
|
||||
return {
|
||||
name: signals[i]?.name ?? `S${i}`,
|
||||
type: 'line', step: 'end',
|
||||
data: ts.map((t, j) => [t, vals[j] > 0 ? 1 : 0]),
|
||||
data: ts.map((t, j) => [t - nowSec, base + (vals[j] > 0 ? 1 : 0)]),
|
||||
lineStyle: { color: signals[i]?.color ?? COLORS[i % COLORS.length] },
|
||||
showSymbol: false,
|
||||
};
|
||||
@@ -186,22 +278,33 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
||||
};
|
||||
}
|
||||
case 'waterfall': {
|
||||
const ROWS = 32;
|
||||
// Spectrogram of the first signal: split the windowed samples into
|
||||
// ROWS time frames and FFT each frame (freq on x, time on y).
|
||||
const ROWS = 24, FRAME = 32;
|
||||
const b = buffers[0];
|
||||
const step = Math.max(1, Math.floor(b.values.length / ROWS));
|
||||
const flatData: [number, number, number][] = [];
|
||||
for (let ri = 0; ri < ROWS; ri++) {
|
||||
const idx = b.values.length - 1 - (ROWS - 1 - ri) * step;
|
||||
if (idx < 0) continue;
|
||||
const row = b.values[idx];
|
||||
const arr = Array.isArray(row) ? row : [row];
|
||||
arr.forEach((val, ci) => flatData.push([ci, ri, val]));
|
||||
let maxMag = 0;
|
||||
if (b && b.values.length >= FRAME) {
|
||||
const { sampled } = resampleUniform(b.timestamps, b.values, ROWS * FRAME);
|
||||
for (let r = 0; r < ROWS; r++) {
|
||||
const mag = fftMag(sampled.slice(r * FRAME, (r + 1) * FRAME));
|
||||
for (let k = 0; k < mag.length; k++) {
|
||||
flatData.push([k, r, mag[k]]);
|
||||
if (mag[k] > maxMag) maxMag = mag[k];
|
||||
}
|
||||
}
|
||||
}
|
||||
const bins = FRAME >> 1;
|
||||
return {
|
||||
backgroundColor: ECHARTS_DARK.backgroundColor,
|
||||
visualMap: { min: 0, max: 1, show: false, inRange: { color: ['#0f1117', '#4a9eff'] } },
|
||||
xAxis: { type: 'value', axisLabel },
|
||||
yAxis: { type: 'value', axisLabel },
|
||||
grid: { left: 56, right: 70, top: 12, bottom: 36 },
|
||||
visualMap: {
|
||||
min: 0, max: maxMag || 1, calculable: true, right: 8, top: 'center',
|
||||
dimension: 2, textStyle: { color: '#94a3b8' },
|
||||
inRange: { color: ['#0f1117', '#1e3a8a', '#4a9eff', '#22c55e', '#f59e0b', '#ef4444'] },
|
||||
},
|
||||
xAxis: { type: 'category', name: 'Freq bin', nameLocation: 'middle', nameGap: 22, data: Array.from({ length: bins }, (_, k) => String(k)), axisLine, axisLabel },
|
||||
yAxis: { type: 'category', name: 'Time', data: Array.from({ length: ROWS }, (_, r) => String(r - ROWS + 1)), axisLine, axisLabel },
|
||||
series: [{ type: 'heatmap', data: flatData }],
|
||||
};
|
||||
}
|
||||
@@ -214,42 +317,22 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
||||
const el = chartRef.current;
|
||||
const w = el.clientWidth || widget.w;
|
||||
const h = el.clientHeight || widget.h;
|
||||
// Reserve vertical space for the uPlot legend so it isn't clipped.
|
||||
const legendH = showLegend ? 30 : 0;
|
||||
|
||||
const scaleY: uPlot.Scale = {};
|
||||
if (yMin !== undefined && yMin !== 'auto') scaleY.min = parseFloat(yMin);
|
||||
if (yMax !== undefined && yMax !== 'auto') scaleY.max = parseFloat(yMax);
|
||||
|
||||
if (plotType === 'timeseries') {
|
||||
const seriesConf: uPlot.Series[] = [
|
||||
{},
|
||||
...signals.map((s, i) => ({
|
||||
label: s.name,
|
||||
stroke: s.color ?? COLORS[i % COLORS.length],
|
||||
width: 1.5,
|
||||
})),
|
||||
];
|
||||
|
||||
uplot = new uPlot(
|
||||
{
|
||||
width: w,
|
||||
height: h,
|
||||
series: seriesConf,
|
||||
legend: { show: showLegend },
|
||||
axes: [
|
||||
{ stroke: '#64748b', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } },
|
||||
{ stroke: '#64748b', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } },
|
||||
],
|
||||
scales: { x: { time: true }, y: scaleY },
|
||||
},
|
||||
uplotData(),
|
||||
el,
|
||||
);
|
||||
} else {
|
||||
if (plotType !== 'timeseries') {
|
||||
echart = echarts.init(el);
|
||||
echart.setOption(echartsOption());
|
||||
}
|
||||
|
||||
// ── Historical mode (timeseries only) ──────────────────────────────────
|
||||
// uPlot is NOT created until data arrives — initialising with empty arrays
|
||||
// causes the x-axis to start at epoch 0, compressing all real data to the
|
||||
// right edge of the chart.
|
||||
if (timeRange && plotType === 'timeseries') {
|
||||
setHistStatus('loading');
|
||||
let cancelled = false;
|
||||
@@ -272,23 +355,45 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
||||
if (cancelled) return;
|
||||
const totalPoints = buffers.reduce((s, b) => s + b.timestamps.length, 0);
|
||||
setHistStatus(totalPoints === 0 ? 'empty' : 'idle');
|
||||
if (uplot) uplot.setData(uplotData());
|
||||
// Create uPlot now — with real data — so the x-axis is correct from the start.
|
||||
uplot = new uPlot(makeUplotOpts(), uplotData(), el);
|
||||
}).catch(() => {
|
||||
if (!cancelled) setHistStatus('error');
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (uplot) uplot.destroy();
|
||||
if (echart) echart.dispose();
|
||||
uplot?.destroy();
|
||||
echart?.dispose();
|
||||
};
|
||||
}
|
||||
|
||||
// ── Live mode: init uPlot immediately ──────────────────────────────────
|
||||
if (plotType === 'timeseries') {
|
||||
uplot = new uPlot(makeUplotOpts(), uplotData(), el);
|
||||
}
|
||||
|
||||
// ── Live streaming mode ────────────────────────────────────────────────
|
||||
setHistStatus('idle');
|
||||
|
||||
// Honour action.widget logic commands: `paused` suspends live ingestion and
|
||||
// a bumped `clearNonce` empties the buffers + redraws once.
|
||||
const cmdStore = getWidgetCmdStore(widget.id);
|
||||
let paused = cmdStore.get().paused;
|
||||
let lastClear = cmdStore.get().clearNonce;
|
||||
unsubs.push(cmdStore.subscribe(cmd => {
|
||||
paused = cmd.paused;
|
||||
if (cmd.clearNonce !== lastClear) {
|
||||
lastClear = cmd.clearNonce;
|
||||
buffers.forEach(b => { b.timestamps.length = 0; b.values.length = 0; });
|
||||
if (uplot) uplot.setData(uplotData(timeWindow));
|
||||
if (echart) echart.setOption(echartsOption(), { notMerge: true });
|
||||
}
|
||||
}));
|
||||
|
||||
signals.forEach((sig: SignalRef & { color?: string }, i) => {
|
||||
const unsub = getSignalStore(sig).subscribe(sv => {
|
||||
if (paused) return;
|
||||
if (sv.value === null || sv.value === undefined || sv.ts === null) return;
|
||||
const ts = new Date(sv.ts).getTime() / 1000;
|
||||
|
||||
@@ -303,7 +408,6 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
||||
const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value));
|
||||
if (!isNaN(v)) {
|
||||
pushSample(buffers[i], ts, v);
|
||||
if (plotType === 'histogram') histValues[i].push(v);
|
||||
}
|
||||
if (echart) echart.setOption(echartsOption(), { notMerge: true });
|
||||
}
|
||||
@@ -317,7 +421,7 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
||||
const nw = chartRef.current.clientWidth;
|
||||
const nh = chartRef.current.clientHeight;
|
||||
if (nw > 0 && nh > 0) {
|
||||
if (uplot) uplot.setSize({ width: nw, height: nh });
|
||||
if (uplot) uplot.setSize({ width: nw, height: Math.max(50, nh - legendH) });
|
||||
if (echart) echart.resize();
|
||||
}
|
||||
});
|
||||
@@ -329,11 +433,11 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
||||
if (uplot) uplot.destroy();
|
||||
if (echart) echart.dispose();
|
||||
};
|
||||
}, [widget.id, timeRangeKey, plotType, signalsKey]);
|
||||
}, [widget.id, timeRangeKey, plotType, signalsKey, widget.options['timeWindow'] ?? '60', widget.options['format'] ?? '', widget.options['yMin'] ?? '', widget.options['yMax'] ?? '', widget.options['legend'] ?? 'bottom']);
|
||||
|
||||
return (
|
||||
<div
|
||||
class="plot-widget"
|
||||
class={`plot-widget legend-${legendPos}`}
|
||||
ref={outerRef}
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
onContextMenu={onContextMenu}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { h } from 'preact';
|
||||
import { h, Fragment } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||
import { wsClient } from '../lib/ws';
|
||||
import { useAuth, canWrite } from '../lib/auth';
|
||||
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
||||
|
||||
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||
@@ -19,6 +20,7 @@ interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||
|
||||
export default function SetValue({ widget, onContextMenu }: Props) {
|
||||
const sigRef = widget.signals[0];
|
||||
const me = useAuth();
|
||||
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
|
||||
const [meta, setMeta] = useState<SignalMeta | null>(null);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
@@ -35,32 +37,48 @@ export default function SetValue({ widget, onContextMenu }: Props) {
|
||||
const unit = rawUnit === 'none' ? '' : (rawUnit || meta?.unit || '');
|
||||
const confirm = widget.options['confirm'] === 'true';
|
||||
const isNumeric = meta ? meta.type !== 'string' : true;
|
||||
const isEnum = !!(meta?.enumStrings && meta.enumStrings.length > 0);
|
||||
const quality = sv.quality;
|
||||
|
||||
// The control is read-only when the current user cannot write this signal:
|
||||
// either their global access is read-only, or the signal's metadata reports
|
||||
// it is not writable. Panel-local vars are written client-side (no backend
|
||||
// meta) so they stay editable. meta.writable === false only once known, so
|
||||
// the control isn't prematurely disabled while metadata is still loading.
|
||||
const isLocal = sigRef?.ds === 'local';
|
||||
const writeAllowed = isLocal || (canWrite(me.level) && meta?.writable !== false);
|
||||
|
||||
function displayValue(): string {
|
||||
const v = sv.value;
|
||||
if (v === null || v === undefined) return '---';
|
||||
if (isEnum && meta?.enumStrings) {
|
||||
const idx = typeof v === 'number' ? Math.round(v) : parseInt(String(v), 10);
|
||||
return meta.enumStrings[idx] ?? String(v);
|
||||
}
|
||||
if (typeof v === 'number') {
|
||||
return Number.isFinite(v) ? v.toPrecision(4).replace(/\.?0+$/, '') : String(v);
|
||||
}
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function doWrite(raw: string) {
|
||||
if (!sigRef || !writeAllowed || !raw.trim()) return;
|
||||
const val = isNumeric ? parseFloat(raw) : raw;
|
||||
wsClient.write(sigRef, val);
|
||||
setInputValue('');
|
||||
}
|
||||
|
||||
function handleSet() {
|
||||
if (!sigRef) return;
|
||||
const raw = inputValue.trim();
|
||||
if (!raw) return;
|
||||
|
||||
const doWrite = () => {
|
||||
const val = isNumeric ? parseFloat(raw) : raw;
|
||||
wsClient.write(sigRef, val);
|
||||
setInputValue('');
|
||||
};
|
||||
|
||||
if (confirm) {
|
||||
if (window.confirm(`Set ${label} to ${raw}?`)) doWrite();
|
||||
const display = isEnum && meta?.enumStrings
|
||||
? meta.enumStrings[parseInt(raw, 10)] ?? raw
|
||||
: raw;
|
||||
if (window.confirm(`Set ${label} to ${display}?`)) doWrite(raw);
|
||||
} else {
|
||||
doWrite();
|
||||
doWrite(raw);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +86,24 @@ export default function SetValue({ widget, onContextMenu }: Props) {
|
||||
if (e.key === 'Enter') handleSet();
|
||||
}
|
||||
|
||||
const currentEnumIdx = isEnum && sv.value !== null
|
||||
? Math.round(typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value)))
|
||||
: -1;
|
||||
|
||||
// Tracks the user's selection in the dropdown before they click Set
|
||||
const [enumSelected, setEnumSelected] = useState(currentEnumIdx >= 0 ? String(currentEnumIdx) : '0');
|
||||
|
||||
function handleEnumSet() {
|
||||
if (!sigRef || !writeAllowed) return;
|
||||
const doEnumWrite = () => { wsClient.write(sigRef, parseFloat(enumSelected)); };
|
||||
if (confirm) {
|
||||
const display = meta?.enumStrings?.[parseInt(enumSelected, 10)] ?? enumSelected;
|
||||
if (window.confirm(`Set ${label} to ${display}?`)) doEnumWrite();
|
||||
} else {
|
||||
doEnumWrite();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="setvalue"
|
||||
@@ -76,17 +112,38 @@ export default function SetValue({ widget, onContextMenu }: Props) {
|
||||
>
|
||||
<span class="quality-dot" style={`background:${qualityColor(quality)};`} title={`Quality: ${quality}`} />
|
||||
<span class="sv-label">{label}:</span>
|
||||
<input
|
||||
class="sv-input"
|
||||
type={isNumeric ? 'number' : 'text'}
|
||||
value={inputValue}
|
||||
onInput={(e: Event) => setInputValue((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={handleKeydown}
|
||||
placeholder="value"
|
||||
/>
|
||||
<span class="sv-current">{displayValue()}</span>
|
||||
{unit && <span class="sv-unit">{unit}</span>}
|
||||
<button class="sv-btn" onClick={handleSet}>Set</button>
|
||||
{isEnum ? (
|
||||
<>
|
||||
<select
|
||||
class="sv-input sv-select"
|
||||
value={enumSelected}
|
||||
disabled={!writeAllowed}
|
||||
onChange={(e: Event) => setEnumSelected((e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
{meta!.enumStrings!.map((s, i) => (
|
||||
<option key={i} value={String(i)}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
<button class="sv-btn" onClick={handleEnumSet} disabled={!writeAllowed}
|
||||
title={writeAllowed ? '' : 'You do not have permission to write this signal'}>Set</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
class="sv-input"
|
||||
type={isNumeric ? 'number' : 'text'}
|
||||
value={inputValue}
|
||||
disabled={!writeAllowed}
|
||||
onInput={(e: Event) => setInputValue((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={handleKeydown}
|
||||
placeholder="value"
|
||||
/>
|
||||
<span class="sv-current">{displayValue()}</span>
|
||||
{unit && <span class="sv-unit">{unit}</span>}
|
||||
<button class="sv-btn" onClick={handleSet} disabled={!writeAllowed}
|
||||
title={writeAllowed ? '' : 'You do not have permission to write this signal'}>Set</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||
import { formatValue } from '../lib/format';
|
||||
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
||||
|
||||
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||
@@ -32,13 +33,12 @@ export default function TextView({ widget, onContextMenu }: Props) {
|
||||
const rawUnit = widget.options['unit'];
|
||||
const unit = rawUnit === 'none' ? '' : (rawUnit || meta?.unit || '');
|
||||
const quality = sv.quality;
|
||||
const fmt = widget.options['format'] ?? '';
|
||||
|
||||
function displayValue(): string {
|
||||
if (!sv || sv.value === null || sv.value === undefined) return '---';
|
||||
const v = sv.value;
|
||||
if (typeof v === 'number') {
|
||||
return Number.isFinite(v) ? v.toPrecision(6).replace(/\.?0+$/, '') : String(v);
|
||||
}
|
||||
if (typeof v === 'number') return formatValue(v, fmt);
|
||||
return String(v);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"panels": {},
|
||||
"folders": {
|
||||
"fld-68b0bb9c23c9fd3b": {
|
||||
"id": "fld-68b0bb9c23c9fd3b",
|
||||
"name": "Test",
|
||||
"owner": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -128,7 +128,7 @@ fi
|
||||
# ── Start uopi ────────────────────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
echo "Starting uopi (http://localhost:8080)..."
|
||||
echo "Starting uopi (http://localhost:8088)..."
|
||||
echo " UOPI_CA_DEBUG=$UOPI_CA_DEBUG"
|
||||
echo " Config: $WORKSPACE/uopi.toml"
|
||||
echo " Log will appear below. Press Ctrl-C to stop."
|
||||
|
||||
@@ -43,6 +43,25 @@ record(calc, "UOPI:TEMP") {
|
||||
field(HHSV, "MAJOR")
|
||||
}
|
||||
|
||||
record(calc, "UOPI:TEMP_A") {
|
||||
field(DESC, "Simulated temperature")
|
||||
field(SCAN, ".1 second")
|
||||
field(CALC, "10+20*SIN(A+10)")
|
||||
field(INPA, "UOPI:TICK NPP NMS")
|
||||
field(EGU, "degC")
|
||||
field(PREC, "2")
|
||||
field(LOPR, "0")
|
||||
field(HOPR, "100")
|
||||
field(LOW, "20")
|
||||
field(HIGH, "78")
|
||||
field(LOLO, "12")
|
||||
field(HIHI, "88")
|
||||
field(LSV, "MINOR")
|
||||
field(HSV, "MINOR")
|
||||
field(LLSV, "MAJOR")
|
||||
field(HHSV, "MAJOR")
|
||||
}
|
||||
|
||||
# Pressure: sawtooth 0–10 bar, ~20 s period
|
||||
# Tests: gauge, barh, different EGU
|
||||
record(calc, "UOPI:PRESSURE") {
|
||||
|
||||
Reference in New Issue
Block a user