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") {