# 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         | 1–2 days         | ✅ Complete |
| 1         | 3–5 days         | ✅ Complete |
| 2         | 1–2 weeks        | ✅ Complete |
| 3         | 1–2 weeks        | ✅ Complete |
| 4         | 3–5 days         | ✅ Complete |
| 5         | 2–3 weeks        | ✅ Complete |
| 6         | 2–3 weeks        | ✅ Complete |
| 7         | 1–2 weeks        | ✅ Complete |
| 8         | 1 week           | ✅ Complete |
| 9         | 1 week           | ✅ Complete |
| 10        | 1–2 weeks        | ✅ Complete |
| **Total** | **~14–20 weeks** |             |
