Compare commits

..

8 Commits

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-19 07:27:35 +02:00
Martino Ferrari aba394b84d Major changes: logic add to panel, local variables, panel histor, users management... 2026-06-18 17:37:04 +02:00
Martino Ferrari 71430bc3b0 Wworking on improving the tool 2026-05-21 07:41:56 +02:00
Martino Ferrari 6ff8fb5c25 Improved perfs 2026-05-12 10:16:48 +02:00
Martino Ferrari 912ecdd9ed Improved UI 2026-05-06 15:55:45 +02:00
Martino Ferrari 0a5a85e4c4 working epics ioc and tested 2026-05-04 21:13:36 +02:00
94 changed files with 15723 additions and 948 deletions
+31 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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 (18) | 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 50250% |
| Plot buffer | 200,000 samples per signal retained in-browser |
+86
View File
@@ -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
View File
@@ -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` (18) | 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.
+189
View File
@@ -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
}
+35
View File
@@ -0,0 +1,35 @@
package access
import "testing"
func TestCanEditLogic(t *testing.T) {
groups := map[string][]string{"ops": {"carol"}}
// No allowlist configured: everyone with write access may edit logic.
open := New("", nil, groups, nil)
for _, u := range []string{"", "alice", "carol"} {
if !open.CanEditLogic(u) {
t.Errorf("unrestricted: CanEditLogic(%q) = false, want true", u)
}
}
if open.LogicRestricted() {
t.Error("LogicRestricted() = true with no allowlist")
}
// Allowlist by username and by group name.
p := New("", nil, groups, []string{"alice", "ops"})
if !p.LogicRestricted() {
t.Error("LogicRestricted() = false with allowlist set")
}
cases := map[string]bool{
"": true, // anonymous / trusted LAN
"alice": true, // listed user
"carol": true, // member of listed group "ops"
"bob": false, // not listed
}
for u, want := range cases {
if got := p.CanEditLogic(u); got != want {
t.Errorf("CanEditLogic(%q) = %v, want %v", u, got, want)
}
}
}
+884 -17
View File
File diff suppressed because it is too large Load Diff
+16 -3
View File
@@ -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
View File
@@ -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
+269
View File
@@ -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)
}
}
+62
View File
@@ -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()
+66 -3
View File
@@ -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)
}
+161
View File
@@ -0,0 +1,161 @@
// Minimal 5-field cron parser for control-logic cron triggers.
//
// Fields, in order: minute hour day-of-month month day-of-week.
// Each field supports:
//
// * any value
// */n every n (step over the whole range)
// a-b inclusive range
// a-b/n range with step
// a,b,c comma-separated list of the above
// N a single value
//
// Day-of-week is 0-6 with 0 = Sunday (7 is also accepted as Sunday). When both
// day-of-month and day-of-week are restricted (neither is "*"), the schedule
// matches when EITHER matches, following Vixie cron semantics.
package controllogic
import (
"fmt"
"strconv"
"strings"
"time"
)
// Schedule is a parsed 5-field cron specification.
type Schedule struct {
minute uint64 // bitmask 0..59
hour uint64 // bitmask 0..23
dom uint64 // bitmask 1..31
month uint64 // bitmask 1..12
dow uint64 // bitmask 0..6
domStar bool // day-of-month field was "*"
dowStar bool // day-of-week field was "*"
}
// ParseSchedule parses a 5-field cron spec. Extra whitespace is tolerated.
func ParseSchedule(spec string) (*Schedule, error) {
fields := strings.Fields(spec)
if len(fields) != 5 {
return nil, fmt.Errorf("cron spec must have 5 fields, got %d", len(fields))
}
s := &Schedule{}
var err error
if s.minute, err = parseField(fields[0], 0, 59); err != nil {
return nil, fmt.Errorf("minute: %w", err)
}
if s.hour, err = parseField(fields[1], 0, 23); err != nil {
return nil, fmt.Errorf("hour: %w", err)
}
if s.dom, err = parseField(fields[2], 1, 31); err != nil {
return nil, fmt.Errorf("day-of-month: %w", err)
}
if s.month, err = parseField(fields[3], 1, 12); err != nil {
return nil, fmt.Errorf("month: %w", err)
}
if s.dow, err = parseDOW(fields[4]); err != nil {
return nil, fmt.Errorf("day-of-week: %w", err)
}
s.domStar = strings.TrimSpace(fields[2]) == "*"
s.dowStar = strings.TrimSpace(fields[4]) == "*"
return s, nil
}
// Match reports whether t (truncated to the minute) satisfies the schedule.
func (s *Schedule) Match(t time.Time) bool {
if s.minute&bit(t.Minute()) == 0 {
return false
}
if s.hour&bit(t.Hour()) == 0 {
return false
}
if s.month&bit(int(t.Month())) == 0 {
return false
}
domMatch := s.dom&bit(t.Day()) != 0
dowMatch := s.dow&bit(int(t.Weekday())) != 0
// Vixie semantics: when both day fields are restricted, match either.
switch {
case s.domStar && s.dowStar:
return true
case s.domStar:
return dowMatch
case s.dowStar:
return domMatch
default:
return domMatch || dowMatch
}
}
func bit(n int) uint64 { return uint64(1) << uint(n) }
func parseDOW(field string) (uint64, error) {
// Normalise 7 → 0 (both mean Sunday) by parsing then folding.
mask, err := parseField(field, 0, 7)
if err != nil {
return 0, err
}
if mask&bit(7) != 0 {
mask = (mask &^ bit(7)) | bit(0)
}
return mask, nil
}
func parseField(field string, lo, hi int) (uint64, error) {
var mask uint64
for _, part := range strings.Split(field, ",") {
m, err := parsePart(strings.TrimSpace(part), lo, hi)
if err != nil {
return 0, err
}
mask |= m
}
if mask == 0 {
return 0, fmt.Errorf("empty field %q", field)
}
return mask, nil
}
func parsePart(part string, lo, hi int) (uint64, error) {
if part == "" {
return 0, fmt.Errorf("empty term")
}
step := 1
rangePart := part
if idx := strings.IndexByte(part, '/'); idx >= 0 {
rangePart = part[:idx]
st, err := strconv.Atoi(part[idx+1:])
if err != nil || st < 1 {
return 0, fmt.Errorf("bad step %q", part[idx+1:])
}
step = st
}
start, end := lo, hi
if rangePart != "*" {
if idx := strings.IndexByte(rangePart, '-'); idx >= 0 {
a, err1 := strconv.Atoi(rangePart[:idx])
b, err2 := strconv.Atoi(rangePart[idx+1:])
if err1 != nil || err2 != nil {
return 0, fmt.Errorf("bad range %q", rangePart)
}
start, end = a, b
} else {
v, err := strconv.Atoi(rangePart)
if err != nil {
return 0, fmt.Errorf("bad value %q", rangePart)
}
start, end = v, v
}
}
if start < lo || end > hi || start > end {
return 0, fmt.Errorf("value out of range [%d,%d] in %q", lo, hi, part)
}
var mask uint64
for v := start; v <= end; v += step {
mask |= bit(v)
}
return mask, nil
}
+92
View File
@@ -0,0 +1,92 @@
package controllogic
import (
"testing"
"time"
)
func mustSched(t *testing.T, spec string) *Schedule {
t.Helper()
s, err := ParseSchedule(spec)
if err != nil {
t.Fatalf("ParseSchedule(%q): %v", spec, err)
}
return s
}
func TestCronEveryMinute(t *testing.T) {
s := mustSched(t, "* * * * *")
if !s.Match(time.Date(2026, 6, 18, 12, 34, 0, 0, time.UTC)) {
t.Error("* * * * * should match any time")
}
}
func TestCronSpecificTime(t *testing.T) {
s := mustSched(t, "30 9 * * *")
if !s.Match(time.Date(2026, 6, 18, 9, 30, 0, 0, time.UTC)) {
t.Error("should match 09:30")
}
if s.Match(time.Date(2026, 6, 18, 9, 31, 0, 0, time.UTC)) {
t.Error("should not match 09:31")
}
}
func TestCronStep(t *testing.T) {
s := mustSched(t, "*/15 * * * *")
for _, m := range []int{0, 15, 30, 45} {
if !s.Match(time.Date(2026, 6, 18, 1, m, 0, 0, time.UTC)) {
t.Errorf("*/15 should match minute %d", m)
}
}
if s.Match(time.Date(2026, 6, 18, 1, 7, 0, 0, time.UTC)) {
t.Error("*/15 should not match minute 7")
}
}
func TestCronRangeAndList(t *testing.T) {
s := mustSched(t, "0 9-17 * * 1,2,3,4,5")
// 2026-06-18 is a Thursday (weekday 4).
if !s.Match(time.Date(2026, 6, 18, 10, 0, 0, 0, time.UTC)) {
t.Error("should match Thursday 10:00")
}
if s.Match(time.Date(2026, 6, 18, 18, 0, 0, 0, time.UTC)) {
t.Error("should not match 18:00 (out of 9-17)")
}
// 2026-06-20 is a Saturday (weekday 6).
if s.Match(time.Date(2026, 6, 20, 10, 0, 0, 0, time.UTC)) {
t.Error("should not match Saturday")
}
}
func TestCronDOWSunday7(t *testing.T) {
s := mustSched(t, "0 0 * * 7")
// 2026-06-21 is a Sunday.
if !s.Match(time.Date(2026, 6, 21, 0, 0, 0, 0, time.UTC)) {
t.Error("7 should mean Sunday")
}
}
func TestCronDomOrDow(t *testing.T) {
// When both day fields restricted, match either (Vixie semantics).
s := mustSched(t, "0 0 1 * 5")
// 2026-06-01 is a Monday — matches via day-of-month=1.
if !s.Match(time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)) {
t.Error("should match day-of-month 1")
}
// 2026-06-19 is a Friday (weekday 5) — matches via day-of-week.
if !s.Match(time.Date(2026, 6, 19, 0, 0, 0, 0, time.UTC)) {
t.Error("should match Friday")
}
// 2026-06-18 Thursday, not the 1st — no match.
if s.Match(time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)) {
t.Error("should not match Thursday the 18th")
}
}
func TestCronInvalid(t *testing.T) {
for _, bad := range []string{"* * * *", "60 * * * *", "* 24 * * *", "* * 0 * *", "a * * * *", "*/0 * * * *"} {
if _, err := ParseSchedule(bad); err == nil {
t.Errorf("ParseSchedule(%q) should error", bad)
}
}
}
+688
View File
@@ -0,0 +1,688 @@
package controllogic
import (
"context"
"log/slog"
"math"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/uopi/uopi/internal/broker"
)
// Guards against runaway flows (cycles / pathological loops).
const (
maxSteps = 100000
maxLoop = 100000
)
// Engine runs all enabled control-logic graphs continuously under a root
// context. Reload tears down the current generation (subscriptions, timers,
// in-flight flows) and rebuilds from the store's enabled graphs.
type Engine struct {
broker *broker.Broker
store *Store
log *slog.Logger
root context.Context
mu sync.Mutex
cancel context.CancelFunc // cancels the current generation
wg *sync.WaitGroup // tracks the current generation's goroutines
// Shared live signal cache for the current generation (key "ds\0name").
liveMu sync.RWMutex
live map[string]float64
}
// NewEngine creates an engine bound to root. Call Reload to start it.
func NewEngine(root context.Context, brk *broker.Broker, store *Store, log *slog.Logger) *Engine {
return &Engine{
broker: brk,
store: store,
log: log,
root: root,
live: map[string]float64{},
}
}
// ── reference / value helpers ──────────────────────────────────────────────────
func refKey(ds, name string) string { return ds + "\x00" + name }
// parseRef splits a "ds:name" target on the FIRST ':' (EPICS PV names contain
// ':'). A bare name (no ':') is a graph-local variable in data source "local".
func parseRef(target string) (ds, name string, ok bool) {
t := strings.TrimSpace(target)
if t == "" {
return "", "", false
}
i := strings.IndexByte(t, ':')
if i < 0 {
return "local", t, true
}
return t[:i], t[i+1:], true
}
func toNum(v any) float64 {
switch x := v.(type) {
case float64:
return x
case float32:
return float64(x)
case int64:
return float64(x)
case int:
return float64(x)
case bool:
if x {
return 1
}
return 0
case string:
f, err := strconv.ParseFloat(strings.TrimSpace(x), 64)
if err != nil {
return math.NaN()
}
return f
default:
return math.NaN()
}
}
// ── lifecycle ───────────────────────────────────────────────────────────────
// Reload rebuilds the engine from the store. Safe to call repeatedly (after any
// graph mutation). It is a no-op-safe full restart of the running generation.
func (e *Engine) Reload() {
e.mu.Lock()
defer e.mu.Unlock()
// Tear down the previous generation and wait for its goroutines to exit.
if e.cancel != nil {
e.cancel()
e.cancel = nil
}
if e.wg != nil {
e.wg.Wait()
e.wg = nil
}
e.liveMu.Lock()
e.live = map[string]float64{}
e.liveMu.Unlock()
graphs := e.store.List()
var compiled []*compiledGraph
refs := map[string]RefLite{}
for i := range graphs {
g := graphs[i]
if !g.Enabled {
continue
}
cg := compile(g)
compiled = append(compiled, cg)
for k, r := range cg.refs {
refs[k] = r
}
}
if len(compiled) == 0 {
return
}
genCtx, cancel := context.WithCancel(e.root)
wg := &sync.WaitGroup{}
e.cancel = cancel
e.wg = wg
for _, cg := range compiled {
cg.engine = e
cg.genCtx = genCtx
cg.wg = wg
}
// One shared updates channel feeds a single dispatch goroutine; every
// subscription delivers into it. Subscriptions are released on teardown.
updates := make(chan broker.Update, 128)
var unsubs []func()
for _, r := range refs {
unsub, err := e.broker.Subscribe(broker.SignalRef{DS: r.DS, Name: r.Name}, updates)
if err != nil {
e.log.Warn("control logic: subscribe failed", "ds", r.DS, "signal", r.Name, "err", err)
continue
}
unsubs = append(unsubs, unsub)
}
wg.Add(1)
go func() {
defer wg.Done()
<-genCtx.Done()
for _, u := range unsubs {
u()
}
}()
// Dispatch goroutine: keep the live cache fresh and drive level/edge triggers.
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case u := <-updates:
val := toNum(u.Value.Data)
key := refKey(u.Ref.DS, u.Ref.Name)
e.liveMu.Lock()
e.live[key] = val
e.liveMu.Unlock()
for _, cg := range compiled {
cg.onSignal(key, val)
}
case <-genCtx.Done():
return
}
}
}()
// Start timer and cron triggers.
for _, cg := range compiled {
cg.startTriggers()
}
e.log.Info("control logic engine reloaded", "graphs", len(compiled), "signals", len(refs))
}
// liveGet reads the current value of a signal from the shared cache.
func (e *Engine) liveGet(ds, name string) float64 {
if ds == "sys" {
if name == "time" {
return float64(time.Now().UnixNano()) / 1e9
}
return math.NaN() // sys:dt handled per-activation
}
e.liveMu.RLock()
defer e.liveMu.RUnlock()
v, ok := e.live[refKey(ds, name)]
if !ok {
return math.NaN()
}
return v
}
// write applies an action.write/lua-set to a target: a bare name updates a
// graph-local var; a ds:name target writes to the data source.
func (e *Engine) write(cg *compiledGraph, target string, val float64) {
ds, name, ok := parseRef(target)
if !ok || math.IsNaN(val) {
return
}
if ds == "local" {
cg.setLocal(name, val)
return
}
src, ok := e.broker.Source(ds)
if !ok {
e.log.Warn("control logic: write to unknown data source", "ds", ds, "signal", name)
return
}
if err := src.Write(e.root, name, val); err != nil {
e.log.Warn("control logic: write failed", "ds", ds, "signal", name, "err", err)
}
}
// ── compiled graph ─────────────────────────────────────────────────────────────
type wireOut struct {
to string
port string
}
// compiledGraph holds the runtime state for one enabled graph.
type compiledGraph struct {
engine *Engine
genCtx context.Context
wg *sync.WaitGroup
name string
byId map[string]Node
out map[string][]wireOut
inc map[string][]string // incoming source ids per node (for gates)
refs map[string]RefLite // unique signals to subscribe (excl. sys/local)
watchers map[string][]string // signal key → trigger node ids
luaNodes map[string]*luaRuntime
stateMu sync.Mutex
levelState map[string]bool // current truth of level triggers (threshold/alarm)
prevBool map[string]bool // edge detection for threshold/alarm
prevVal map[string]float64 // last value for change triggers
hasVal map[string]bool
lastFire map[string]int64 // ns wall clock each trigger last fired
locals map[string]float64
}
func compile(g Graph) *compiledGraph {
cg := &compiledGraph{
name: g.Name,
byId: map[string]Node{},
out: map[string][]wireOut{},
inc: map[string][]string{},
refs: map[string]RefLite{},
watchers: map[string][]string{},
luaNodes: map[string]*luaRuntime{},
levelState: map[string]bool{},
prevBool: map[string]bool{},
prevVal: map[string]float64{},
hasVal: map[string]bool{},
lastFire: map[string]int64{},
locals: map[string]float64{},
}
for _, n := range g.Nodes {
cg.byId[n.ID] = n
}
for _, w := range g.Wires {
port := w.FromPort
if port == "" {
port = "out"
}
cg.out[w.From] = append(cg.out[w.From], wireOut{to: w.To, port: port})
cg.inc[w.To] = append(cg.inc[w.To], w.From)
}
want := func(ds, name string) {
if ds == "sys" || ds == "local" {
return
}
if name == "" {
return
}
cg.refs[refKey(ds, name)] = RefLite{DS: ds, Name: name}
}
wantExpr := func(expr string) {
for _, r := range CollectRefs(expr) {
want(r.DS, r.Name)
}
}
for _, n := range g.Nodes {
switch n.Kind {
case "trigger.threshold", "trigger.change", "trigger.alarm":
if ds, name, ok := parseRef(n.param("signal")); ok {
want(ds, name)
key := refKey(ds, name)
cg.watchers[key] = append(cg.watchers[key], n.ID)
}
case "flow.if":
wantExpr(n.param("cond"))
case "flow.loop":
if n.param("mode") == "while" {
wantExpr(n.param("cond"))
}
case "action.write", "action.log":
wantExpr(n.param("expr"))
case "action.lua":
cg.luaNodes[n.ID] = newLuaRuntime(n.param("script"))
for _, r := range luaGetRefs(n.param("script")) {
want(r.DS, r.Name)
}
}
}
return cg
}
func (cg *compiledGraph) setLocal(name string, v float64) {
cg.stateMu.Lock()
cg.locals[name] = v
cg.stateMu.Unlock()
}
func (cg *compiledGraph) getLocal(name string) float64 {
cg.stateMu.Lock()
defer cg.stateMu.Unlock()
v, ok := cg.locals[name]
if !ok {
return 0
}
return v
}
// startTriggers launches timer and cron trigger goroutines for the generation.
func (cg *compiledGraph) startTriggers() {
hasCron := false
for _, n := range cg.byId {
switch n.Kind {
case "trigger.timer":
node := n
cg.wg.Add(1)
go func() {
defer cg.wg.Done()
d := intervalOf(node)
t := time.NewTicker(d)
defer t.Stop()
for {
select {
case <-t.C:
cg.activate(node.ID)
case <-cg.genCtx.Done():
return
}
}
}()
case "trigger.cron":
hasCron = true
}
}
if hasCron {
cg.startCron()
}
}
func (cg *compiledGraph) startCron() {
type cronNode struct {
id string
sched *Schedule
}
var crons []cronNode
for _, n := range cg.byId {
if n.Kind != "trigger.cron" {
continue
}
sched, err := ParseSchedule(n.param("spec"))
if err != nil {
cg.engine.log.Warn("control logic: bad cron spec", "graph", cg.name, "spec", n.param("spec"), "err", err)
continue
}
crons = append(crons, cronNode{id: n.ID, sched: sched})
}
if len(crons) == 0 {
return
}
cg.wg.Add(1)
go func() {
defer cg.wg.Done()
t := time.NewTicker(time.Second)
defer t.Stop()
lastMinute := -1
for {
select {
case now := <-t.C:
minute := now.Hour()*60 + now.Minute()
if minute == lastMinute {
continue // fire at most once per minute
}
lastMinute = minute
for _, c := range crons {
if c.sched.Match(now) {
cg.activate(c.id)
}
}
case <-cg.genCtx.Done():
return
}
}
}()
}
func intervalOf(n Node) time.Duration {
ms, err := strconv.Atoi(strings.TrimSpace(n.param("interval")))
if err != nil || ms < 50 {
ms = 1000
}
return time.Duration(ms) * time.Millisecond
}
// ── trigger evaluation ─────────────────────────────────────────────────────────
// onSignal drives threshold/alarm (rising edge) and change triggers when a
// watched signal updates.
func (cg *compiledGraph) onSignal(key string, value float64) {
for _, id := range cg.watchers[key] {
node, ok := cg.byId[id]
if !ok {
continue
}
switch node.Kind {
case "trigger.threshold":
cur := testThreshold(value, node.param("op"), parseFloat(node.param("value")))
cg.stateMu.Lock()
cg.levelState[id] = cur
prev := cg.prevBool[id]
cg.prevBool[id] = cur
cg.stateMu.Unlock()
if cur && !prev {
cg.activate(id)
}
case "trigger.alarm":
lo := parseFloat(node.param("min"))
hi := parseFloat(node.param("max"))
cur := !math.IsNaN(value) && (value < lo || value > hi)
cg.stateMu.Lock()
cg.levelState[id] = cur
prev := cg.prevBool[id]
cg.prevBool[id] = cur
cg.stateMu.Unlock()
if cur && !prev {
cg.activate(id)
}
case "trigger.change":
cg.stateMu.Lock()
had := cg.hasVal[id]
prev := cg.prevVal[id]
cg.prevVal[id] = value
cg.hasVal[id] = true
cg.stateMu.Unlock()
if had && value != prev {
cg.activate(id)
}
}
}
}
func testThreshold(val float64, op string, cmp float64) bool {
if math.IsNaN(val) {
return false
}
switch op {
case "<":
return val < cmp
case ">=":
return val >= cmp
case "<=":
return val <= cmp
case "==":
return val == cmp
case "!=":
return val != cmp
default: // ">"
return val > cmp
}
}
func parseFloat(s string) float64 {
f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
if err != nil {
return 0
}
return f
}
// ── execution ──────────────────────────────────────────────────────────────────
type runCtx struct {
fired string
steps int
resolve Resolver
}
// activate spawns a flow run for a trigger on its own goroutine so that
// action.delay does not block signal dispatch or other flows.
func (cg *compiledGraph) activate(triggerID string) {
now := time.Now().UnixNano()
cg.stateMu.Lock()
last, had := cg.lastFire[triggerID]
cg.lastFire[triggerID] = now
cg.stateMu.Unlock()
dt := 0.0
if had {
dt = float64(now-last) / 1e9
}
resolve := func(ds, name string) float64 {
switch ds {
case "sys":
if name == "dt" {
return dt
}
return cg.engine.liveGet("sys", name)
case "local":
return cg.getLocal(name)
default:
return cg.engine.liveGet(ds, name)
}
}
cg.wg.Add(1)
go func() {
defer cg.wg.Done()
ctx := &runCtx{fired: triggerID, resolve: resolve}
cg.follow(triggerID, "out", ctx)
}()
}
func (cg *compiledGraph) follow(fromID, port string, ctx *runCtx) {
for _, w := range cg.out[fromID] {
if w.port == port {
cg.run(w.to, ctx)
}
}
}
func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
if ctx.steps > maxSteps {
return
}
ctx.steps++
node, ok := cg.byId[nodeID]
if !ok {
return
}
select {
case <-cg.genCtx.Done():
return
default:
}
switch node.Kind {
case "gate.and":
if cg.gateSatisfied(node.ID, ctx.fired) {
cg.follow(node.ID, "out", ctx)
}
case "flow.if":
branch := "else"
if EvalBool(node.param("cond"), ctx.resolve) {
branch = "then"
}
cg.follow(node.ID, branch, ctx)
case "flow.loop":
if node.param("mode") == "while" {
for i := 0; i < maxLoop && ctx.steps <= maxSteps && EvalBool(node.param("cond"), ctx.resolve); i++ {
cg.follow(node.ID, "body", ctx)
}
} else {
n := int(EvalExpr(node.param("count"), ctx.resolve))
if n < 0 {
n = 0
}
if n > maxLoop {
n = maxLoop
}
for i := 0; i < n && ctx.steps <= maxSteps; i++ {
cg.follow(node.ID, "body", ctx)
}
}
cg.follow(node.ID, "done", ctx)
case "action.write":
val := EvalExpr(node.param("expr"), ctx.resolve)
cg.engine.write(cg, node.param("target"), val)
cg.follow(node.ID, "out", ctx)
case "action.delay":
ms := 0
if v, err := strconv.Atoi(strings.TrimSpace(node.param("ms"))); err == nil && v > 0 {
ms = v
}
if ms > 0 {
t := time.NewTimer(time.Duration(ms) * time.Millisecond)
select {
case <-t.C:
case <-cg.genCtx.Done():
t.Stop()
return
}
}
cg.follow(node.ID, "out", ctx)
case "action.log":
val := EvalExpr(node.param("expr"), ctx.resolve)
label := strings.TrimSpace(node.param("label"))
cg.engine.log.Info("control logic log", "graph", cg.name, "label", label, "value", val)
cg.follow(node.ID, "out", ctx)
case "action.lua":
cg.runLua(node.ID, ctx)
cg.follow(node.ID, "out", ctx)
default:
cg.follow(node.ID, "out", ctx)
}
}
// gateSatisfied: every incoming trigger must currently be satisfied. The firing
// trigger counts as satisfied; level triggers (threshold/alarm) use their truth.
func (cg *compiledGraph) gateSatisfied(gateID, fired string) bool {
inputs := cg.inc[gateID]
if len(inputs) == 0 {
return false
}
cg.stateMu.Lock()
defer cg.stateMu.Unlock()
for _, src := range inputs {
if src == fired || cg.levelState[src] {
continue
}
return false
}
return true
}
// luaGetRefs scans a Lua script for get("ds:name") / get('ds:name') literals so
// the engine can subscribe to the signals the script reads.
var luaGetRe = regexp.MustCompile(`get\s*\(\s*["']([^"']+)["']`)
func luaGetRefs(script string) []RefLite {
var out []RefLite
for _, m := range luaGetRe.FindAllStringSubmatch(script, -1) {
if ds, name, ok := parseRef(m[1]); ok {
out = append(out, RefLite{DS: ds, Name: name})
}
}
return out
}
func (cg *compiledGraph) runLua(nodeID string, ctx *runCtx) {
lr := cg.luaNodes[nodeID]
if lr == nil {
return
}
lr.run(ctx.resolve, func(target string, val float64) {
cg.engine.write(cg, target, val)
}, func(msg string) {
cg.engine.log.Info("control logic lua", "graph", cg.name, "msg", msg)
})
}
+527
View File
@@ -0,0 +1,527 @@
// Small, safe expression evaluator — a Go port of web/src/lib/expr.ts.
//
// Supports numbers, booleans (true/false → 1/0), arithmetic (+ - * / %),
// comparison (< <= > >= == !=), boolean (&& || !), ternary (a ? b : c),
// parentheses, and a handful of math functions. Two kinds of variable
// reference are resolved live at evaluation time:
//
// {ds:name} a data-source signal value (the brace content is split on the
// FIRST ':' so EPICS PV names like "MY:PV:NAME" work).
// bareIdent a graph-local state variable (data source "local").
//
// Booleans are represented as numbers: comparisons / logical ops yield 1 or 0,
// and any nonzero value is truthy. The evaluator never uses reflection or eval;
// it walks a parsed AST against a caller-supplied Resolver.
package controllogic
import (
"fmt"
"math"
"strconv"
"strings"
"sync"
)
// Resolver returns the current numeric value of a signal/local reference.
type Resolver func(ds, name string) float64
// RefLite identifies one signal/local reference read by an expression.
type RefLite struct {
DS string
Name string
}
// ── AST ──────────────────────────────────────────────────────────────────────
type exprNode interface{ eval(R Resolver) float64 }
type numNode struct{ v float64 }
type sigNode struct{ ds, name string }
type varNode struct{ name string }
type unNode struct {
op string
a exprNode
}
type binNode struct {
op string
a, b exprNode
}
type ternNode struct{ c, a, b exprNode }
type callNode struct {
fn string
args []exprNode
}
func (n numNode) eval(R Resolver) float64 { return n.v }
func (n sigNode) eval(R Resolver) float64 { return R(n.ds, n.name) }
func (n varNode) eval(R Resolver) float64 { return R("local", n.name) }
func (n unNode) eval(R Resolver) float64 {
if n.op == "-" {
return -n.a.eval(R)
}
if n.a.eval(R) == 0 {
return 1
}
return 0
}
func (n ternNode) eval(R Resolver) float64 {
if n.c.eval(R) != 0 {
return n.a.eval(R)
}
return n.b.eval(R)
}
func (n callNode) eval(R Resolver) float64 {
args := make([]float64, len(n.args))
for i, a := range n.args {
args[i] = a.eval(R)
}
return funcs[n.fn](args)
}
func (n binNode) eval(R Resolver) float64 {
a, b := n.a.eval(R), n.b.eval(R)
switch n.op {
case "+":
return a + b
case "-":
return a - b
case "*":
return a * b
case "/":
return a / b
case "%":
return math.Mod(a, b)
case "<":
return boolf(a < b)
case "<=":
return boolf(a <= b)
case ">":
return boolf(a > b)
case ">=":
return boolf(a >= b)
case "==":
return boolf(a == b)
case "!=":
return boolf(a != b)
case "&&":
return boolf(a != 0 && b != 0)
case "||":
return boolf(a != 0 || b != 0)
}
return math.NaN()
}
func boolf(b bool) float64 {
if b {
return 1
}
return 0
}
var funcs = map[string]func([]float64) float64{
"abs": func(a []float64) float64 { return math.Abs(a[0]) },
"min": func(a []float64) float64 { return minSlice(a) },
"max": func(a []float64) float64 { return maxSlice(a) },
"sqrt": func(a []float64) float64 { return math.Sqrt(a[0]) },
"floor": func(a []float64) float64 { return math.Floor(a[0]) },
"ceil": func(a []float64) float64 { return math.Ceil(a[0]) },
"round": func(a []float64) float64 { return math.Round(a[0]) },
"sign": func(a []float64) float64 { return float64(sign(a[0])) },
"pow": func(a []float64) float64 { return math.Pow(a[0], a[1]) },
"log": func(a []float64) float64 { return math.Log(a[0]) },
"exp": func(a []float64) float64 { return math.Exp(a[0]) },
"sin": func(a []float64) float64 { return math.Sin(a[0]) },
"cos": func(a []float64) float64 { return math.Cos(a[0]) },
}
func minSlice(a []float64) float64 {
if len(a) == 0 {
return math.Inf(1)
}
m := a[0]
for _, x := range a[1:] {
m = math.Min(m, x)
}
return m
}
func maxSlice(a []float64) float64 {
if len(a) == 0 {
return math.Inf(-1)
}
m := a[0]
for _, x := range a[1:] {
m = math.Max(m, x)
}
return m
}
func sign(x float64) int {
switch {
case x > 0:
return 1
case x < 0:
return -1
default:
return 0
}
}
// ── Tokenizer ────────────────────────────────────────────────────────────────
type tok struct {
k string
v string
}
func tokenize(src string) ([]tok, error) {
var toks []tok
two := map[string]bool{"<=": true, ">=": true, "==": true, "!=": true, "&&": true, "||": true}
r := []rune(src)
i := 0
for i < len(r) {
c := r[i]
switch {
case c == ' ' || c == '\t' || c == '\n' || c == '\r':
i++
continue
case c == '{':
end := -1
for j := i + 1; j < len(r); j++ {
if r[j] == '}' {
end = j
break
}
}
if end < 0 {
return nil, fmt.Errorf("unterminated { in expression")
}
toks = append(toks, tok{k: "sig", v: string(r[i+1 : end])})
i = end + 1
continue
}
if isDigit(c) || (c == '.' && i+1 < len(r) && isDigit(r[i+1])) {
j := i + 1
for j < len(r) && (isDigit(r[j]) || r[j] == '.') {
j++
}
toks = append(toks, tok{k: "num", v: string(r[i:j])})
i = j
continue
}
if isIdentStart(c) {
j := i + 1
for j < len(r) && isIdentPart(r[j]) {
j++
}
toks = append(toks, tok{k: "ident", v: string(r[i:j])})
i = j
continue
}
if i+1 < len(r) {
pair := string(r[i : i+2])
if two[pair] {
toks = append(toks, tok{k: pair})
i += 2
continue
}
}
if strings.ContainsRune("+-*/%<>!()?:,", c) {
toks = append(toks, tok{k: string(c)})
i++
continue
}
return nil, fmt.Errorf("unexpected character %q in expression", string(c))
}
return toks, nil
}
func isDigit(c rune) bool { return c >= '0' && c <= '9' }
func isIdentStart(c rune) bool { return c == '_' || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') }
func isIdentPart(c rune) bool { return isIdentStart(c) || isDigit(c) }
// ── Parser (recursive descent) ────────────────────────────────────────────────
type parser struct {
toks []tok
p int
}
func (ps *parser) peek() (tok, bool) {
if ps.p < len(ps.toks) {
return ps.toks[ps.p], true
}
return tok{}, false
}
func (ps *parser) eat(k string) (tok, error) {
if ps.p >= len(ps.toks) {
return tok{}, fmt.Errorf("unexpected end of expression")
}
t := ps.toks[ps.p]
if k != "" && t.k != k {
return tok{}, fmt.Errorf("expected %q in expression", k)
}
ps.p++
return t, nil
}
func parse(src string) (exprNode, error) {
toks, err := tokenize(src)
if err != nil {
return nil, err
}
ps := &parser{toks: toks}
root, err := ps.ternary()
if err != nil {
return nil, err
}
if ps.p < len(ps.toks) {
return nil, fmt.Errorf("trailing tokens in expression")
}
return root, nil
}
func (ps *parser) primary() (exprNode, error) {
t, ok := ps.peek()
if !ok {
return nil, fmt.Errorf("unexpected end of expression")
}
switch t.k {
case "num":
ps.eat("")
v, err := strconv.ParseFloat(t.v, 64)
if err != nil {
return nil, fmt.Errorf("bad number %q", t.v)
}
return numNode{v: v}, nil
case "sig":
ps.eat("")
idx := strings.IndexByte(t.v, ':')
if idx < 0 {
return sigNode{ds: t.v, name: ""}, nil
}
return sigNode{ds: t.v[:idx], name: t.v[idx+1:]}, nil
case "ident":
ps.eat("")
id := t.v
if id == "true" {
return numNode{v: 1}, nil
}
if id == "false" {
return numNode{v: 0}, nil
}
if nx, ok := ps.peek(); ok && nx.k == "(" {
ps.eat("(")
var args []exprNode
if nx2, ok := ps.peek(); ok && nx2.k != ")" {
a, err := ps.ternary()
if err != nil {
return nil, err
}
args = append(args, a)
for {
nx3, ok := ps.peek()
if !ok || nx3.k != "," {
break
}
ps.eat(",")
a, err := ps.ternary()
if err != nil {
return nil, err
}
args = append(args, a)
}
}
if _, err := ps.eat(")"); err != nil {
return nil, err
}
if _, ok := funcs[id]; !ok {
return nil, fmt.Errorf("unknown function %q", id)
}
return callNode{fn: id, args: args}, nil
}
return varNode{name: id}, nil
case "(":
ps.eat("(")
e, err := ps.ternary()
if err != nil {
return nil, err
}
if _, err := ps.eat(")"); err != nil {
return nil, err
}
return e, nil
}
return nil, fmt.Errorf("unexpected token %q in expression", t.k)
}
func (ps *parser) unary() (exprNode, error) {
if t, ok := ps.peek(); ok && (t.k == "-" || t.k == "!") {
ps.eat("")
a, err := ps.unary()
if err != nil {
return nil, err
}
return unNode{op: t.k, a: a}, nil
}
return ps.primary()
}
func (ps *parser) binLevel(next func() (exprNode, error), ops ...string) (exprNode, error) {
a, err := next()
if err != nil {
return nil, err
}
for {
t, ok := ps.peek()
if !ok || !contains(ops, t.k) {
return a, nil
}
op, _ := ps.eat("")
b, err := next()
if err != nil {
return nil, err
}
a = binNode{op: op.k, a: a, b: b}
}
}
func (ps *parser) mul() (exprNode, error) { return ps.binLevel(ps.unary, "*", "/", "%") }
func (ps *parser) add() (exprNode, error) { return ps.binLevel(ps.mul, "+", "-") }
func (ps *parser) cmp() (exprNode, error) { return ps.binLevel(ps.add, "<", "<=", ">", ">=") }
func (ps *parser) eq() (exprNode, error) { return ps.binLevel(ps.cmp, "==", "!=") }
func (ps *parser) and() (exprNode, error) { return ps.binLevel(ps.eq, "&&") }
func (ps *parser) or() (exprNode, error) { return ps.binLevel(ps.and, "||") }
func (ps *parser) ternary() (exprNode, error) {
c, err := ps.or()
if err != nil {
return nil, err
}
if t, ok := ps.peek(); ok && t.k == "?" {
ps.eat("?")
a, err := ps.ternary()
if err != nil {
return nil, err
}
if _, err := ps.eat(":"); err != nil {
return nil, err
}
b, err := ps.ternary()
if err != nil {
return nil, err
}
return ternNode{c: c, a: a, b: b}, nil
}
return c, nil
}
func contains(s []string, v string) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}
// ── Cache + public API ─────────────────────────────────────────────────────────
type cacheEntry struct {
node exprNode
err error
}
var (
cacheMu sync.Mutex
cache = map[string]cacheEntry{}
)
func parseCached(src string) (exprNode, error) {
cacheMu.Lock()
e, ok := cache[src]
cacheMu.Unlock()
if ok {
return e.node, e.err
}
n, err := parse(src)
cacheMu.Lock()
cache[src] = cacheEntry{node: n, err: err}
cacheMu.Unlock()
return n, err
}
// EvalExpr evaluates an expression string, returning NaN on parse/eval failure.
func EvalExpr(src string, resolve Resolver) float64 {
n, err := parseCached(src)
if err != nil {
return math.NaN()
}
return safeEval(n, resolve)
}
func safeEval(n exprNode, resolve Resolver) (out float64) {
defer func() {
if recover() != nil {
out = math.NaN()
}
}()
return n.eval(resolve)
}
// EvalBool reports whether the expression evaluates to a nonzero, non-NaN value.
func EvalBool(src string, resolve Resolver) bool {
v := EvalExpr(src, resolve)
return !math.IsNaN(v) && v != 0
}
// CollectRefs returns every signal/local reference an expression reads, for
// subscription. Returns nil for an unparseable expression.
func CollectRefs(src string) []RefLite {
root, err := parseCached(src)
if err != nil {
return nil
}
var out []RefLite
seen := map[string]bool{}
add := func(ds, name string) {
k := ds + "\x00" + name
if !seen[k] {
seen[k] = true
out = append(out, RefLite{DS: ds, Name: name})
}
}
var walk func(n exprNode)
walk = func(n exprNode) {
switch t := n.(type) {
case sigNode:
add(t.ds, t.name)
case varNode:
add("local", t.name)
case unNode:
walk(t.a)
case binNode:
walk(t.a)
walk(t.b)
case ternNode:
walk(t.c)
walk(t.a)
walk(t.b)
case callNode:
for _, a := range t.args {
walk(a)
}
}
}
walk(root)
return out
}
// CheckExpr validates an expression; returns an error message or "" if it parses.
func CheckExpr(src string) string {
if strings.TrimSpace(src) == "" {
return ""
}
if _, err := parse(src); err != nil {
return err.Error()
}
return ""
}
+86
View File
@@ -0,0 +1,86 @@
package controllogic
import (
"math"
"testing"
)
func TestEvalExpr(t *testing.T) {
resolve := func(ds, name string) float64 {
switch {
case ds == "stub" && name == "x":
return 10
case ds == "local" && name == "y":
return 3
case ds == "sys" && name == "dt":
return 0.5
}
return math.NaN()
}
cases := []struct {
expr string
want float64
}{
{"1 + 2 * 3", 7},
{"(1 + 2) * 3", 9},
{"10 % 3", 1},
{"{stub:x} + y", 13},
{"{stub:x} > 5 ? 1 : 0", 1},
{"min(4, 2, 8)", 2},
{"max(4, 2, 8)", 8},
{"abs(-5)", 5},
{"sqrt(16)", 4},
{"2 < 3 && 3 < 4", 1},
{"!0", 1},
{"-{stub:x}", -10},
{"{sys:dt} * 2", 1},
{"true + false", 1},
}
for _, c := range cases {
got := EvalExpr(c.expr, resolve)
if math.Abs(got-c.want) > 1e-9 {
t.Errorf("EvalExpr(%q) = %v, want %v", c.expr, got, c.want)
}
}
}
func TestEvalExprErrors(t *testing.T) {
r := func(ds, name string) float64 { return 0 }
for _, bad := range []string{"1 +", "(1", "1 2", "{unterminated"} {
if v := EvalExpr(bad, r); !math.IsNaN(v) {
t.Errorf("EvalExpr(%q) = %v, want NaN", bad, v)
}
}
}
func TestCollectRefs(t *testing.T) {
refs := CollectRefs("{stub:x} + y + {epics:MY:PV} * z")
got := map[string]bool{}
for _, r := range refs {
got[r.DS+":"+r.Name] = true
}
for _, want := range []string{"stub:x", "local:y", "epics:MY:PV", "local:z"} {
if !got[want] {
t.Errorf("CollectRefs missing %q (got %v)", want, got)
}
}
}
func TestCheckExpr(t *testing.T) {
if msg := CheckExpr("1 + 2"); msg != "" {
t.Errorf("CheckExpr valid returned %q", msg)
}
if msg := CheckExpr("1 +"); msg == "" {
t.Errorf("CheckExpr invalid returned empty")
}
if msg := CheckExpr(""); msg != "" {
t.Errorf("CheckExpr empty returned %q", msg)
}
}
func TestEpicsRefSplitFirstColon(t *testing.T) {
refs := CollectRefs("{epics:SR:BPM:01:X}")
if len(refs) != 1 || refs[0].DS != "epics" || refs[0].Name != "SR:BPM:01:X" {
t.Errorf("got %+v, want epics / SR:BPM:01:X", refs)
}
}
+115
View File
@@ -0,0 +1,115 @@
package controllogic
import (
"sync"
lua "github.com/yuin/gopher-lua"
)
// luaRuntime holds a sandboxed Lua VM for one action.lua node. The VM is created
// lazily and reused; access is serialised so concurrent flow activations of the
// same node don't share VM state unsafely. Host functions exposed to scripts:
//
// get("ds:name") → number read a signal / sys / local value (NaN if absent)
// set("ds:name", v) write a value (bare name → graph-local var)
// log(msg) append to the server log
//
// The os, io, package, debug and code-loading globals are removed, mirroring the
// dsp LuaNode sandbox.
type luaRuntime struct {
script string
mu sync.Mutex
L *lua.LState
// Bound to the current activation while run() holds mu.
curResolve Resolver
curSet func(target string, val float64)
curLog func(msg string)
}
func newLuaRuntime(script string) *luaRuntime {
return &luaRuntime{script: script}
}
func (lr *luaRuntime) ensure() error {
if lr.L != nil {
return nil
}
L := lua.NewState(lua.Options{SkipOpenLibs: true})
for _, pair := range []struct {
name string
fn lua.LGFunction
}{
{lua.LoadLibName, lua.OpenPackage},
{lua.BaseLibName, lua.OpenBase},
{lua.MathLibName, lua.OpenMath},
{lua.StringLibName, lua.OpenString},
{lua.TabLibName, lua.OpenTable},
} {
if err := L.CallByParam(lua.P{Fn: L.NewFunction(pair.fn), NRet: 0, Protect: true}, lua.LString(pair.name)); err != nil {
L.Close()
return err
}
}
L.SetGlobal("load", lua.LNil)
L.SetGlobal("loadfile", lua.LNil)
L.SetGlobal("dofile", lua.LNil)
L.SetGlobal("require", lua.LNil)
L.SetGlobal("get", L.NewFunction(func(s *lua.LState) int {
target := s.CheckString(1)
ds, name, ok := parseRef(target)
var v float64
if ok && lr.curResolve != nil {
v = lr.curResolve(ds, name)
}
s.Push(lua.LNumber(v))
return 1
}))
L.SetGlobal("set", L.NewFunction(func(s *lua.LState) int {
target := s.CheckString(1)
val := float64(s.CheckNumber(2))
if lr.curSet != nil {
lr.curSet(target, val)
}
return 0
}))
L.SetGlobal("log", L.NewFunction(func(s *lua.LState) int {
if lr.curLog != nil {
lr.curLog(s.CheckString(1))
}
return 0
}))
lr.L = L
return nil
}
// run executes the script once with the given host hooks bound.
func (lr *luaRuntime) run(resolve Resolver, set func(string, float64), logf func(string)) {
lr.mu.Lock()
defer lr.mu.Unlock()
if err := lr.ensure(); err != nil {
logf("lua init error: " + err.Error())
return
}
lr.curResolve = resolve
lr.curSet = set
lr.curLog = logf
defer func() {
lr.curResolve = nil
lr.curSet = nil
lr.curLog = nil
if r := recover(); r != nil {
logf("lua panic")
}
}()
lr.L.SetTop(0)
if err := lr.L.DoString(lr.script); err != nil {
logf("lua error: " + err.Error())
}
}
+65
View File
@@ -0,0 +1,65 @@
// Package controllogic implements a server-side flow-graph engine. It mirrors
// the client-side panel logic engine (web/src/lib/logic.ts) but runs
// continuously on the server under the root context, independent of any panel.
//
// A control-logic graph is a Node-RED-style flow of trigger, gate, control-flow
// and action nodes connected by wires. Triggers are flow entry points; when one
// activates the engine follows the outgoing wires executing downstream nodes.
//
// Compared with the panel engine, control logic has no button trigger (buttons
// are panel-UI driven) and gains two headless triggers — cron (5-field schedule)
// and alarm (signal out of an allowed range) — plus a Lua script action node
// with get/set/log host functions.
//
// Graphs are persisted as JSON in {storageDir}/controllogic.json and CRUD'd via
// the REST API; the engine rebuilds itself (Reload) whenever they change.
package controllogic
// Node kinds. Triggers begin flows; the rest execute downstream.
//
// trigger.threshold — fires on the rising edge of cmp(signal, value).
// trigger.change — fires whenever a watched signal's value changes.
// trigger.timer — fires every `interval` ms.
// trigger.cron — fires when the wall clock matches a 5-field cron `spec`.
// trigger.alarm — fires on the rising edge of signal leaving [min,max].
// gate.and — passes only when all incoming triggers are satisfied.
// flow.if — evaluates `cond`, continues on the 'then'/'else' port.
// flow.loop — repeats the 'body' port (count / while, capped) then 'done'.
// action.write — evaluates `expr`, writes the result to `target`.
// action.delay — waits `ms` before continuing.
// action.log — logs an expression value to the server log.
// action.lua — runs a sandboxed Lua script with get/set/log host funcs.
// Node is a single node in a control-logic graph. Params are stored as strings
// (matching the panel logic model) and parsed per-kind by the engine.
type Node struct {
ID string `json:"id"`
Kind string `json:"kind"`
X float64 `json:"x"`
Y float64 `json:"y"`
Params map[string]string `json:"params,omitempty"`
}
// Wire connects an output port of one node to the input of another.
// FromPort defaults to "out" when empty.
type Wire struct {
From string `json:"from"`
FromPort string `json:"fromPort,omitempty"`
To string `json:"to"`
}
// Graph is a named, independently-enableable control-logic flow.
type Graph struct {
ID string `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
Nodes []Node `json:"nodes"`
Wires []Wire `json:"wires"`
}
func (n Node) param(key string) string {
if n.Params == nil {
return ""
}
return n.Params[key]
}
+111
View File
@@ -0,0 +1,111 @@
package controllogic
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
)
const definitionsFile = "controllogic.json"
// ErrNotFound is returned when a graph id does not exist.
var ErrNotFound = errors.New("control logic graph not found")
// Store persists control-logic graphs as a single JSON file in the storage dir.
// Writes are atomic (tmp file + rename); all access is mutex-guarded.
type Store struct {
mu sync.RWMutex
path string
items map[string]Graph
}
// NewStore opens (or initialises) the control-logic store under storageDir.
func NewStore(storageDir string) (*Store, error) {
s := &Store{
path: filepath.Join(storageDir, definitionsFile),
items: map[string]Graph{},
}
if err := s.load(); err != nil {
return nil, err
}
return s, nil
}
func (s *Store) load() error {
data, err := os.ReadFile(s.path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
var graphs []Graph
if err := json.Unmarshal(data, &graphs); err != nil {
return fmt.Errorf("parse %s: %w", s.path, err)
}
for _, g := range graphs {
s.items[g.ID] = g
}
return nil
}
// saveLocked writes the current set atomically. Caller must hold s.mu.
func (s *Store) saveLocked() error {
graphs := make([]Graph, 0, len(s.items))
for _, g := range s.items {
graphs = append(graphs, g)
}
data, err := json.MarshalIndent(graphs, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
// List returns all graphs.
func (s *Store) List() []Graph {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Graph, 0, len(s.items))
for _, g := range s.items {
out = append(out, g)
}
return out
}
// Get returns a single graph by id.
func (s *Store) Get(id string) (Graph, error) {
s.mu.RLock()
defer s.mu.RUnlock()
g, ok := s.items[id]
if !ok {
return Graph{}, ErrNotFound
}
return g, nil
}
// Save inserts or replaces a graph and persists the store.
func (s *Store) Save(g Graph) error {
s.mu.Lock()
defer s.mu.Unlock()
s.items[g.ID] = g
return s.saveLocked()
}
// Delete removes a graph by id.
func (s *Store) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.items[id]; !ok {
return ErrNotFound
}
delete(s.items, id)
return s.saveLocked()
}
+50
View File
@@ -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.
+67
View File
@@ -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")
}
}
+104 -15
View File
@@ -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 {
+192 -27
View File
@@ -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))
}
+2
View File
@@ -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.
+48 -5
View File
@@ -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()
+27
View File
@@ -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
View File
@@ -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 ad 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 (18)
}
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.
+417
View File
@@ -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
}
+109
View File
@@ -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)
}
}
+49 -4
View File
@@ -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
View File
@@ -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,
}
}
+343
View File
@@ -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
View File
@@ -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.
+11 -11
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}
+267
View File
@@ -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
View File
@@ -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>
+4
View File
@@ -12,6 +12,8 @@ const TIPS: Record<string, Array<{ text: string; section?: string }>> = {
{ text: 'Right-click a widget to view signal info or export data as CSV.', section: 'view' },
{ text: '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' },
],
};
+826
View File
@@ -0,0 +1,826 @@
import { h, Fragment } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import SearchableSelect from './SearchableSelect';
import LuaEditor from './LuaEditor';
import { checkExpr } from './lib/expr';
// ── Types (mirror internal/controllogic/model.go) ────────────────────────────
type CLNodeKind =
| 'trigger.threshold'
| 'trigger.change'
| 'trigger.timer'
| 'trigger.cron'
| 'trigger.alarm'
| 'gate.and'
| 'flow.if'
| 'flow.loop'
| 'action.write'
| 'action.delay'
| 'action.log'
| 'action.lua';
interface CLNode {
id: string;
kind: CLNodeKind;
x: number;
y: number;
params: Record<string, string>;
}
interface CLWire { from: string; fromPort?: string; to: string; }
interface CLGraph {
id: string;
name: string;
enabled: boolean;
nodes: CLNode[];
wires: CLWire[];
}
interface DataSource { name: string; }
interface SignalInfo { name: string; }
function splitRef(ref: string): { ds: string; name: string } {
const i = ref.indexOf(':');
if (i < 0) return { ds: '', name: '' };
return { ds: ref.slice(0, i), name: ref.slice(i + 1) };
}
// ── Geometry (shared with LogicEditor look/feel) ─────────────────────────────
const REM = (() => {
if (typeof document === 'undefined') return 16;
return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
})();
const NODE_W = 11.5 * REM;
const PORT_TOP = 2 * REM;
const PORT_GAP = 1.375 * REM;
const PORT_R = 0.375 * REM;
interface PaletteEntry { kind: CLNodeKind; label: string; params: Record<string, string>; }
const PALETTE: PaletteEntry[] = [
{ kind: 'trigger.threshold', label: 'Threshold', params: { signal: '', op: '>', value: '0' } },
{ kind: 'trigger.change', label: 'On change', params: { signal: '' } },
{ kind: 'trigger.timer', label: 'Timer', params: { interval: '1000' } },
{ kind: 'trigger.cron', label: 'Cron', params: { spec: '*/5 * * * *' } },
{ kind: 'trigger.alarm', label: 'Alarm', params: { signal: '', min: '0', max: '100' } },
{ kind: 'gate.and', label: 'AND gate', params: {} },
{ kind: 'flow.if', label: 'If / else', params: { cond: '' } },
{ kind: 'flow.loop', label: 'Loop', params: { mode: 'count', count: '3', cond: '' } },
{ kind: 'action.write', label: 'Write', params: { target: '', expr: '' } },
{ kind: 'action.delay', label: 'Delay', params: { ms: '500' } },
{ kind: 'action.log', label: 'Log', params: { expr: '', label: '' } },
{ kind: 'action.lua', label: 'Lua script', params: { script: '-- get("ds:name") reads, set("ds:name", v) writes,\n-- log("msg") logs to the server.\n' } },
];
const KIND_LABEL: Record<CLNodeKind, string> = {
'trigger.threshold': 'Threshold',
'trigger.change': 'On change',
'trigger.timer': 'Timer',
'trigger.cron': 'Cron',
'trigger.alarm': 'Alarm',
'gate.and': 'AND gate',
'flow.if': 'If / else',
'flow.loop': 'Loop',
'action.write': 'Write',
'action.delay': 'Delay',
'action.log': 'Log',
'action.lua': 'Lua script',
};
const THRESHOLD_OPS = ['>', '<', '>=', '<=', '==', '!='] as const;
const CRON_PRESETS: { label: string; spec: string }[] = [
{ label: 'Every minute', spec: '* * * * *' },
{ label: 'Every 5 minutes', spec: '*/5 * * * *' },
{ label: 'Every hour', spec: '0 * * * *' },
{ label: 'Daily at 08:00', spec: '0 8 * * *' },
{ label: 'Weekdays at 09:00', spec: '0 9 * * 1-5' },
];
function isTrigger(kind: CLNodeKind): boolean { return kind.startsWith('trigger.'); }
function category(kind: CLNodeKind): 'trigger' | 'gate' | 'flow' | 'action' {
if (kind.startsWith('trigger.')) return 'trigger';
if (kind.startsWith('gate.')) return 'gate';
if (kind.startsWith('flow.')) return 'flow';
return 'action';
}
function hasInput(kind: CLNodeKind): boolean { return !isTrigger(kind); }
interface Port { id: string; label: string; }
function outputs(kind: CLNodeKind): Port[] {
if (kind === 'flow.if') return [{ id: 'then', label: 'then' }, { id: 'else', label: 'else' }];
if (kind === 'flow.loop') return [{ id: 'body', label: 'body' }, { id: 'done', label: 'done' }];
return [{ id: 'out', label: '' }];
}
function nodeHeight(kind: CLNodeKind): number { return PORT_TOP + outputs(kind).length * PORT_GAP; }
function genId(): string { return 'n_' + Math.random().toString(36).slice(2, 9); }
function inAnchor(n: CLNode) { return { x: n.x, y: n.y + PORT_TOP }; }
function outAnchor(n: CLNode, port: string) {
const ports = outputs(n.kind);
const i = Math.max(0, ports.findIndex(p => p.id === port));
return { x: n.x + NODE_W, y: n.y + PORT_TOP + i * PORT_GAP };
}
function wirePathStr(x1: number, y1: number, x2: number, y2: number): string {
const dx = Math.max(40, Math.abs(x2 - x1) / 2);
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
}
// ── Manager modal ────────────────────────────────────────────────────────────
interface Props { onClose: () => void; }
export default function ControlLogicEditor({ onClose }: Props) {
const [graphs, setGraphs] = useState<CLGraph[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [graph, setGraph] = useState<CLGraph | null>(null);
const [dirty, setDirty] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function reload() {
try {
const res = await fetch('/api/v1/controllogic');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setGraphs(await res.json());
} catch (err) {
setError(`Failed to load control logic: ${err instanceof Error ? err.message : err}`);
}
}
useEffect(() => { reload(); }, []);
function selectGraph(g: CLGraph) {
if (dirty && !confirm('Discard unsaved changes to the current graph?')) return;
setSelectedId(g.id);
setGraph(JSON.parse(JSON.stringify(g)));
setDirty(false);
setError(null);
}
async function createGraph() {
setBusy(true);
setError(null);
try {
const body: Omit<CLGraph, 'id'> = { name: 'New control logic', enabled: false, nodes: [], wires: [] };
const res = await fetch('/api/v1/controllogic', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const created: CLGraph = await res.json();
await reload();
setSelectedId(created.id);
setGraph(created);
setDirty(false);
} catch (err) {
setError(`Create failed: ${err instanceof Error ? err.message : err}`);
} finally {
setBusy(false);
}
}
async function saveGraph() {
if (!graph) return;
setBusy(true);
setError(null);
try {
const res = await fetch(`/api/v1/controllogic/${encodeURIComponent(graph.id)}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(graph),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setDirty(false);
await reload();
} catch (err) {
setError(`Save failed: ${err instanceof Error ? err.message : err}`);
} finally {
setBusy(false);
}
}
async function deleteGraph(id: string) {
if (!confirm('Delete this control logic graph? This stops it on the server.')) return;
setBusy(true);
setError(null);
try {
const res = await fetch(`/api/v1/controllogic/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (!res.ok && res.status !== 204) throw new Error(`HTTP ${res.status}`);
if (selectedId === id) { setSelectedId(null); setGraph(null); setDirty(false); }
await reload();
} catch (err) {
setError(`Delete failed: ${err instanceof Error ? err.message : err}`);
} finally {
setBusy(false);
}
}
// Toggle a graph's enabled flag straight from the list (persists immediately).
async function toggleEnabled(g: CLGraph) {
setBusy(true);
setError(null);
try {
const updated = { ...g, enabled: !g.enabled };
const res = await fetch(`/api/v1/controllogic/${encodeURIComponent(g.id)}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updated),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (selectedId === g.id && graph) setGraph({ ...graph, enabled: updated.enabled });
await reload();
} catch (err) {
setError(`Update failed: ${err instanceof Error ? err.message : err}`);
} finally {
setBusy(false);
}
}
function patchGraph(patch: Partial<CLGraph>) {
if (!graph) return;
setGraph({ ...graph, ...patch });
setDirty(true);
}
return (
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div class="cl-modal">
<header class="cl-header">
<span class="cl-title">Control logic</span>
<span class="hint cl-subtitle">Server-side flows run continuously, independent of any panel.</span>
<div class="cl-header-actions">
{graph && dirty && <span class="cl-dirty">unsaved</span>}
{graph && <button class="panel-btn" disabled={busy || !dirty} onClick={saveGraph}>Save</button>}
<button class="panel-btn" onClick={onClose}>Close</button>
</div>
</header>
{error && <div class="cl-error">{error}</div>}
<div class="cl-body">
<div class="cl-list">
<div class="cl-list-head">
<span>Graphs</span>
<button class="panel-btn" disabled={busy} onClick={createGraph}>+ New</button>
</div>
{graphs.length === 0 && <div class="hint cl-list-empty">No control logic yet.</div>}
{graphs.map(g => (
<div key={g.id}
class={`cl-list-item${selectedId === g.id ? ' cl-list-item-active' : ''}`}
onClick={() => selectGraph(g)}>
<span class={`cl-status-dot${g.enabled ? ' cl-status-on' : ''}`}
title={g.enabled ? 'Enabled' : 'Disabled'} />
<span class="cl-list-name" title={g.name}>{g.name || '(unnamed)'}</span>
<button class="cl-mini-btn" title={g.enabled ? 'Disable' : 'Enable'}
onClick={(e) => { e.stopPropagation(); toggleEnabled(g); }}>
{g.enabled ? '⏸' : '▶'}
</button>
<button class="cl-mini-btn" title="Delete"
onClick={(e) => { e.stopPropagation(); deleteGraph(g.id); }}></button>
</div>
))}
</div>
<div class="cl-editor-wrap">
{!graph && <div class="cl-empty hint">Select a graph on the left, or create a new one.</div>}
{graph && (
<Fragment>
<div class="cl-graph-bar">
<input class="prop-input cl-name-input" value={graph.name}
placeholder="Graph name"
onInput={(e) => patchGraph({ name: (e.target as HTMLInputElement).value })} />
<label class="cl-enable">
<input type="checkbox" checked={graph.enabled}
onChange={(e) => patchGraph({ enabled: (e.target as HTMLInputElement).checked })} />
Enabled
</label>
<span class="hint">Saving a disabled graph stops it; enabling + saving starts it.</span>
</div>
<FlowEditor graph={graph}
onChange={(g) => { setGraph({ ...graph, nodes: g.nodes, wires: g.wires }); setDirty(true); }} />
</Fragment>
)}
</div>
</div>
</div>
</div>
);
}
// ── Node-graph editor ─────────────────────────────────────────────────────────
function FlowEditor({ graph, onChange }: {
graph: CLGraph;
onChange: (g: { nodes: CLNode[]; wires: CLWire[] }) => void;
}) {
const nodes = graph.nodes;
const wires = graph.wires;
const [selectedNode, setSelectedNode] = useState<string | null>(null);
const [selectedWire, setSelectedWire] = useState<number | null>(null);
const [dataSources, setDataSources] = useState<string[]>([]);
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
const canvasRef = useRef<HTMLDivElement>(null);
const dragNode = useRef<{ id: string; dx: number; dy: number } | null>(null);
const [pendingWire, setPendingWire] = useState<{ from: string; port: string; x: number; y: number } | null>(null);
const pendingRef = useRef(pendingWire);
pendingRef.current = pendingWire;
useEffect(() => {
fetch('/api/v1/datasources')
.then(r => r.ok ? r.json() : [])
.then((ds: DataSource[]) => setDataSources(ds.map(d => d.name)))
.catch(() => {});
}, []);
async function loadSignals(ds: string) {
if (!ds || ds === 'local' || ds === 'sys' || dsSignals[ds]) return;
try {
const res = await fetch(`/api/v1/signals?ds=${encodeURIComponent(ds)}`);
if (!res.ok) return;
const sigs: SignalInfo[] = await res.json();
setDsSignals(prev => ({ ...prev, [ds]: sigs.map(s => s.name) }));
} catch {}
}
// Bare-name write targets are graph-local variables; offer them under 'local'.
const localNames = Array.from(new Set(
nodes.filter(n => n.kind === 'action.write')
.map(n => n.params.target ?? '')
.filter(t => t && !t.includes(':'))
));
const dsOptions = ['local', 'sys', ...dataSources];
function signalOptions(ds: string): string[] {
if (ds === 'local') return localNames;
if (ds === 'sys') return ['time', 'dt'];
return dsSignals[ds] ?? [];
}
const selected = nodes.find(n => n.id === selectedNode) ?? null;
useEffect(() => {
if (selected?.kind === 'trigger.threshold' || selected?.kind === 'trigger.change' || selected?.kind === 'trigger.alarm') {
loadSignals(splitRef(selected.params.signal ?? '').ds);
}
if (selected?.kind === 'action.write') loadSignals(splitRef(selected.params.target ?? '').ds);
}, [selectedNode, selected?.kind]);
function emit(next: { nodes: CLNode[]; wires: CLWire[] }) { onChange(next); }
function setNodes(next: CLNode[]) { emit({ nodes: next, wires }); }
function setWires(next: CLWire[]) { emit({ nodes, wires: next }); }
function addNode(entry: PaletteEntry, x?: number, y?: number) {
const node: CLNode = {
id: genId(),
kind: entry.kind,
x: x ?? 40 + (nodes.length % 5) * 30,
y: y ?? 40 + (nodes.length % 5) * 30,
params: { ...entry.params },
};
emit({ nodes: [...nodes, node], wires });
setSelectedNode(node.id);
setSelectedWire(null);
}
function patchParams(id: string, patch: Record<string, string>) {
setNodes(nodes.map(n => (n.id === id ? { ...n, params: { ...n.params, ...patch } } : n)));
}
function moveNode(id: string, x: number, y: number) {
setNodes(nodes.map(n => (n.id === id ? { ...n, x, y } : n)));
}
function deleteNode(id: string) {
emit({ nodes: nodes.filter(n => n.id !== id), wires: wires.filter(w => w.from !== id && w.to !== id) });
if (selectedNode === id) setSelectedNode(null);
}
function addWire(from: string, port: string, to: string) {
if (from === to) return;
if (wires.some(w => w.from === from && (w.fromPort ?? 'out') === port && w.to === to)) return;
setWires([...wires, { from, to, ...(port !== 'out' ? { fromPort: port } : {}) }]);
}
function deleteWire(idx: number) {
setWires(wires.filter((_, i) => i !== idx));
if (selectedWire === idx) setSelectedWire(null);
}
function toCanvas(e: MouseEvent): { x: number; y: number } {
const el = canvasRef.current!;
const rect = el.getBoundingClientRect();
return { x: e.clientX - rect.left + el.scrollLeft, y: e.clientY - rect.top + el.scrollTop };
}
function startNodeDrag(e: MouseEvent, node: CLNode) {
e.stopPropagation();
const p = toCanvas(e);
dragNode.current = { id: node.id, dx: p.x - node.x, dy: p.y - node.y };
setSelectedNode(node.id);
setSelectedWire(null);
window.addEventListener('mousemove', onNodeDragMove);
window.addEventListener('mouseup', onNodeDragUp);
}
function onNodeDragMove(e: MouseEvent) {
const d = dragNode.current;
if (!d) return;
const p = toCanvas(e);
moveNode(d.id, Math.max(0, p.x - d.dx), Math.max(0, p.y - d.dy));
}
function onNodeDragUp() {
dragNode.current = null;
window.removeEventListener('mousemove', onNodeDragMove);
window.removeEventListener('mouseup', onNodeDragUp);
}
function startWire(e: MouseEvent, node: CLNode, port: string) {
e.stopPropagation();
const p = toCanvas(e);
setPendingWire({ from: node.id, port, x: p.x, y: p.y });
window.addEventListener('mousemove', onWireMove);
window.addEventListener('mouseup', onWireUp);
}
function onWireMove(e: MouseEvent) {
const cur = pendingRef.current;
if (!cur) return;
const p = toCanvas(e);
setPendingWire({ ...cur, x: p.x, y: p.y });
}
function endWire() {
pendingRef.current = null;
window.removeEventListener('mousemove', onWireMove);
window.removeEventListener('mouseup', onWireUp);
setPendingWire(null);
}
function onWireUp() { endWire(); }
function finishWire(target: CLNode) {
const cur = pendingRef.current;
if (cur && hasInput(target.kind)) addWire(cur.from, cur.port, target.id);
endWire();
}
const DRAG_MIME = 'application/x-uopi-cl-node';
function onCanvasDragOver(e: DragEvent) {
if (Array.from(e.dataTransfer?.types ?? []).includes(DRAG_MIME)) {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
}
}
function onCanvasDrop(e: DragEvent) {
const kind = e.dataTransfer?.getData(DRAG_MIME);
if (!kind) return;
e.preventDefault();
const entry = PALETTE.find(p => p.kind === kind);
if (!entry) return;
const p = toCanvas(e);
addNode(entry, Math.max(0, p.x - NODE_W / 2), Math.max(0, p.y - PORT_TOP / 2));
}
useEffect(() => {
function onKey(e: KeyboardEvent) {
const t = e.target as Element;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return;
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
if (selectedWire !== null) deleteWire(selectedWire);
else if (selectedNode) deleteNode(selectedNode);
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [selectedNode, selectedWire, nodes, wires]);
const byId = new Map(nodes.map(n => [n.id, n]));
function insertRef(id: string, field: string, ds: string, sig: string) {
const cur = (byId.get(id)?.params[field]) ?? '';
patchParams(id, { [field]: `${cur}{${ds}:${sig}}` });
}
return (
<div class="flow-editor">
<div class="flow-palette">
<div class="flow-palette-title">Triggers</div>
{PALETTE.filter(e => category(e.kind) === 'trigger').map(paletteBtn)}
<div class="flow-palette-title">Logic</div>
{PALETTE.filter(e => category(e.kind) === 'gate' || category(e.kind) === 'flow').map(paletteBtn)}
<div class="flow-palette-title">Actions</div>
{PALETTE.filter(e => category(e.kind) === 'action').map(paletteBtn)}
<div class="flow-palette-hint hint">
Triggers start a flow. Drag a node's right port to another node's left port to connect.
In expressions, reference signals as <code>{'{ds:name}'}</code> and graph-local vars by name.
</div>
</div>
<div class="flow-canvas" ref={canvasRef}
onMouseDown={() => { setSelectedNode(null); setSelectedWire(null); }}
onDragOver={onCanvasDragOver}
onDrop={onCanvasDrop}>
<div class="flow-canvas-inner">
<svg class="flow-wires">
{wires.map((w, idx) => {
const a = byId.get(w.from);
const b = byId.get(w.to);
if (!a || !b) return null;
const p1 = outAnchor(a, w.fromPort ?? 'out');
const p2 = inAnchor(b);
return (
<path key={idx}
class={`flow-wire${selectedWire === idx ? ' flow-wire-selected' : ''}`}
d={wirePathStr(p1.x, p1.y, p2.x, p2.y)}
onClick={(e) => { e.stopPropagation(); setSelectedWire(idx); setSelectedNode(null); }} />
);
})}
{pendingWire && (() => {
const a = byId.get(pendingWire.from);
if (!a) return null;
const p1 = outAnchor(a, pendingWire.port);
return <path class="flow-wire flow-wire-pending" d={wirePathStr(p1.x, p1.y, pendingWire.x, pendingWire.y)} />;
})()}
</svg>
{nodes.map(node => (
<div key={node.id}
class={`flow-node flow-node-${category(node.kind)}${selectedNode === node.id ? ' flow-node-selected' : ''}`}
style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px; min-height:${nodeHeight(node.kind)}px;`}
onMouseDown={(e) => startNodeDrag(e, node)}
onMouseUp={() => { if (pendingRef.current) finishWire(node); }}>
<div class="flow-node-header">
<span class="flow-node-title">{KIND_LABEL[node.kind]}</span>
<button class="flow-node-del" title="Delete node"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); deleteNode(node.id); }}></button>
</div>
<div class="flow-node-body hint">{nodeSummary(node)}</div>
{hasInput(node.kind) && (
<div class="flow-port flow-port-in" title="Input"
style={`top:${PORT_TOP - PORT_R}px; left:${-PORT_R}px;`}
onMouseDown={(e) => e.stopPropagation()}
onMouseUp={(e) => { e.stopPropagation(); finishWire(node); }} />
)}
{outputs(node.kind).map((port, i) => (
<Fragment key={port.id}>
{port.label && (
<span class="flow-port-label" style={`top:${PORT_TOP + i * PORT_GAP - 0.5 * REM}px;`}>{port.label}</span>
)}
<div class={`flow-port flow-port-out flow-port-${port.id}`}
style={`top:${PORT_TOP + i * PORT_GAP - PORT_R}px; right:${-PORT_R}px;`}
title={`Output: ${port.id}`}
onMouseDown={(e) => startWire(e, node, port.id)} />
</Fragment>
))}
</div>
))}
</div>
</div>
<div class="flow-inspector">
{!selected && <div class="hint">Select a node to edit it, or add one from the palette.</div>}
{selected && (
<Fragment>
<div class="wizard-section-title">{KIND_LABEL[selected.kind]}</div>
{(selected.kind === 'trigger.threshold' || selected.kind === 'trigger.change' || selected.kind === 'trigger.alarm') && (() => {
const { ds, name } = splitRef(selected.params.signal ?? '');
return (
<Fragment>
<div class="wizard-field">
<label>Data source</label>
<SearchableSelect value={ds} options={dataSources}
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { signal: `${newDs}:` }); }} />
</div>
<div class="wizard-field">
<label>Signal</label>
<SearchableSelect value={name} options={signalOptions(ds)}
onSelect={(sig) => patchParams(selected.id, { signal: `${ds}:${sig}` })}
placeholder={ds ? 'Search signals…' : 'Select data source first'} />
</div>
{selected.kind === 'trigger.threshold' && (
<div class="wizard-field wizard-field-row">
<div class="wizard-field" style="flex:0 0 4.5rem;">
<label>Op</label>
<select class="prop-select" value={selected.params.op ?? '>'}
onChange={(e) => patchParams(selected.id, { op: (e.target as HTMLSelectElement).value })}>
{THRESHOLD_OPS.map(o => <option key={o} value={o}>{o}</option>)}
</select>
</div>
<div class="wizard-field" style="flex:1;">
<label>Value</label>
<input class="prop-input" value={selected.params.value ?? ''}
onInput={(e) => patchParams(selected.id, { value: (e.target as HTMLInputElement).value })} />
</div>
</div>
)}
{selected.kind === 'trigger.alarm' && (
<div class="wizard-field wizard-field-row">
<div class="wizard-field" style="flex:1;">
<label>Min</label>
<input class="prop-input" type="number" value={selected.params.min ?? '0'}
onInput={(e) => patchParams(selected.id, { min: (e.target as HTMLInputElement).value })} />
</div>
<div class="wizard-field" style="flex:1;">
<label>Max</label>
<input class="prop-input" type="number" value={selected.params.max ?? '100'}
onInput={(e) => patchParams(selected.id, { max: (e.target as HTMLInputElement).value })} />
</div>
</div>
)}
{selected.kind === 'trigger.change' && <p class="hint">Fires whenever the signal's value changes.</p>}
{selected.kind === 'trigger.alarm' && <p class="hint">Fires when the signal leaves the [min, max] range (rising edge).</p>}
</Fragment>
);
})()}
{selected.kind === 'trigger.timer' && (
<div class="wizard-field">
<label>Interval (ms)</label>
<input class="prop-input" type="number" value={selected.params.interval ?? '1000'}
onInput={(e) => patchParams(selected.id, { interval: (e.target as HTMLInputElement).value })} />
<p class="hint">Fires repeatedly on the server while the graph is enabled.</p>
</div>
)}
{selected.kind === 'trigger.cron' && (
<Fragment>
<div class="wizard-field">
<label>Schedule (cron)</label>
<input class="prop-input" value={selected.params.spec ?? ''}
placeholder="min hour dom month dow"
onInput={(e) => patchParams(selected.id, { spec: (e.target as HTMLInputElement).value })} />
</div>
<div class="wizard-field">
<label>Presets</label>
<select class="prop-select" value=""
onChange={(e) => { const v = (e.target as HTMLSelectElement).value; if (v) patchParams(selected.id, { spec: v }); }}>
<option value="">choose…</option>
{CRON_PRESETS.map(p => <option key={p.spec} value={p.spec}>{p.label} ({p.spec})</option>)}
</select>
</div>
<p class="hint">5 fields: minute hour day-of-month month day-of-week. Supports <code>*</code>, <code>*/n</code>, ranges <code>a-b</code> and lists <code>a,b</code>.</p>
</Fragment>
)}
{selected.kind === 'gate.and' && (
<p class="hint">Wire two or more triggers into this gate. The flow continues only when all
inputs are satisfied (level triggers like Threshold/Alarm use their current truth).</p>
)}
{selected.kind === 'flow.if' && (
<ExprField label="Condition" value={selected.params.cond ?? ''}
onChange={(v) => patchParams(selected.id, { cond: v })}
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
hint="True → 'then' port, false → 'else'. e.g. {stub:level} > 5 && temp < 100. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
)}
{selected.kind === 'flow.loop' && (
<Fragment>
<div class="wizard-field">
<label>Mode</label>
<select class="prop-select" value={selected.params.mode ?? 'count'}
onChange={(e) => patchParams(selected.id, { mode: (e.target as HTMLSelectElement).value })}>
<option value="count">Repeat N times</option>
<option value="while">While condition</option>
</select>
</div>
{(selected.params.mode ?? 'count') === 'count' ? (
<div class="wizard-field">
<label>Repeat count</label>
<input class="prop-input" type="number" value={selected.params.count ?? '3'}
onInput={(e) => patchParams(selected.id, { count: (e.target as HTMLInputElement).value })} />
</div>
) : (
<ExprField label="While condition" value={selected.params.cond ?? ''}
onChange={(v) => patchParams(selected.id, { cond: v })}
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
hint="Loops the 'body' port while true (capped). e.g. {stub:level} < 90" />
)}
<p class="hint">Runs the <b>body</b> port repeatedly, then continues on <b>done</b>.</p>
</Fragment>
)}
{selected.kind === 'action.write' && (() => {
const { ds, name } = splitRef(selected.params.target ?? '');
return (
<Fragment>
<div class="wizard-field">
<label>Target data source</label>
<SearchableSelect value={ds} options={dsOptions}
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { target: `${newDs}:` }); }} />
</div>
<div class="wizard-field">
<label>Target signal</label>
<SearchableSelect value={name} options={signalOptions(ds)}
onSelect={(sig) => patchParams(selected.id, { target: `${ds}:${sig}` })}
placeholder={ds ? 'Search signals' : 'Select data source first'} />
</div>
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
hint="Write to a data source signal, or a bare name to store a graph-local var. e.g. ({stub:a} - {stub:b}) / {sys:dt}." />
</Fragment>
);
})()}
{selected.kind === 'action.delay' && (
<div class="wizard-field">
<label>Delay (ms)</label>
<input class="prop-input" type="number" value={selected.params.ms ?? '0'}
onInput={(e) => patchParams(selected.id, { ms: (e.target as HTMLInputElement).value })} />
</div>
)}
{selected.kind === 'action.log' && (
<Fragment>
<div class="wizard-field">
<label>Label (optional)</label>
<input class="prop-input" value={selected.params.label ?? ''}
placeholder="prefix for the log line"
onInput={(e) => patchParams(selected.id, { label: (e.target as HTMLInputElement).value })} />
</div>
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
hint="Logs this value to the server log — handy for debugging a flow." />
</Fragment>
)}
{selected.kind === 'action.lua' && (
<div class="wizard-field">
<label>Lua script</label>
<LuaEditor value={selected.params.script ?? ''}
onChange={(v) => patchParams(selected.id, { script: v })} rows={14} />
<p class="hint">
Host functions: <code>get("ds:name")</code> reads a signal/sys/local value,
<code>set("ds:name", v)</code> writes (bare name → graph-local var),
<code>log(msg)</code> logs to the server. The <code>os/io</code> libraries are disabled.
</p>
</div>
)}
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(selected.id)}>Delete node</button>
</Fragment>
)}
</div>
</div>
);
function paletteBtn(entry: PaletteEntry) {
return (
<button key={entry.kind} class={`flow-palette-btn flow-palette-${category(entry.kind)}`}
title={`${entry.kind} — drag onto the canvas or click to add`}
draggable
onDragStart={(e) => {
e.dataTransfer?.setData(DRAG_MIME, entry.kind);
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy';
}}
onClick={() => addNode(entry)}>{entry.label}</button>
);
}
}
function ExprField({ label, value, onChange, onInsert, dsOptions, signalOptions, loadSignals, hint }: {
label: string;
value: string;
onChange: (v: string) => void;
onInsert: (ds: string, sig: string) => void;
dsOptions: string[];
signalOptions: (ds: string) => string[];
loadSignals: (ds: string) => void;
hint: string;
}) {
const [insDs, setInsDs] = useState('');
const err = checkExpr(value);
return (
<div class="wizard-field">
<label>{label}</label>
<input class={`prop-input${err ? ' prop-input-error' : ''}`} value={value}
placeholder="expression"
onInput={(e) => onChange((e.target as HTMLInputElement).value)} />
{err && <p class="wizard-error">{err}</p>}
<div class="flow-expr-insert">
<SearchableSelect value={insDs} options={dsOptions}
onSelect={(ds) => { setInsDs(ds); loadSignals(ds); }} placeholder="ds…" />
<SearchableSelect value="" options={signalOptions(insDs)}
onSelect={(sig) => onInsert(insDs, sig)}
placeholder={insDs ? '+ insert signal' : 'pick ds first'} />
</div>
<p class="hint">{hint}</p>
</div>
);
}
function nodeSummary(n: CLNode): string {
switch (n.kind) {
case 'trigger.threshold': return `${n.params.signal || '?'} ${n.params.op || '>'} ${n.params.value || '0'}`;
case 'trigger.change': return `Δ ${n.params.signal || '?'}`;
case 'trigger.timer': return `every ${n.params.interval || '?'} ms`;
case 'trigger.cron': return n.params.spec || '(no schedule)';
case 'trigger.alarm': return `${n.params.signal || '?'} ∉ [${n.params.min || '?'}, ${n.params.max || '?'}]`;
case 'gate.and': return 'all inputs';
case 'flow.if': return n.params.cond || '(no condition)';
case 'flow.loop': return (n.params.mode ?? 'count') === 'count'
? `repeat ${n.params.count || '0'}×`
: `while ${n.params.cond || '?'}`;
case 'action.write': return `${n.params.target || '?'} = ${n.params.expr || ''}`;
case 'action.delay': return `wait ${n.params.ms || '0'} ms`;
case 'action.log': return `log ${n.params.label ? n.params.label + ': ' : ''}${n.params.expr || ''}`;
case 'action.lua': return 'Lua script';
default: return '';
}
}
+3 -2
View File
@@ -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
View File
@@ -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 && (
+327
View File
@@ -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
View File
@@ -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 &amp; distribute</h3>
<p>When 2+ widgets are selected the alignment toolbar appears. Buttons align edges or centres; with 3+ selected you can distribute spacing evenly.</p>
<h3 class="help-h3">Layout &amp; Logic tabs</h3>
<p>The centre of the editor has two tabs:</p>
<ul class="help-list">
<li><strong>Layout</strong> the drag-and-drop widget canvas described above.</li>
<li><strong>Logic</strong> a visual flow editor that adds interactive behaviour to the
panel (buttons that run actions, thresholds that pop up dialogs, timers, ). See the
<em>Panel Logic</em> section. The tab is hidden if you are not permitted to edit logic.</li>
</ul>
<h3 class="help-h3">Local variables</h3>
<p>
Panels can define their own <strong>local variables</strong> lightweight values that live
only inside the panel (no data source needed). Add them from the signal tree's
<em>Local</em> group (or the Logic palette). They are written by Set-value widgets, buttons
and logic actions, and referenced anywhere a signal is, making them handy for set-points,
toggles and counters used by panel logic.
</p>
<h3 class="help-h3">Saving</h3>
<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 &amp; arranging</h3>
<ul class="help-list">
<li>Hover a pane and use its split buttons (<strong>⬌</strong> vertical / <strong>⬍</strong>
horizontal) to divide it — a new empty plot appears in the freed half.</li>
<li>Drag the divider between two panes to resize them; nesting is unlimited.</li>
<li>Click a pane to select it, then configure its plot in the Properties pane (plot type,
time window, range, legend, per-signal colour).</li>
<li>Drag a signal from the tree onto a pane to add it to that pane's plot.</li>
<li>Use a pane's <strong>✕</strong> to remove it; the layout collapses onto its sibling.</li>
</ul>
<p>In view mode the saved split layout fills the screen with live, streaming plots.</p>
</div>
);
}
function SectionLogic() {
return (
<div>
<p class="help-lead">
The <strong>Logic</strong> tab in the panel editor is a node-graph (Node-RED style) flow
editor. You wire <em>trigger</em> nodes to <em>action</em> nodes to give a panel interactive
behaviour. Logic runs entirely client-side while the panel is open in view mode.
</p>
<h3 class="help-h3">Building a flow</h3>
<ul class="help-list">
<li>Drag blocks from the left palette onto the canvas (or click to add), then drag from a
node's output port to another node's input port to connect them.</li>
<li>Select a node to edit its parameters in the right inspector.</li>
<li>Expression fields accept arithmetic/logic over signals — reference a signal as
<code>{'{ds:name}'}</code> and a panel-local variable by its bare name.</li>
<li>The editor has its own undo/redo and copy/paste (see Keyboard Shortcuts).</li>
</ul>
<h3 class="help-h3">Node types</h3>
<div class="help-widget-table">
{[
{ type: 'Triggers', desc: 'Button press, threshold crossing, value change, timer/interval, panel loop, and On-open / On-close lifecycle.' },
{ type: 'Logic', desc: 'AND gate, If (then/else branches), and Loop (count or while).' },
{ type: 'Actions', desc: 'Write a value to a signal/variable, Delay, Log (debug), and Accumulate / Export-CSV / Clear for in-memory data arrays.' },
{ type: 'Dialogs', desc: 'Info and Error pop-ups, and a Set-point prompt that asks the user for a number and writes it to a target.' },
].map(w => (
<div key={w.type} class="help-widget-row">
<span class="help-widget-name">{w.type}</span>
<span class="help-widget-desc">{w.desc}</span>
</div>
))}
</div>
<h3 class="help-h3">System helpers</h3>
<ul class="help-list">
<li><code>{'{sys:time}'}</code> — current time in epoch seconds.</li>
<li><code>{'{sys:dt}'}</code> — seconds since the firing trigger last fired (useful for rates).</li>
</ul>
</div>
);
}
function SectionControl() {
return (
<div>
<p class="help-lead">
<strong>Control logic</strong> is server-side automation. Unlike panel logic (which only
runs while a panel is open in a browser), control-logic graphs run continuously on the
server, independent of any client. Open them with the <strong>⚙ Control logic</strong>
button in the view-mode toolbar.
</p>
<h3 class="help-h3">What it can do</h3>
<ul class="help-list">
<li>React to <em>cron</em> schedules and signal <em>alarm</em>/threshold conditions.</li>
<li>Run a <strong>Lua</strong> block for custom logic and compute writes back to signals.</li>
<li>Each graph can be enabled/disabled independently; changes reload the engine live.</li>
</ul>
<p class="help-callout">
Editing both panel logic and control logic can be restricted to an allowlist of users or
groups (<code>server.logic_editors</code> in the config). When restricted, users not on the
list keep full access to everything else but cannot add or change logic — the Logic tab and
the ⚙ Control logic button are hidden for them.
</p>
</div>
);
}
function SectionSharing() {
return (
<div>
<p class="help-lead">
uopi has graduated, server-enforced access control. Identity comes from a trusted
reverse-proxy header (with a configurable default for unproxied/LAN use) — there is no login
page in the app itself.
</p>
<h3 class="help-h3">Global access levels</h3>
<ul class="help-list">
<li>Every user is trusted with full <strong>write</strong> access by default.</li>
<li>A config blacklist can downgrade specific users to <strong>read-only</strong> or
<strong>no access</strong>. Read-only users see panels but cannot edit or write signals.</li>
<li>Named <strong>groups</strong> are defined in the config and used by panel sharing.</li>
</ul>
<h3 class="help-h3">Panel ownership &amp; sharing</h3>
<ul class="help-list">
<li>New panels are <strong>private</strong> to their owner by default.</li>
<li>Use the <strong>Share</strong> button on a panel (in the interface list) to grant
read or write to specific users/groups, or make it public.</li>
<li>Your global level always caps the per-panel permission (read-only stays read-only).</li>
</ul>
<h3 class="help-h3">Folders</h3>
<ul class="help-list">
<li>Organise panels into nested <strong>folders</strong> in the interface list.</li>
<li>Permissions inherit down the folder chain.</li>
<li>Drag panels to reorder them or move them between folders.</li>
</ul>
</div>
);
}
function SectionWidgets() {
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 &amp; 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://&lt;host&gt;/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 &amp; 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://&lt;host&gt;/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,
};
+152
View File
@@ -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
View File
@@ -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>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { h, Fragment } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { logicDialogs, type DialogRequest } from './lib/logic';
// Renders the user-interaction dialogs requested by action.dialog.* logic nodes.
// The engine pushes requests to the `logicDialogs` store and awaits the user's
// response; this component shows each as a modal and calls req.resolve.
export default function LogicDialogs() {
const [reqs, setReqs] = useState<DialogRequest[]>([]);
useEffect(() => logicDialogs.subscribe(setReqs), []);
if (reqs.length === 0) return null;
return <Fragment>{reqs.map(r => <DialogModal key={r.id} req={r} />)}</Fragment>;
}
function DialogModal({ req }: { req: DialogRequest; key?: string }) {
const isSetpoint = req.kind === 'setpoint';
const inputIdx = req.fields
.map((f, i) => (f.type === 'input' ? i : -1))
.filter(i => i >= 0);
// Per-input editable values, keyed by field index, seeded from the prefill.
const [vals, setVals] = useState<Record<number, string>>(
() => Object.fromEntries(inputIdx.map(i => [i, req.fields[i].value]))
);
function ok() {
req.resolve(inputIdx.map(i => vals[i] ?? ''));
}
function cancel() { req.resolve(null); }
function onKey(e: KeyboardEvent) {
if (e.key === 'Enter' && !(e.target instanceof HTMLTextAreaElement)) { e.preventDefault(); ok(); }
if (e.key === 'Escape') { e.preventDefault(); cancel(); }
}
return (
<div class="logic-dialog-backdrop" onKeyDown={onKey}>
<div class={`logic-dialog logic-dialog-${req.kind}`} role="dialog">
<div class="logic-dialog-header">{req.title || (req.kind === 'error' ? 'Error' : isSetpoint ? 'Set value' : 'Info')}</div>
{req.message && <div class="logic-dialog-message">{req.message}</div>}
{req.fields.map((f, i) => (
<div class="logic-dialog-field" key={i}>
{f.label && <label class="logic-dialog-label">{f.label}</label>}
{f.type === 'input' ? (
<input class="prop-input logic-dialog-input" type="number"
autoFocus={i === inputIdx[0]}
value={vals[i] ?? ''}
onInput={(e) => setVals(v => ({ ...v, [i]: (e.target as HTMLInputElement).value }))}
onKeyDown={onKey} />
) : (
<span class="logic-dialog-value">{f.value}</span>
)}
</div>
))}
<div class="logic-dialog-actions">
{isSetpoint && <button class="panel-btn" onClick={cancel}>Cancel</button>}
<button class="panel-btn panel-btn-primary" onClick={ok}>OK</button>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+137
View File
@@ -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>
);
}
+107
View File
@@ -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
View File
@@ -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)}>
+58
View File
@@ -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>
);
}
+181
View File
@@ -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
View File
@@ -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>
);
}
+64
View File
@@ -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>;
}
+582
View File
@@ -0,0 +1,582 @@
import { h, Fragment } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import SearchableSelect from './SearchableSelect';
import LuaEditor from './LuaEditor';
import type { InputRef, PipelineNode, SignalDef } from './lib/types';
interface DataSource { name: string; }
interface SignalInfo { name: string; }
interface Props {
name: string;
onClose: () => void;
onSaved: () => void;
}
// ── Node-type catalogue ──────────────────────────────────────────────────────
// Mirrors the backend dsp node set (internal/dsp/nodes.go). The combine nodes
// (add/subtract/…) and expr take several inputs, which only the FIRST node of a
// pipeline receives — see the compile() note below.
interface NodeParam { label: string; key: string; type: 'number' | 'text' | 'lua'; default: string; }
interface OpDef { type: string; label: string; params: NodeParam[]; }
const OPS: OpDef[] = [
{ type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] },
{ type: 'offset', label: 'Offset', params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] },
{ type: 'add', label: 'Add (Σ inputs)', params: [] },
{ type: 'subtract', label: 'Subtract (ab)', params: [] },
{ type: 'multiply', label: 'Multiply (Π)', params: [] },
{ type: 'divide', label: 'Divide (a÷b)', params: [] },
{ type: 'moving_average', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'lowpass', label: 'Low-pass', params: [
{ label: 'Cutoff freq (Hz)', key: 'freq', type: 'number', default: '1' },
{ label: 'Order (18)', key: 'order', type: 'number', default: '1' },
]},
{ type: 'derivative', label: 'Derivative', params: [] },
{ type: 'integrate', label: 'Integrate', params: [] },
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
{ type: 'threshold', label: 'Threshold', params: [
{ label: 'Threshold', key: 'threshold', type: 'number', default: '0' },
{ label: 'High output', key: 'high', type: 'number', default: '1' },
{ label: 'Low output', key: 'low', type: 'number', default: '0' },
]},
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a,b,c,d = inputs)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a,b,c,d = inputs)', key: 'script', type: 'lua', default: 'return a' }] },
];
const OP_BY_TYPE = new Map(OPS.map(o => [o.type, o]));
function opLabel(type: string): string { return OP_BY_TYPE.get(type)?.label ?? type; }
function opParamDefs(type: string): NodeParam[] { return OP_BY_TYPE.get(type)?.params ?? []; }
// ── Graph model (UI only — compiled to SignalDef on save) ───────────────────
type NodeKind = 'source' | 'op' | 'output';
interface GNode {
id: string;
kind: NodeKind;
x: number;
y: number;
ds?: string; // source
signal?: string; // source
op?: string; // op type
params?: Record<string, any>; // op
}
interface GWire { from: string; to: string; }
interface Graph { nodes: GNode[]; wires: GWire[]; }
// ── Geometry (rem-based, like the logic editor) ─────────────────────────────
const REM = (() => {
if (typeof document === 'undefined') return 16;
return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
})();
const NODE_W = 10 * REM;
const PORT_TOP = 1.4 * REM;
const PORT_R = 0.375 * REM;
function genId(): string { return 'g_' + Math.random().toString(36).slice(2, 9); }
function hasInput(k: NodeKind): boolean { return k !== 'source'; }
function hasOutput(k: NodeKind): boolean { return k !== 'output'; }
function inAnchor(n: GNode) { return { x: n.x, y: n.y + PORT_TOP }; }
function outAnchor(n: GNode) { return { x: n.x + NODE_W, y: n.y + PORT_TOP }; }
function wirePathStr(x1: number, y1: number, x2: number, y2: number): string {
const dx = Math.max(40, Math.abs(x2 - x1) / 2);
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
}
// Build an initial graph by laying out an existing SignalDef: sources stacked on
// the left, the linear pipeline as a row of op nodes, output on the right.
function buildInitial(def: SignalDef): Graph {
const inputs: InputRef[] = def.inputs && def.inputs.length > 0
? def.inputs
: (def.ds && def.signal ? [{ ds: def.ds, signal: def.signal }] : []);
const pipeline = def.pipeline ?? [];
const nodes: GNode[] = [];
const wires: GWire[] = [];
const srcIds = inputs.map((inp, i) => {
const id = genId();
nodes.push({ id, kind: 'source', x: 2 * REM, y: (2 + i * 5) * REM, ds: inp.ds, signal: inp.signal });
return id;
});
const opIds = pipeline.map((nd, i) => {
const id = genId();
nodes.push({ id, kind: 'op', x: (15 + i * 12) * REM, y: 3 * REM, op: nd.type, params: { ...nd.params } });
return id;
});
const outId = genId();
nodes.push({ id: outId, kind: 'output', x: (15 + pipeline.length * 12) * REM, y: 3 * REM });
const headId = opIds[0] ?? outId;
srcIds.forEach(s => wires.push({ from: s, to: headId }));
for (let i = 0; i < opIds.length - 1; i++) wires.push({ from: opIds[i], to: opIds[i + 1] });
if (opIds.length > 0) wires.push({ from: opIds[opIds.length - 1], to: outId });
return { nodes, wires };
}
// Compile the visual graph back into the backend's linear form. The pipeline is
// a single chain ending at the output; the head node receives every source
// signal (inputs[0]=a, inputs[1]=b, …), every later node the previous output.
function compile(g: Graph): { inputs?: InputRef[]; pipeline?: PipelineNode[]; error?: string } {
const output = g.nodes.find(n => n.kind === 'output');
if (!output) return { error: 'missing output node' };
const byId = new Map(g.nodes.map(n => [n.id, n]));
const upstream = (id: string) => g.wires.filter(w => w.to === id).map(w => byId.get(w.from)).filter(Boolean) as GNode[];
const chain: GNode[] = [];
const seen = new Set<string>();
let cur: GNode = output;
for (;;) {
if (seen.has(cur.id)) return { error: 'the graph contains a cycle' };
seen.add(cur.id);
const ups = upstream(cur.id);
const opUps = ups.filter(n => n.kind === 'op');
const srcUps = ups.filter(n => n.kind === 'source');
if (opUps.length > 1) return { error: 'a node may have only one upstream node — the pipeline must be a single chain' };
if (opUps.length === 1) {
if (srcUps.length > 0) return { error: 'only the first processing node may take input signals directly' };
chain.unshift(opUps[0]);
cur = opUps[0];
continue;
}
// Reached the head of the chain: its source upstreams become the inputs.
if (srcUps.length === 0) {
return { error: cur.kind === 'output' ? 'connect at least one signal to the output' : 'the first node has no input signals' };
}
const inputs = srcUps
.slice()
.sort((a, b) => a.y - b.y)
.map(n => ({ ds: n.ds || '', signal: n.signal || '' }));
if (inputs.some(i => !i.ds || !i.signal)) return { error: 'every input signal needs a data source and a signal' };
const pipeline = chain.map(n => ({ type: n.op!, params: n.params ?? {} }));
return { inputs, pipeline };
}
}
function nodeSummary(n: GNode): string {
if (n.kind === 'source') return n.ds && n.signal ? `${n.ds}:${n.signal}` : '(pick a signal)';
if (n.kind === 'output') return 'synthetic result';
const p = n.params ?? {};
switch (n.op) {
case 'gain': return `× ${p.gain ?? 1}`;
case 'offset': return `+ ${p.offset ?? 0}`;
case 'moving_average': return `avg ${p.window ?? 10}`;
case 'rms': return `rms ${p.window ?? 10}`;
case 'lowpass': return `${p.freq ?? 1} Hz`;
case 'clamp': return `[${p.min ?? 0}, ${p.max ?? 100}]`;
case 'threshold': return `> ${p.threshold ?? 0}`;
case 'expr': return String(p.expr ?? '');
case 'lua': return 'lua';
default: return opLabel(n.op ?? '');
}
}
export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props) {
const [def, setDef] = useState<SignalDef | null>(null);
const [graph, setGraph] = useState<Graph>({ nodes: [], wires: [] });
const [selected, setSelected] = useState<string | null>(null);
const [selectedWire, setSelectedWire] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [dataSources, setDataSources] = useState<string[]>([]);
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
const canvasRef = useRef<HTMLDivElement>(null);
const dragNode = useRef<{ id: string; dx: number; dy: number; pushed: boolean } | null>(null);
const [pendingWire, setPendingWire] = useState<{ from: string; x: number; y: number } | null>(null);
const pendingRef = useRef(pendingWire);
pendingRef.current = pendingWire;
const graphRef = useRef(graph);
graphRef.current = graph;
const undoStack = useRef<Graph[]>([]);
const redoStack = useRef<Graph[]>([]);
const [, setTick] = useState(0);
const bump = () => setTick(t => t + 1);
const canUndo = undoStack.current.length > 0;
const canRedo = redoStack.current.length > 0;
useEffect(() => {
fetch('/api/v1/datasources')
.then(r => r.ok ? r.json() : [])
.then((ds: DataSource[]) => setDataSources(ds.map(d => d.name)))
.catch(() => {});
}, []);
async function loadSignals(ds: string) {
if (!ds || dsSignals[ds]) return;
try {
const res = await fetch(`/api/v1/signals?ds=${encodeURIComponent(ds)}`);
if (!res.ok) return;
const sigs: SignalInfo[] = await res.json();
setDsSignals(prev => ({ ...prev, [ds]: sigs.map(s => s.name) }));
} catch {}
}
useEffect(() => {
fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`)
.then(r => r.ok ? r.json() : Promise.reject(r.statusText))
.then((d: SignalDef) => {
setDef(d);
const g = buildInitial(d);
setGraph(g);
g.nodes.forEach(n => { if (n.kind === 'source' && n.ds) loadSignals(n.ds); });
})
.catch(e => setError(String(e)))
.finally(() => setLoading(false));
}, [name]);
// ── History ────────────────────────────────────────────────────────────────
function pushUndo() {
undoStack.current = [...undoStack.current.slice(-49), graphRef.current];
redoStack.current = [];
}
function commit(next: Graph, record = true) {
if (record) pushUndo();
setGraph(next);
}
function undo() {
if (undoStack.current.length === 0) return;
const prev = undoStack.current[undoStack.current.length - 1];
redoStack.current = [graphRef.current, ...redoStack.current];
undoStack.current = undoStack.current.slice(0, -1);
setSelected(null); setSelectedWire(null);
setGraph(prev); bump();
}
function redo() {
if (redoStack.current.length === 0) return;
const next = redoStack.current[0];
undoStack.current = [...undoStack.current, graphRef.current];
redoStack.current = redoStack.current.slice(1);
setSelected(null); setSelectedWire(null);
setGraph(next); bump();
}
// ── Graph mutators ───────────────────────────────────────────────────────
function addSource() {
const node: GNode = { id: genId(), kind: 'source', x: 2 * REM, y: (2 + graph.nodes.filter(n => n.kind === 'source').length * 5) * REM, ds: dataSources[0] || '', signal: '' };
if (node.ds) loadSignals(node.ds);
commit({ nodes: [...graph.nodes, node], wires: graph.wires });
setSelected(node.id); setSelectedWire(null);
}
function addOp(op: OpDef, x?: number, y?: number) {
const params: Record<string, any> = {};
for (const p of op.params) params[p.key] = p.type === 'number' ? parseFloat(p.default) : p.default;
const node: GNode = { id: genId(), kind: 'op', op: op.type, params, x: x ?? (14 + (graph.nodes.length % 4) * 3) * REM, y: y ?? (3 + (graph.nodes.length % 4) * 2) * REM };
commit({ nodes: [...graph.nodes, node], wires: graph.wires });
setSelected(node.id); setSelectedWire(null);
}
function patchNode(id: string, patch: Partial<GNode>) {
commit({ nodes: graph.nodes.map(n => (n.id === id ? { ...n, ...patch } : n)), wires: graph.wires });
}
function patchParam(id: string, key: string, val: string) {
commit({
nodes: graph.nodes.map(n => {
if (n.id !== id) return n;
const pd = opParamDefs(n.op ?? '').find(p => p.key === key);
const typed: any = pd?.type === 'number' ? parseFloat(val) : val;
return { ...n, params: { ...n.params, [key]: typed } };
}),
wires: graph.wires,
});
}
function moveNode(id: string, x: number, y: number, record: boolean) {
commit({ nodes: graph.nodes.map(n => (n.id === id ? { ...n, x, y } : n)), wires: graph.wires }, record);
}
function deleteNode(id: string) {
const n = graph.nodes.find(x => x.id === id);
if (!n || n.kind === 'output') return; // the output node is permanent
commit({ nodes: graph.nodes.filter(x => x.id !== id), wires: graph.wires.filter(w => w.from !== id && w.to !== id) });
if (selected === id) setSelected(null);
}
function addWire(from: string, to: string) {
if (from === to) return;
const fn = graph.nodes.find(n => n.id === from);
const tn = graph.nodes.find(n => n.id === to);
if (!fn || !tn || !hasOutput(fn.kind) || !hasInput(tn.kind)) return;
if (graph.wires.some(w => w.from === from && w.to === to)) return;
commit({ nodes: graph.nodes, wires: [...graph.wires, { from, to }] });
}
function deleteWire(idx: number) {
commit({ nodes: graph.nodes, wires: graph.wires.filter((_, i) => i !== idx) });
if (selectedWire === idx) setSelectedWire(null);
}
// ── Pointer / drag / wire ──────────────────────────────────────────────────
function toCanvas(e: MouseEvent) {
const el = canvasRef.current!;
const rect = el.getBoundingClientRect();
return { x: e.clientX - rect.left + el.scrollLeft, y: e.clientY - rect.top + el.scrollTop };
}
function startNodeDrag(e: MouseEvent, node: GNode) {
e.stopPropagation();
const p = toCanvas(e);
dragNode.current = { id: node.id, dx: p.x - node.x, dy: p.y - node.y, pushed: false };
setSelected(node.id); setSelectedWire(null);
window.addEventListener('mousemove', onNodeDragMove);
window.addEventListener('mouseup', onNodeDragUp);
}
function onNodeDragMove(e: MouseEvent) {
const d = dragNode.current;
if (!d) return;
const p = toCanvas(e);
const record = !d.pushed;
d.pushed = true;
moveNode(d.id, Math.max(0, p.x - d.dx), Math.max(0, p.y - d.dy), record);
}
function onNodeDragUp() {
dragNode.current = null;
window.removeEventListener('mousemove', onNodeDragMove);
window.removeEventListener('mouseup', onNodeDragUp);
}
function startWire(e: MouseEvent, node: GNode) {
e.stopPropagation();
const p = toCanvas(e);
setPendingWire({ from: node.id, x: p.x, y: p.y });
window.addEventListener('mousemove', onWireMove);
window.addEventListener('mouseup', onWireUp);
}
function onWireMove(e: MouseEvent) {
const cur = pendingRef.current;
if (!cur) return;
const p = toCanvas(e);
setPendingWire({ ...cur, x: p.x, y: p.y });
}
function endWire() {
pendingRef.current = null;
window.removeEventListener('mousemove', onWireMove);
window.removeEventListener('mouseup', onWireUp);
setPendingWire(null);
}
function onWireUp() { endWire(); }
function finishWire(target: GNode) {
const cur = pendingRef.current;
if (cur && hasInput(target.kind)) addWire(cur.from, target.id);
endWire();
}
// ── Palette drag-and-drop ──────────────────────────────────────────────────
const DRAG_MIME = 'application/x-uopi-synth-op';
function onCanvasDragOver(e: DragEvent) {
if (Array.from(e.dataTransfer?.types ?? []).includes(DRAG_MIME)) {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
}
}
function onCanvasDrop(e: DragEvent) {
const type = e.dataTransfer?.getData(DRAG_MIME);
if (!type) return;
e.preventDefault();
const op = OP_BY_TYPE.get(type);
if (!op) return;
const p = toCanvas(e);
addOp(op, Math.max(0, p.x - NODE_W / 2), Math.max(0, p.y - PORT_TOP));
}
// ── Keyboard ────────────────────────────────────────────────────────────────
useEffect(() => {
function onKey(e: KeyboardEvent) {
const t = e.target as Element;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return;
const mod = e.ctrlKey || e.metaKey;
if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; }
if (mod && (e.key.toLowerCase() === 'y' || (e.key.toLowerCase() === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; }
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
if (selectedWire !== null) deleteWire(selectedWire);
else if (selected) deleteNode(selected);
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [selected, selectedWire, graph]);
const byId = new Map(graph.nodes.map(n => [n.id, n]));
const sel = graph.nodes.find(n => n.id === selected) ?? null;
const compiled = compile(graph);
async function handleSave() {
if (!def) return;
if (compiled.error) { setError(compiled.error); return; }
setSaving(true);
setError('');
try {
const body = { ...def, inputs: compiled.inputs, pipeline: compiled.pipeline, ds: undefined, signal: undefined };
const res = await fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const j = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(j.error ?? res.statusText);
}
onSaved();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}
function nodeClass(n: GNode): string {
const cat = n.kind === 'source' ? 'trigger' : n.kind === 'output' ? 'action' : 'flow';
return `flow-node flow-node-${cat}${selected === n.id ? ' flow-node-selected' : ''}`;
}
return (
<div class="wizard-backdrop" onClick={onClose}>
<div class="synth-graph" onClick={(e) => e.stopPropagation()}>
<div class="wizard-header">
<span>Synthetic Signal {name}</span>
<button class="icon-btn" onClick={onClose}></button>
</div>
{loading ? (
<div class="wizard-body"><p class="hint">Loading</p></div>
) : (
<div class="flow-editor">
<div class="flow-palette">
<div class="flow-palette-toolbar">
<button class="toolbar-btn" disabled={!canUndo} onClick={undo} title="Undo (Ctrl+Z)"></button>
<button class="toolbar-btn" disabled={!canRedo} onClick={redo} title="Redo (Ctrl+Shift+Z)"></button>
</div>
<div class="flow-palette-title">Inputs</div>
<button class="flow-palette-btn flow-palette-trigger" onClick={addSource}>+ Signal</button>
<div class="flow-palette-title">Operations</div>
{OPS.map(op => (
<button key={op.type} class="flow-palette-btn flow-palette-flow"
title={`${op.type} — drag onto the canvas or click to add`}
draggable
onDragStart={(e) => { e.dataTransfer?.setData(DRAG_MIME, op.type); if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy'; }}
onClick={() => addOp(op)}>{op.label}</button>
))}
<div class="flow-palette-hint hint">
Wire input signals into the first operation, chain operations, and connect the
last one to <b>Output</b>. The first node receives every input as <code>a,b,c,d</code>.
</div>
</div>
<div class="flow-canvas" ref={canvasRef}
onMouseDown={() => { setSelected(null); setSelectedWire(null); }}
onDragOver={onCanvasDragOver}
onDrop={onCanvasDrop}>
<div class="flow-canvas-inner">
<svg class="flow-wires">
{graph.wires.map((w, idx) => {
const a = byId.get(w.from); const b = byId.get(w.to);
if (!a || !b) return null;
const p1 = outAnchor(a); const p2 = inAnchor(b);
return (
<path key={idx}
class={`flow-wire${selectedWire === idx ? ' flow-wire-selected' : ''}`}
d={wirePathStr(p1.x, p1.y, p2.x, p2.y)}
onClick={(e) => { e.stopPropagation(); setSelectedWire(idx); setSelected(null); }} />
);
})}
{pendingWire && (() => {
const a = byId.get(pendingWire.from);
if (!a) return null;
const p1 = outAnchor(a);
return <path class="flow-wire flow-wire-pending" d={wirePathStr(p1.x, p1.y, pendingWire.x, pendingWire.y)} />;
})()}
</svg>
{graph.nodes.map(node => (
<div key={node.id}
class={nodeClass(node)}
style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px;`}
onMouseDown={(e) => startNodeDrag(e, node)}
onMouseUp={() => { if (pendingRef.current) finishWire(node); }}>
<div class="flow-node-header">
<span class="flow-node-title">{node.kind === 'source' ? 'Signal' : node.kind === 'output' ? 'Output' : opLabel(node.op ?? '')}</span>
{node.kind !== 'output' && (
<button class="flow-node-del" title="Delete node"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); deleteNode(node.id); }}></button>
)}
</div>
<div class="flow-node-body hint">{nodeSummary(node)}</div>
{hasInput(node.kind) && (
<div class="flow-port flow-port-in" title="Input"
style={`top:${PORT_TOP - PORT_R}px; left:${-PORT_R}px;`}
onMouseDown={(e) => e.stopPropagation()}
onMouseUp={(e) => { e.stopPropagation(); finishWire(node); }} />
)}
{hasOutput(node.kind) && (
<div class="flow-port flow-port-out" title="Output"
style={`top:${PORT_TOP - PORT_R}px; right:${-PORT_R}px;`}
onMouseDown={(e) => startWire(e, node)} />
)}
</div>
))}
</div>
</div>
<div class="flow-inspector">
{!sel && <div class="hint">Select a node to edit it, or add one from the palette.</div>}
{sel?.kind === 'source' && (
<Fragment>
<div class="wizard-section-title">Input signal</div>
<div class="wizard-field">
<label>Data source</label>
<SearchableSelect value={sel.ds ?? ''} options={dataSources}
onSelect={(ds) => { loadSignals(ds); patchNode(sel.id, { ds, signal: '' }); }} />
</div>
<div class="wizard-field">
<label>Signal</label>
<SearchableSelect value={sel.signal ?? ''} options={dsSignals[sel.ds ?? ''] ?? []}
onSelect={(signal) => patchNode(sel.id, { signal })}
placeholder={sel.ds ? 'Search signals…' : 'Select data source first'} />
</div>
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(sel.id)}>Delete node</button>
</Fragment>
)}
{sel?.kind === 'op' && (
<Fragment>
<div class="wizard-section-title">{opLabel(sel.op ?? '')}</div>
{opParamDefs(sel.op ?? '').length === 0 && (
<p class="hint">No parameters this operation transforms its input directly.</p>
)}
{opParamDefs(sel.op ?? '').map(pd => (
<div key={pd.key} class="wizard-field">
<label>{pd.label}</label>
{pd.type === 'lua' ? (
<LuaEditor value={String(sel.params?.[pd.key] ?? pd.default)}
onChange={(v) => patchParam(sel.id, pd.key, v)} />
) : (
<input class="prop-input" type={pd.type === 'number' ? 'number' : 'text'}
value={sel.params?.[pd.key] ?? pd.default}
onInput={(e) => patchParam(sel.id, pd.key, (e.target as HTMLInputElement).value)} />
)}
</div>
))}
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(sel.id)}>Delete node</button>
</Fragment>
)}
{sel?.kind === 'output' && (
<Fragment>
<div class="wizard-section-title">Output</div>
<p class="hint">The value wired into this node becomes the signal <b>{name}</b>.</p>
</Fragment>
)}
</div>
</div>
)}
<div class="wizard-footer">
{error && <span class="wizard-error" style="flex:1;">{error}</span>}
{!error && compiled.error && <span class="hint" style="flex:1;">{compiled.error}</span>}
<button class="toolbar-btn" onClick={onClose}>Cancel</button>
<button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving || loading || !!compiled.error}>
{saving ? 'Saving…' : 'Save Signal'}
</button>
</div>
</div>
</div>
);
}
+134 -37
View File
@@ -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 (18)', key: 'order', type: 'number', default: '1' },
]},
{ type: 'derivative', label: 'Derivative', params: [] },
{ type: 'integrate', label: 'Integrate', params: [] },
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a,b,c,d = inputs)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a,b,c,d = inputs)', key: 'script', type: 'lua', default: 'return a' }] },
];
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
View File
@@ -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>
);
}
+35
View File
@@ -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>
);
}
+44
View File
@@ -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;
}
}
+268
View File
@@ -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); }
}
+89
View File
@@ -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 };
}
+44
View File
@@ -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+$/, '');
}
+82
View File
@@ -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() });
}
+637
View File
@@ -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();
+72
View File
@@ -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) };
}
+6
View File
@@ -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;
+31
View File
@@ -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' },
},
],
};
}
+239
View File
@@ -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-REDstyle 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'
}
+47
View File
@@ -0,0 +1,47 @@
// Per-widget command bus.
//
// Panel logic (logic.ts) runs in view mode and can drive individual widgets via
// the `action.widget` node: enable/disable, show/hide, and — for plot widgets —
// clear or pause/resume the live stream. Each widget id gets a small reactive
// store; widget components subscribe to their own store (Canvas wraps every
// widget in a <WidgetView> that honours disabled/hidden, and PlotWidget reacts
// to paused/clear). The engine writes commands through the setters below.
import { writable, type Writable } from './store';
export interface WidgetCmd {
disabled: boolean; // interaction blocked + dimmed
hidden: boolean; // removed from the view
paused: boolean; // plot: stop ingesting live samples
clearNonce: number; // plot: bumped to request a one-shot buffer clear
}
const DEFAULT: WidgetCmd = { disabled: false, hidden: false, paused: false, clearNonce: 0 };
const stores = new Map<string, Writable<WidgetCmd>>();
export function getWidgetCmdStore(id: string): Writable<WidgetCmd> {
let s = stores.get(id);
if (!s) { s = writable({ ...DEFAULT }); stores.set(id, s); }
return s;
}
export function setWidgetDisabled(id: string, v: boolean): void {
getWidgetCmdStore(id).update(c => ({ ...c, disabled: v }));
}
export function setWidgetHidden(id: string, v: boolean): void {
getWidgetCmdStore(id).update(c => ({ ...c, hidden: v }));
}
export function setPlotPaused(id: string, v: boolean): void {
getWidgetCmdStore(id).update(c => ({ ...c, paused: v }));
}
export function clearPlot(id: string): void {
getWidgetCmdStore(id).update(c => ({ ...c, clearNonce: c.clearNonce + 1 }));
}
// Reset every widget back to its default state. Called when a panel unmounts so
// that re-opening the same panel (whose widget ids are stable) starts fresh
// rather than inheriting disabled/hidden/paused flags from the previous view.
export function resetWidgetCmds(): void {
for (const s of stores.values()) s.set({ ...DEFAULT });
}
+6
View File
@@ -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
View File
@@ -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, '&gt;');
}
/** 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
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -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 {
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}
+78 -21
View File
@@ -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>
);
}
+3 -3
View File
@@ -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);
}
+10
View File
@@ -0,0 +1,10 @@
{
"panels": {},
"folders": {
"fld-68b0bb9c23c9fd3b": {
"id": "fld-68b0bb9c23c9fd3b",
"name": "Test",
"owner": ""
}
}
}
+1 -1
View File
@@ -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."
+19
View File
@@ -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 010 bar, ~20 s period
# Tests: gauge, barh, different EGU
record(calc, "UOPI:PRESSURE") {