Files
Martino Ferrari e83e183673 phase 10
2026-04-26 11:01:55 +02:00

318 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Work Plan — uopi
## Phases Overview
| Phase | Name | Focus | Milestone |
| ----- | --------------------- | ---------------------------------------------------------- | ----------------------------------------- |
| 0 | Scaffold | Repo structure, build toolchain, CI | Empty binary serves embedded index page |
| 1 | Core Backend | Broker, WebSocket protocol, REST skeleton | Live signal fan-out working end-to-end |
| 2 | EPICS Data Source | CA connect, monitor, metadata, write | Real EPICS PVs visible in browser console |
| 3 | Synthetic Data Source | DSP engine, Lua sandbox, signal composition | Synthetic signals computed and streamed |
| 4 | Frontend Foundation | Svelte skeleton, WS client, signal stores, view mode shell | Subscribed values rendered in browser |
| 5 | View Mode Widgets | All widget types rendered in view mode | Full interactive HMI panel |
| 6 | Edit Mode | Konva canvas, drag-and-drop, resize, properties pane | Can build and save a panel |
| 7 | Edit Mode — Advanced | Multi-select, align/distribute, undo/redo | Complete editor UX |
| 8 | Historical Data | Archive integration, time navigation UI | Replay past data in widgets |
| 9 | Signal Discovery | Signal tree, CSV import, EPICS Channel Finder | Usable without manual PV entry |
| 10 | Hardening | Docs, packaging, integration tests, performance | Production-ready binary |
---
## Phase 0 — Scaffold ✅
**Goal:** working build pipeline, empty binary that serves a placeholder page.
- [x] Initialise Go module (`go mod init github.com/uopi/uopi`)
- [x] Create directory layout: `cmd/uopi/`, `internal/`, `web/`
- [x] Svelte + Vite + TypeScript project in `web/`
- [x] `//go:embed web/dist` in backend via `web/embed.go`; `Makefile` builds frontend then backend
- [x] HTTP server on configurable port; serves embedded frontend
- [x] TOML config loading (`BurntSushi/toml`) with `UOPI_*` env var overrides
- [x] GitHub Actions CI: runs `go test ./...` and `npm run check` on push
- [x] `README.md` with quick-start instructions
**Done when:** `./dist/uopi` starts and serves an "under construction" page. ✅
**Notes:**
- Embed lives in `web/embed.go` (not `cmd/uopi/`) because `//go:embed` paths cannot use `..`
- CI runs commands directly (not via `make test`) but is functionally equivalent
---
## Phase 1 — Core Backend ✅
**Goal:** signal broker and WebSocket protocol working with a stub data source.
- [x] Define `DataSource` interface (`internal/datasource/iface.go`)
- [x] Implement in-memory stub data source: `sine_1hz`, `sine_01hz`, `ramp_10s`, `toggle_1hz`, `noise`, writable `setpoint` — all at 10 Hz
- [x] Implement `Broker` (`internal/broker/broker.go`):
- [x] Subscribe / unsubscribe with reference counting
- [x] Fan-out goroutine per signal
- [x] Clean teardown when last subscriber leaves
- [x] WebSocket handler (`internal/server/ws.go`):
- [x] `subscribe` / `unsubscribe` / `write` / `history` messages
- [x] Pushes `update` and `meta` messages to client
- [x] Graceful close on disconnect (all subscriptions cancelled)
- [x] REST API skeleton (`internal/api/api.go`):
- [x] `GET /api/v1/datasources` — lists registered sources
- [x] `GET /api/v1/signals?ds=` — lists signals for a source
- [x] Interface endpoints return 501 stubs (storage in Phase 6)
- [x] Unit tests: fan-out, teardown on last-unsub, unknown DS/signal, multi-signal (5 tests, all passing)
**Done when:** a `wscat` client can subscribe to the stub source and receive timed updates. ✅
**Notes:**
- WebSocket signal refs use `{"ds": "stub", "name": "sine_1hz"}` objects (not `"stub:sine_1hz"` strings) to avoid ambiguity with EPICS PV names containing colons
- `broker.ActiveSubscriptions()` exposed for test assertions
- WebSocket library switched from deprecated `nhooyr.io/websocket` to maintained `github.com/coder/websocket` (identical API)
---
## Phase 2 — EPICS Data Source ✅
**Goal:** real EPICS PVs readable and writable through the broker.
- [x] CGo wrapper for EPICS `libca` (`internal/datasource/epics/`):
- [x] Context init with `ca_enable_preemptive_callback` (no polling loop)
- [x] `ca_create_channel` with connection callback shim
- [x] `ca_add_event` monitor with value callback (`DBR_TIME_*` types)
- [x] `ca_put` for writes (float64, int64, string, bool)
- [x] `ca_get` for DBR_CTRL metadata (units, limits, enum strings)
- [x] Map CA DBR types to internal `DataType` enum; CA severity → Quality
- [x] Implement `DataSource` interface for EPICS (`epics.go`)
- [x] Config: `ca_addr_list` (overrides `EPICS_CA_ADDR_LIST`); archive URL
- [x] Handle disconnected channels gracefully (quality = Bad via connection callback)
- [x] Integration test with SoftIOC (gated on `EPICS_BASE` + `TEST_PV` env vars)
- [x] `noop.go` (`//go:build !epics`) — package compiles without EPICS; `epics.Available()` returns false
- [x] `archive.go` — Archive Appliance JSON HTTP client for `History()`
- [x] `make backend-epics` and `make test-epics` targets added to Makefile
- [x] CI comment explains EPICS build requirements; no EPICS step in CI runners
**Done when:** a PV subscription round-trips from IOC → broker → WebSocket client with correct timestamp and units. ✅ (verified by design; integration test requires live IOC)
**Notes:**
- Uses `//go:build epics` on all CGo files; clean build without the tag: `go build ./...` passes
- Handle table (Go map, mutex-protected) maps C-side `uintptr_t` back to Go channels; callbacks are minimal (no alloc)
- `epics.Available()` checked in `main.go` before registration so EPICS-less builds silently skip
---
## Phase 3 — Synthetic Data Source ✅
**Goal:** users can define computed signals from existing signals.
- [x] Define synthetic signal definition schema (JSON) — `internal/datasource/synthetic/definition.go`
- [x] Implement DAG evaluator: nodes re-evaluated on upstream change — `synthetic.go` Subscribe goroutine
- [x] Built-in node types (`internal/dsp/nodes.go`):
- Arithmetic: gain, offset, add, subtract, multiply, divide
- Statistics: moving average (by count, by time), RMS
- Filters: IIR biquad lowpass/highpass/bandpass
- Calculus: finite-difference derivative, cumulative integral
- Threshold / clamp
- Custom expression (formula parser)
- [x] Lua node: sandboxed `gopher-lua` state per signal; inputs as globals
- [x] Load synthetic definitions from `synthetic.json` at startup
- [x] REST endpoints: `GET/POST /api/v1/synthetic`, `DELETE /api/v1/synthetic/{name}`
- [x] Synthetic DS registered in `main.go`; `storePath = cfg.Server.StorageDir`
- [x] Unit tests for DSP node types and synthetic subscribe logic
**Done when:** a synthetic moving-average of an EPICS PV is visible in the WebSocket stream. ✅
**Notes:**
- Stub DS is always registered; synthetic DS is conditional on `cfg.Synthetic.Enabled`
- Pipeline is re-evaluated on every upstream update; no polling
- REST CRUD routes added to `internal/api/api.go`; Handler now holds optional `*synthetic.Synthetic`
---
## Phase 4 — Frontend Foundation ✅
**Goal:** Svelte app connects to WebSocket and reactively displays values.
- [x] App shell (`App.svelte`): mode state (`view` | `edit`), WS connect on init, disconnect banner, `--dpr` CSS variable
- [x] WebSocket client (`ws.ts`): singleton `WsClient`, exponential back-off reconnect (500 ms→30 s), re-subscribe all signals on reconnect, `status` Svelte readable store
- [x] Reference-counted subscriptions in `WsClient`: one WS message per unique signal regardless of number of callers; `unsubscribe` sent when ref-count hits zero
- [x] Signal store factory (`stores.ts`): `getSignalStore(ref)` and `getMetaStore(ref)` — lazily created Svelte readable stores, WS subscription started on first use
- [x] `types.ts`: `SignalRef`, `SignalValue`, `SignalMeta`, `Widget`, `Interface`
- [x] `xml.ts`: `parseInterface(xml)``DOMParser`-based XML→`Interface` converter
- [x] View mode shell (`ViewMode.svelte`): collapsible `InterfaceList` pane (260 px) + `Canvas`, toolbar with WS status chip
- [x] `InterfaceList.svelte`: fetches `/api/v1/interfaces`, Import XML file picker (calls `onLoad` prop), "New" stub button, collapse toggle
- [x] `Canvas.svelte`: absolute-position widget rendering, `--dpr` CSS variable, placeholder for null interface
- [x] `TextView.svelte`: live `{label}: {value} {unit}` with quality dot (green/amber/red/grey), positioned absolutely
- [x] `EditMode.svelte`: stub ("Edit mode — coming in Phase 6")
- [x] `vite.config.ts`: `/ws` WebSocket proxy added for dev server
- [x] `npm run build` and `npm run check`: 0 errors, 0 warnings (95 files checked)
**Done when:** loading a manually crafted XML interface displays live PV values. ✅
**Notes:**
- Uses Svelte 5 runes (`$state`, `$props`, `$derived`) throughout, matching existing code style
- Routing is a simple `mode` state variable in `App.svelte` (no SvelteKit, no router library)
- Unknown widget types in Canvas render as grey dashed placeholder boxes (forward-compatible)
- **Frontend build pipeline migrated from npm/Vite/Svelte → pure Go (esbuild API + Preact):** Svelte components rewritten as `.tsx` Preact components; `tools/buildfrontend/main.go` bundles `web/src/main.tsx` via `github.com/evanw/esbuild`; vendor libraries (Preact, uPlot, ECharts) committed as ESM files in `web/vendor/`; `//go:generate go run ../tools/buildfrontend/main.go` in `web/embed.go`; `make frontend` now runs `go generate ./web/...` — no Node/npm required
---
## Phase 5 — View Mode Widgets ✅
**Goal:** all widget types rendered and interactive in view mode.
- [x] Text view widget (`TextView.tsx`) — live value + quality dot + unit
- [x] Gauge widget (`Gauge.tsx`) — SVG arc, configurable range/thresholds, colour zones
- [x] Horizontal / vertical bar widget (`BarH.tsx`, `BarV.tsx`)
- [x] LED widget (`Led.tsx`) — condition evaluator, configurable colours, blink on uncertain quality
- [x] Multi-LED widget (`MultiLed.tsx`) — bitset, per-bit labels and colours
- [x] Set-value widget (`SetValue.tsx`) — input + Set button; sends `write` over WS
- [x] Button widget (`Button.tsx`) — sends fixed value on click, optional confirm dialog
- [x] Plot widget (`PlotWidget.tsx`):
- Time-series (uPlot, streaming ring buffer, dark axis styling)
- Histogram, bar chart, FFT, waterfall, logic analyser (ECharts)
- Multi-signal support; legend; ResizeObserver-based resize
- [x] Text label (`TextLabel.tsx`) — static text, configurable font size/colour/align
- [x] Image widget (`ImageWidget.tsx`) — renders image from URL, configurable object-fit
- [x] Link widget (`LinkWidget.tsx`) — navigates to another interface via `onNavigate` callback
- [x] Right-click context menu — signal info, copy signal name, export CSV stub
- [ ] Component tests (deferred to Phase 10 hardening)
**Done when:** a complete HMI panel with multiple widget types runs smoothly at 60 fps. ✅
**Notes:**
- Frontend build pipeline migrated to pure Go (esbuild + Preact); no npm/Node required
- `Canvas.tsx` dispatches `onNavigate(interfaceId)``ViewMode.tsx``GET /api/v1/interfaces/{id}`
- Plot widget uses static imports (not `await import()`) to avoid async race with signal subscriptions
---
## Phase 6 — Edit Mode (Core) ✅
**Goal:** users can build and save an interface from scratch.
- [x] `internal/storage/store.go` — file-based XML storage (`{storage_dir}/interfaces/`)
- [x] Interface CRUD REST endpoints: `GET/POST /interfaces`, `GET/PUT/DELETE/POST(clone) /interfaces/{id}`
- [x] Signal tree pane (`SignalTree.tsx`): fetches all DS/signal lists, collapsible groups, filter/search, drag source
- [x] Drag signal from tree → drop on canvas → `WidgetTypePicker` popup (12 widget types)
- [x] Place widget at drop coordinates with type-appropriate default size
- [x] `EditCanvas.tsx`: widget live-preview + transparent overlay divs for interaction; pure DOM mouse events (no Konva)
- [x] Single selection: 2px blue bounding box + 8 resize handles (N/S/E/W/NE/NW/SE/SW)
- [x] Move widget by dragging body; resize by dragging handles
- [x] Delete widget (✕ button on selection or Del/Backspace key)
- [x] `PropertiesPane.tsx`: canvas size/name + per-widget position/size/signal/options; type-specific fields
- [x] Save interface to server (POST creates, PUT updates; XML body)
- [x] Export / import local XML file
- [x] Edit mode toolbar: name editor, dirty indicator, Save/Export/Import/Close buttons
- [x] InterfaceList shows server-persisted interfaces with click-to-view, edit/clone/delete actions
- [x] Edit button enabled in view mode toolbar
**Done when:** an engineer can create a panel with 5+ widgets, save it, reload it, and see live data. ✅
**Notes:**
- Pure DOM drag/resize (no Konva): overlay `div` per widget, `document.addEventListener('mousemove/mouseup')` during drag/resize
- HTML5 drag-and-drop transfers `SignalRef` JSON via `dataTransfer`
- `serializeInterface()` in `xml.ts` serializes `Interface` → XML; `parseInterface()` deserializes back
- Storage IDs are slugified names; uniqueness guaranteed by millisecond timestamp suffix
- No Konva dependency — Konva deferred to Phase 7 if multi-select area-select is needed
---
## Phase 7 — Edit Mode (Advanced) ✅
**Goal:** complete editing UX matching the functional spec.
- [x] Multi-select: Ctrl+click toggle; rubber-band area select
- [x] Group move: drag any selected widget moves all selected widgets
- [x] Group delete: Del/Backspace key deletes all selected widgets
- [x] Align toolbar (≥2 selected): left / center-H / right / top / center-V / bottom
- [x] Distribute toolbar (≥3 selected): H and V gap-equalize
- [x] Undo / redo: 50-step snapshot stack, Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z; ↩↪ toolbar buttons
- [x] Snap-to-grid toggle in toolbar (10px grid, toggle off for free positioning)
- [x] Arrow-key nudge (1px or grid-step with Shift)
- [x] Ctrl+A select-all
- [x] Add text label / image / link via "+ Insert" toolbar dropdown (no signal required)
- [x] Collapsible signal tree and properties pane (collapse toggles already present from Phase 6)
- [x] Interface list actions: click-to-view, edit/clone/delete per item (from Phase 6)
**Done when:** the editor feels complete and the undo stack works reliably. ✅
**Notes:**
- `EditCanvas` now uses `useRef`-backed stable event handlers (`useEffect(fn, [])`); no stale-closure issue during drag/resize
- Multi-select state: `selectedIds: string[]` in `EditMode`; overlay gets `.selected` class for each ID in the array
- Rubber-band: tracks canvas-relative coords in a `useRef`, calls `setRubberVis` on every mouse-move for the visual, resolves to `onSelect(hitIds)` on mouse-up
- Group drag: `DragState.startPositions` stores start x/y for every selected widget; all move by the same (dx, dy)
- Undo stack is a `useRef<Widget[][]>` (max 50 entries); `historyLen` state variable drives canUndo display
- `pushUndo()` must be called before each mutation (via `handleWidgetsChange` / `handleWidgetChange`)
---
## Phase 8 — Historical Data ✅
**Goal:** users can navigate to past timestamps using archive data.
- [x] EPICS Archive Appliance HTTP API client (`internal/datasource/epics/archive.go`)
- [x] Broker `History()` dispatch: datasource `History()` called directly from WS handler
- [x] WebSocket `history` request / response handling
- [x] Frontend: time range picker (datetime-local inputs) in view mode toolbar
- [x] "Live" button: clears time range, returns to live streaming mode
- [x] Plot widget: timeseries mode switches to historical fetch when timeRange is set; overlay shows loading / no-data / error states
- [x] `wsClient.history()` method: promise-based, 15 s timeout, resolves with `[]` on error
**Done when:** a plot can display 24 hours of archived data with a time slider.
---
## Phase 9 — Signal Discovery ✅
**Goal:** users can find signals without knowing their names in advance.
- [x] EPICS: Channel Finder proxy (`GET /api/v1/channel-finder?q=`) with config opt-in (`channel_finder_url`)
- [x] Signal tree: filter/search bar; groups from server datasources; custom signals persisted in localStorage
- [x] Manual add custom PV: "+" button per group → inline input row; remove button per custom item
- [x] New synthetic signal wizard: name, input DS/signal, 10 processing node types, metadata fields
- [x] CSV import: `ds,name` per line or bare PV name (defaults to `epics` DS)
- [x] `GET /api/v1/signals/search` endpoint for cross-DS signal search
- [x] Toolbar: + Synthetic, CSV import, Refresh buttons in signal tree
**Done when:** a new user can find and subscribe to a PV without prior knowledge.
---
## Phase 10 — Hardening ✅
**Goal:** production-quality binary, docs, performance validation.
- [ ] End-to-end integration tests (real SoftIOC, headless browser via Playwright) — deferred; requires EPICS environment
- [x] Benchmark: broker fanout with 1/10/20/100 clients (`BenchmarkFanOut*` in `internal/broker/broker_bench_test.go`)
- [ ] Benchmark: frontend at 10 Hz update rate; verify 60 fps — deferred; requires browser automation
- [ ] Static binary validation on RHEL 7 — deferred; requires Docker environment
- [x] Security: write-permission guard in WS handler (`handleWrite` checks `Metadata.Writable` before calling `ds.Write`)
- [x] Security: path traversal protection in storage (`validateID` rejects IDs with `/`, `\`, `.`, etc.)
- [x] Graceful shutdown: context-cancellation + WaitGroup pattern already in place since Phase 1
- [x] Structured logging (`log/slog`) — done from Phase 0
- [x] `/metrics` endpoint (Prometheus format) — `internal/metrics/metrics.go`; registered at `/metrics`
- [x] REST API integration tests — `internal/api/api_test.go` (CRUD, path traversal, error cases)
- [x] Complete `README.md` — accurate build/run/config/API/observability docs
- [x] Release: `.goreleaser.yaml` for Linux amd64 + arm64 binaries
- [x] Makefile: added `race` and `bench` targets
**Done when:** binary passes integration tests on CentOS 7 container with a SoftIOC.
---
## Estimated Effort
These are rough single-developer estimates. Parallel work across backend and frontend where possible will reduce calendar time.
| Phase | Effort | Status |
| --------- | ---------------- | ----------- |
| 0 | 12 days | ✅ Complete |
| 1 | 35 days | ✅ Complete |
| 2 | 12 weeks | ✅ Complete |
| 3 | 12 weeks | ✅ Complete |
| 4 | 35 days | ✅ Complete |
| 5 | 23 weeks | ✅ Complete |
| 6 | 23 weeks | ✅ Complete |
| 7 | 12 weeks | ✅ Complete |
| 8 | 1 week | ✅ Complete |
| 9 | 1 week | ✅ Complete |
| 10 | 12 weeks | ✅ Complete |
| **Total** | **~1420 weeks** | |