Improved UI
This commit is contained in:
+158
-90
@@ -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
|
||||
@@ -187,8 +200,8 @@ Base path: `/api/v1`
|
||||
|
||||
### 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,9 +211,24 @@ Base path: `/api/v1`
|
||||
|
||||
- Each synthetic signal is defined as a directed acyclic graph (DAG) of processing nodes.
|
||||
- Processing nodes are re-evaluated whenever any upstream signal emits a new value.
|
||||
- Built-in node types implemented on top of `gonum/dsp` and custom code.
|
||||
- Lua nodes receive a sandboxed `lua.LState` with access to input values and a persistent state table.
|
||||
- Synthetic signal definitions are stored as part of the server configuration (JSON/TOML file), distinct from interface XML files.
|
||||
- Definitions are stored in a configurable JSON/TOML file alongside server configuration.
|
||||
- The `dsp_bridge.go` file maps node type names to `dsp.Node` implementations.
|
||||
|
||||
**Built-in node types:**
|
||||
|
||||
| Node type | Parameters | Description |
|
||||
| ------------ | ----------------------------------- | ------------------------------------------------ |
|
||||
| `source` | `ds`, `name` | Reads a signal from any data source |
|
||||
| `gain` | `factor` | Multiplies by a constant |
|
||||
| `offset` | `value` | Adds a constant |
|
||||
| `moving_avg` | `window` (samples) | Rolling mean |
|
||||
| `lowpass` | `freq` (Hz), `order` (1–8) | Cascaded IIR Butterworth-style low-pass filter |
|
||||
| `formula` | `expr` | Inline math expression (variables: `a`, `b`, …) |
|
||||
| `lua` | `script` | Arbitrary Lua 5.1 code with persistent state |
|
||||
|
||||
**Low-pass filter implementation:** Cascaded first-order IIR sections. Each stage computes `y = y_prev + α·(x − y_prev)` where `α = dt / (RC + dt)` and `RC = 1/(2π·fc)`. `dt` is computed per sample from source timestamps so the filter is correct for event-driven (non-uniform) data.
|
||||
|
||||
**Lua node:** Receives `inputs` table (indexed by signal name) and a persistent `state` table across calls. The `os`, `io`, `package`, and `debug` libraries are disabled.
|
||||
|
||||
### 3.7 Interface Storage
|
||||
|
||||
@@ -215,6 +243,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"/>
|
||||
@@ -230,47 +259,79 @@ Interfaces are stored as XML files in a configurable directory on the server.
|
||||
|
||||
## 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 Panel (`PlotPanel.tsx`)
|
||||
|
||||
### 4.5 DPI Adaptation
|
||||
A live multi-signal plot panel accessible from the "Plot" tab in View mode:
|
||||
|
||||
- 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`.
|
||||
- Signals are added by right-clicking any widget and choosing "Plot".
|
||||
- Supports configurable time window (10s to 1h).
|
||||
- Per-signal style editor: color picker, line width (0 = hidden), line dash (solid/dashed/dotted), marker size (none/S/M/L).
|
||||
- Statistics table per signal: last, min, max, mean over the current time window.
|
||||
- Uses a `requestAnimationFrame` loop limited to ≤1 redraw/second when data is not changing.
|
||||
- Chart fills its container; `ResizeObserver` keeps the uPlot canvas sized correctly.
|
||||
|
||||
### 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 +339,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 +382,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.
|
||||
|
||||
---
|
||||
|
||||
@@ -346,10 +414,10 @@ archive_url = "" # EPICS Archive Appliance URL
|
||||
|
||||
[datasource.synthetic]
|
||||
enabled = true
|
||||
definitions_file = "./synthetic.json"
|
||||
definitions_file = "./synthetic.toml"
|
||||
```
|
||||
|
||||
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_EPICS_CA_ADDR_LIST`).
|
||||
|
||||
---
|
||||
|
||||
@@ -359,11 +427,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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user