19 KiB
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.
- Initialise Go module (
go mod init github.com/uopi/uopi) - Create directory layout:
cmd/uopi/,internal/,web/ - Svelte + Vite + TypeScript project in
web/ //go:embed web/distin backend viaweb/embed.go;Makefilebuilds frontend then backend- HTTP server on configurable port; serves embedded frontend
- TOML config loading (
BurntSushi/toml) withUOPI_*env var overrides - GitHub Actions CI: runs
go test ./...andnpm run checkon push README.mdwith quick-start instructions
Done when: ./dist/uopi starts and serves an "under construction" page. ✅
Notes:
- Embed lives in
web/embed.go(notcmd/uopi/) because//go:embedpaths 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.
- Define
DataSourceinterface (internal/datasource/iface.go) - Implement in-memory stub data source:
sine_1hz,sine_01hz,ramp_10s,toggle_1hz,noise, writablesetpoint— all at 10 Hz - Implement
Broker(internal/broker/broker.go):- Subscribe / unsubscribe with reference counting
- Fan-out goroutine per signal
- Clean teardown when last subscriber leaves
- WebSocket handler (
internal/server/ws.go):subscribe/unsubscribe/write/historymessages- Pushes
updateandmetamessages to client - Graceful close on disconnect (all subscriptions cancelled)
- REST API skeleton (
internal/api/api.go):GET /api/v1/datasources— lists registered sourcesGET /api/v1/signals?ds=— lists signals for a source- Interface endpoints return 501 stubs (storage in Phase 6)
- 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/websocketto maintainedgithub.com/coder/websocket(identical API)
Phase 2 — EPICS Data Source ✅
Goal: real EPICS PVs readable and writable through the broker.
- CGo wrapper for EPICS
libca(internal/datasource/epics/):- Context init with
ca_enable_preemptive_callback(no polling loop) ca_create_channelwith connection callback shimca_add_eventmonitor with value callback (DBR_TIME_*types)ca_putfor writes (float64, int64, string, bool)ca_getfor DBR_CTRL metadata (units, limits, enum strings)
- Context init with
- Map CA DBR types to internal
DataTypeenum; CA severity → Quality - Implement
DataSourceinterface for EPICS (epics.go) - Config:
ca_addr_list(overridesEPICS_CA_ADDR_LIST); archive URL - Handle disconnected channels gracefully (quality = Bad via connection callback)
- Integration test with SoftIOC (gated on
EPICS_BASE+TEST_PVenv vars) noop.go(//go:build !epics) — package compiles without EPICS;epics.Available()returns falsearchive.go— Archive Appliance JSON HTTP client forHistory()make backend-epicsandmake test-epicstargets added to Makefile- 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 epicson all CGo files; clean build without the tag:go build ./...passes - Handle table (Go map, mutex-protected) maps C-side
uintptr_tback to Go channels; callbacks are minimal (no alloc) epics.Available()checked inmain.gobefore registration so EPICS-less builds silently skip
Phase 3 — Synthetic Data Source ✅
Goal: users can define computed signals from existing signals.
- Define synthetic signal definition schema (JSON) —
internal/datasource/synthetic/definition.go - Implement DAG evaluator: nodes re-evaluated on upstream change —
synthetic.goSubscribe goroutine - 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)
- Lua node: sandboxed
gopher-luastate per signal; inputs as globals - Load synthetic definitions from
synthetic.jsonat startup - REST endpoints:
GET/POST /api/v1/synthetic,DELETE /api/v1/synthetic/{name} - Synthetic DS registered in
main.go;storePath = cfg.Server.StorageDir - 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.
- App shell (
App.svelte): mode state (view|edit), WS connect on init, disconnect banner,--dprCSS variable - WebSocket client (
ws.ts): singletonWsClient, exponential back-off reconnect (500 ms→30 s), re-subscribe all signals on reconnect,statusSvelte readable store - Reference-counted subscriptions in
WsClient: one WS message per unique signal regardless of number of callers;unsubscribesent when ref-count hits zero - Signal store factory (
stores.ts):getSignalStore(ref)andgetMetaStore(ref)— lazily created Svelte readable stores, WS subscription started on first use types.ts:SignalRef,SignalValue,SignalMeta,Widget,Interfacexml.ts:parseInterface(xml)—DOMParser-based XML→Interfaceconverter- View mode shell (
ViewMode.svelte): collapsibleInterfaceListpane (260 px) +Canvas, toolbar with WS status chip InterfaceList.svelte: fetches/api/v1/interfaces, Import XML file picker (callsonLoadprop), "New" stub button, collapse toggleCanvas.svelte: absolute-position widget rendering,--dprCSS variable, placeholder for null interfaceTextView.svelte: live{label}: {value} {unit}with quality dot (green/amber/red/grey), positioned absolutelyEditMode.svelte: stub ("Edit mode — coming in Phase 6")vite.config.ts:/wsWebSocket proxy added for dev servernpm run buildandnpm 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
modestate variable inApp.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
.tsxPreact components;tools/buildfrontend/main.gobundlesweb/src/main.tsxviagithub.com/evanw/esbuild; vendor libraries (Preact, uPlot, ECharts) committed as ESM files inweb/vendor/;//go:generate go run ../tools/buildfrontend/main.goinweb/embed.go;make frontendnow runsgo generate ./web/...— no Node/npm required
Phase 5 — View Mode Widgets ✅
Goal: all widget types rendered and interactive in view mode.
- Text view widget (
TextView.tsx) — live value + quality dot + unit - Gauge widget (
Gauge.tsx) — SVG arc, configurable range/thresholds, colour zones - Horizontal / vertical bar widget (
BarH.tsx,BarV.tsx) - LED widget (
Led.tsx) — condition evaluator, configurable colours, blink on uncertain quality - Multi-LED widget (
MultiLed.tsx) — bitset, per-bit labels and colours - Set-value widget (
SetValue.tsx) — input + Set button; sendswriteover WS - Button widget (
Button.tsx) — sends fixed value on click, optional confirm dialog - 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
- Text label (
TextLabel.tsx) — static text, configurable font size/colour/align - Image widget (
ImageWidget.tsx) — renders image from URL, configurable object-fit - Link widget (
LinkWidget.tsx) — navigates to another interface viaonNavigatecallback - 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.tsxdispatchesonNavigate(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.
internal/storage/store.go— file-based XML storage ({storage_dir}/interfaces/)- Interface CRUD REST endpoints:
GET/POST /interfaces,GET/PUT/DELETE/POST(clone) /interfaces/{id} - Signal tree pane (
SignalTree.tsx): fetches all DS/signal lists, collapsible groups, filter/search, drag source - Drag signal from tree → drop on canvas →
WidgetTypePickerpopup (12 widget types) - Place widget at drop coordinates with type-appropriate default size
EditCanvas.tsx: widget live-preview + transparent overlay divs for interaction; pure DOM mouse events (no Konva)- Single selection: 2px blue bounding box + 8 resize handles (N/S/E/W/NE/NW/SE/SW)
- Move widget by dragging body; resize by dragging handles
- Delete widget (✕ button on selection or Del/Backspace key)
PropertiesPane.tsx: canvas size/name + per-widget position/size/signal/options; type-specific fields- Save interface to server (POST creates, PUT updates; XML body)
- Export / import local XML file
- Edit mode toolbar: name editor, dirty indicator, Save/Export/Import/Close buttons
- InterfaceList shows server-persisted interfaces with click-to-view, edit/clone/delete actions
- 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
divper widget,document.addEventListener('mousemove/mouseup')during drag/resize - HTML5 drag-and-drop transfers
SignalRefJSON viadataTransfer serializeInterface()inxml.tsserializesInterface→ 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.
- Multi-select: Ctrl+click toggle; rubber-band area select
- Group move: drag any selected widget moves all selected widgets
- Group delete: Del/Backspace key deletes all selected widgets
- Align toolbar (≥2 selected): left / center-H / right / top / center-V / bottom
- Distribute toolbar (≥3 selected): H and V gap-equalize
- Undo / redo: 50-step snapshot stack, Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z; ↩↪ toolbar buttons
- Snap-to-grid toggle in toolbar (10px grid, toggle off for free positioning)
- Arrow-key nudge (1px or grid-step with Shift)
- Ctrl+A select-all
- Add text label / image / link via "+ Insert" toolbar dropdown (no signal required)
- Collapsible signal tree and properties pane (collapse toggles already present from Phase 6)
- 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:
EditCanvasnow usesuseRef-backed stable event handlers (useEffect(fn, [])); no stale-closure issue during drag/resize- Multi-select state:
selectedIds: string[]inEditMode; overlay gets.selectedclass for each ID in the array - Rubber-band: tracks canvas-relative coords in a
useRef, callssetRubberVison every mouse-move for the visual, resolves toonSelect(hitIds)on mouse-up - Group drag:
DragState.startPositionsstores start x/y for every selected widget; all move by the same (dx, dy) - Undo stack is a
useRef<Widget[][]>(max 50 entries);historyLenstate variable drives canUndo display pushUndo()must be called before each mutation (viahandleWidgetsChange/handleWidgetChange)
Phase 8 — Historical Data ✅
Goal: users can navigate to past timestamps using archive data.
- EPICS Archive Appliance HTTP API client (
internal/datasource/epics/archive.go) - Broker
History()dispatch: datasourceHistory()called directly from WS handler - WebSocket
historyrequest / response handling - Frontend: time range picker (datetime-local inputs) in view mode toolbar
- "Live" button: clears time range, returns to live streaming mode
- Plot widget: timeseries mode switches to historical fetch when timeRange is set; overlay shows loading / no-data / error states
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.
- EPICS: Channel Finder proxy (
GET /api/v1/channel-finder?q=) with config opt-in (channel_finder_url) - Signal tree: filter/search bar; groups from server datasources; custom signals persisted in localStorage
- Manual add custom PV: "+" button per group → inline input row; remove button per custom item
- New synthetic signal wizard: name, input DS/signal, 10 processing node types, metadata fields
- CSV import:
ds,nameper line or bare PV name (defaults toepicsDS) GET /api/v1/signals/searchendpoint for cross-DS signal search- 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
- Benchmark: broker fanout with 1/10/20/100 clients (
BenchmarkFanOut*ininternal/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
- Security: write-permission guard in WS handler (
handleWritechecksMetadata.Writablebefore callingds.Write) - Security: path traversal protection in storage (
validateIDrejects IDs with/,\,., etc.) - Graceful shutdown: context-cancellation + WaitGroup pattern already in place since Phase 1
- Structured logging (
log/slog) — done from Phase 0 /metricsendpoint (Prometheus format) —internal/metrics/metrics.go; registered at/metrics- REST API integration tests —
internal/api/api_test.go(CRUD, path traversal, error cases) - Complete
README.md— accurate build/run/config/API/observability docs - Release:
.goreleaser.yamlfor Linux amd64 + arm64 binaries - Makefile: added
raceandbenchtargets
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 |