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

19 KiB
Raw Permalink Blame History

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/dist in backend via web/embed.go; Makefile builds frontend then backend
  • HTTP server on configurable port; serves embedded frontend
  • TOML config loading (BurntSushi/toml) with UOPI_* env var overrides
  • GitHub Actions CI: runs go test ./... and npm run check on push
  • 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.

  • Define DataSource interface (internal/datasource/iface.go)
  • Implement in-memory stub data source: sine_1hz, sine_01hz, ramp_10s, toggle_1hz, noise, writable setpoint — 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 / history messages
    • Pushes update and meta messages to client
    • Graceful close on disconnect (all subscriptions cancelled)
  • REST API skeleton (internal/api/api.go):
    • GET /api/v1/datasources — lists registered sources
    • GET /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/websocket to maintained github.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_channel with connection callback shim
    • ca_add_event monitor with value callback (DBR_TIME_* types)
    • ca_put for writes (float64, int64, string, bool)
    • ca_get for DBR_CTRL metadata (units, limits, enum strings)
  • Map CA DBR types to internal DataType enum; CA severity → Quality
  • Implement DataSource interface for EPICS (epics.go)
  • Config: ca_addr_list (overrides EPICS_CA_ADDR_LIST); archive URL
  • Handle disconnected channels gracefully (quality = Bad via connection callback)
  • Integration test with SoftIOC (gated on EPICS_BASE + TEST_PV env vars)
  • noop.go (//go:build !epics) — package compiles without EPICS; epics.Available() returns false
  • archive.go — Archive Appliance JSON HTTP client for History()
  • make backend-epics and make test-epics targets 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 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.

  • Define synthetic signal definition schema (JSON) — internal/datasource/synthetic/definition.go
  • Implement DAG evaluator: nodes re-evaluated on upstream change — synthetic.go Subscribe 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-lua state per signal; inputs as globals
  • Load synthetic definitions from synthetic.json at 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, --dpr CSS variable
  • WebSocket client (ws.ts): singleton WsClient, exponential back-off reconnect (500 ms→30 s), re-subscribe all signals on reconnect, status Svelte readable store
  • Reference-counted subscriptions in WsClient: one WS message per unique signal regardless of number of callers; unsubscribe sent when ref-count hits zero
  • Signal store factory (stores.ts): getSignalStore(ref) and getMetaStore(ref) — lazily created Svelte readable stores, WS subscription started on first use
  • types.ts: SignalRef, SignalValue, SignalMeta, Widget, Interface
  • xml.ts: parseInterface(xml)DOMParser-based XML→Interface converter
  • View mode shell (ViewMode.svelte): collapsible InterfaceList pane (260 px) + Canvas, toolbar with WS status chip
  • InterfaceList.svelte: fetches /api/v1/interfaces, Import XML file picker (calls onLoad prop), "New" stub button, collapse toggle
  • Canvas.svelte: absolute-position widget rendering, --dpr CSS variable, placeholder for null interface
  • TextView.svelte: live {label}: {value} {unit} with quality dot (green/amber/red/grey), positioned absolutely
  • EditMode.svelte: stub ("Edit mode — coming in Phase 6")
  • vite.config.ts: /ws WebSocket proxy added for dev server
  • 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.

  • 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; sends write over 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 via onNavigate callback
  • 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.tsxGET /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 → WidgetTypePicker popup (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 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.

  • 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:

  • 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.

  • EPICS Archive Appliance HTTP API client (internal/datasource/epics/archive.go)
  • Broker History() dispatch: datasource History() called directly from WS handler
  • WebSocket history request / 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,name per line or bare PV name (defaults to epics DS)
  • GET /api/v1/signals/search endpoint 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* 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
  • Security: write-permission guard in WS handler (handleWrite checks Metadata.Writable before calling ds.Write)
  • Security: path traversal protection in storage (validateID rejects IDs with /, \, ., etc.)
  • Graceful shutdown: context-cancellation + WaitGroup pattern already in place since Phase 1
  • Structured logging (log/slog) — done from Phase 0
  • /metrics endpoint (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.yaml for Linux amd64 + arm64 binaries
  • 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