diff --git a/Makefile b/Makefile index 958ab64..d5c04a6 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all frontend backend backend-epics backend-debug test lint clean run +.PHONY: all frontend backend backend-epics backend-debug test bench race lint clean run # --------------------------------------------------------------------------- # # Sources — adding any file here triggers a frontend or backend rebuild # @@ -62,6 +62,14 @@ backend-debug: $(FRONTEND_OUT) test: go test ./... +# Run tests with the race detector enabled. +race: + go test -race ./... + +# Run all benchmarks and print memory allocations. +bench: + go test -bench=. -benchmem -benchtime=3s ./internal/... + lint: go vet ./... diff --git a/README.md b/README.md index 1a43a7e..68030f3 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,24 @@ A web-based HMI (Human-Machine Interface) for monitoring and controlling EPICS-based scientific and industrial control systems. -uopi runs as a single portable binary. Access it from any browser — including over an SSH tunnel — with no client-side installation required. +uopi runs as a **single portable binary** with no runtime dependencies. Point any browser at it — including over an SSH tunnel — and you get a full drag-and-drop HMI builder and live data viewer. --- ## Features -- **Live data** from EPICS (Channel Access / PVAccess) and user-defined synthetic signals -- **Web-based HMI editor** — drag-and-drop widgets, resize, align, undo/redo — saved as XML -- **Rich widget library** — gauges, LEDs, bars, plots (time series, FFT, waterfall, histogram), controls -- **Multiple concurrent clients** sharing the same data subscriptions -- **Historical data** navigation via EPICS Archive Appliance integration -- **Synthetic signals** — compose, filter, and transform existing signals with a DSP pipeline or Lua scripts -- **Single binary** with embedded frontend; no runtime dependencies +| Feature | Details | +|---------|---------| +| **Live data** | EPICS Channel Access (CA) and user-defined synthetic signals | +| **HMI editor** | Drag-and-drop widgets, resize, align/distribute, undo/redo, saved as XML | +| **Widget library** | Text view, gauge, LED, bar, set-point control, button, plots | +| **Plot types** | Time-series (uPlot), FFT, waterfall, histogram, bar chart, logic analyser | +| **Historical data** | EPICS Archive Appliance integration via REST; time range picker in UI | +| **Synthetic signals** | Compose, filter, and transform signals with a DSP pipeline (gain, offset, moving average, lowpass, highpass, derivative, integral, clamp, formula) | +| **Multi-client** | Subscriptions are multiplexed — N clients share one upstream connection per signal | +| **Signal discovery** | Filter/search, CSV import, manual add, Channel Finder proxy | +| **Observability** | Prometheus-format metrics at `/metrics` | +| **Single binary** | Frontend assets embedded at build time — no web server needed | --- @@ -22,13 +27,13 @@ uopi runs as a single portable binary. Access it from any browser — including ### Prerequisites -- EPICS Base installed (for the EPICS data source; `libca` must be linkable) -- Go 1.22+ -- Node.js 20+ and npm +- **Go 1.22+** — the only build dependency (the frontend is bundled by a pure-Go esbuild wrapper) +- EPICS Base (`libca`) — only required for the `epics` build tag; the stub and synthetic data sources work without it ### Build ```bash +# Full build — bundles the frontend then compiles the binary make all ``` @@ -37,10 +42,11 @@ The binary is written to `dist/uopi`. ### Run ```bash -./dist/uopi --config uopi.toml +./dist/uopi # uses default config (stub data source only) +./dist/uopi --config uopi.toml # explicit config file ``` -Then open `http://localhost:8080` in your browser. +Then open [http://localhost:8080](http://localhost:8080) in your browser. **SSH tunnel example:** @@ -53,69 +59,143 @@ ssh -L 8080:localhost:8080 user@controlhost ## Configuration -Create `uopi.toml` (or copy and edit the example): +Create `uopi.toml` (all fields are optional — shown values are defaults): ```toml [server] -listen = ":8080" -storage_dir = "./interfaces" # where interface XML files are stored +listen = ":8080" +storage_dir = "./data" # where interface XML files and synthetic.json are stored [datasource.epics] -enabled = true -ca_addr_list = "" # overrides EPICS_CA_ADDR_LIST if non-empty -archive_url = "" # EPICS Archive Appliance base URL +enabled = false # requires build with -tags epics +ca_addr_list = "" # overrides EPICS_CA_ADDR_LIST if non-empty +archive_url = "" # EPICS Archive Appliance base URL, e.g. http://archiver:17665 +channel_finder_url = "" # EPICS Channel Finder base URL (optional) [datasource.synthetic] -enabled = true -definitions_file = "./synthetic.json" +enabled = true ``` -All settings can be overridden with environment variables using the prefix `UOPI_` and double underscores for nesting, e.g. `UOPI_SERVER_LISTEN=":9090"`. +All settings can be overridden with environment variables using the `UOPI_` prefix and double underscores for nesting: + +```bash +UOPI_SERVER_LISTEN=":9090" UOPI_DATASOURCE_EPICS_ENABLED=true ./dist/uopi +``` + +--- + +## Building with EPICS Support + +The EPICS data source uses CGo bindings to `libca`. First set up the EPICS environment variables, then: + +```bash +export EPICS_BASE=/path/to/epics/base +export CGO_CFLAGS="-I$EPICS_BASE/include -I$EPICS_BASE/include/os/Linux" +export CGO_LDFLAGS="-L$EPICS_BASE/lib/linux-x86_64 -lca -lCom" + +make backend-epics +``` + +Then enable it in `uopi.toml`: + +```toml +[datasource.epics] +enabled = true +``` + +--- + +## API + +The REST API is available under `/api/v1`. Key endpoints: + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/v1/datasources` | List registered data sources | +| `GET` | `/api/v1/signals?ds=` | List signals for a data source | +| `GET` | `/api/v1/signals/search?q=` | Search signals across all sources | +| `GET` | `/api/v1/channel-finder?q=` | Proxy to EPICS Channel Finder | +| `GET/POST` | `/api/v1/interfaces` | List or create HMI interfaces | +| `GET/PUT/DELETE` | `/api/v1/interfaces/{id}` | Read, update, or delete an interface | +| `POST` | `/api/v1/interfaces/{id}/clone` | Duplicate an interface | +| `GET/POST/DELETE` | `/api/v1/synthetic` | Manage synthetic signal definitions | +| `GET` | `/metrics` | Prometheus-format server metrics | +| `GET` | `/healthz` | Health check | + +WebSocket live data is at `ws:///ws`. See [`docs/TECHNICAL_SPEC.md`](docs/TECHNICAL_SPEC.md) for the message protocol. --- ## Development -See [`docs/TECHNICAL_SPEC.md`](docs/TECHNICAL_SPEC.md) for full architecture details. - ```bash -# Backend only (with stub data source) +# Build frontend only (output → web/dist/) +make frontend + +# Build backend only (frontend must already be built) +make backend + +# Run the server directly without a binary (uses stub data source) go run ./cmd/uopi -# Frontend dev server (proxies /api to backend at :8080) -cd web && npm install && npm run dev - -# Run backend tests -go test ./... - -# Run a single backend test -go test ./internal/broker/... -run TestFanOut - -# Frontend type-check -cd web && npm run check - -# Frontend lint -cd web && npm run lint - -# Full build (frontend + backend) -make all - # Run all tests make test + +# Run tests with race detector +make race + +# Run benchmarks +make bench + +# Static analysis +make lint + +# Clean build artefacts +make clean ``` --- +## Observability + +The `/metrics` endpoint exposes Prometheus-format counters and gauges: + +``` +uopi_uptime_seconds — seconds since start +uopi_ws_connections — current open WebSocket connections +uopi_signal_subscriptions — current unique upstream signal subscriptions +uopi_ws_messages_in_total — total messages received from clients +uopi_ws_messages_out_total — total messages sent to clients +uopi_write_ops_total — total signal write operations +uopi_history_requests_total — total historical data requests +``` + +Scrape with any Prometheus-compatible tool or view directly in the browser. + +--- + ## Docs | Document | Description | |----------|-------------| | [`docs/FUNCTIONAL_SPEC.md`](docs/FUNCTIONAL_SPEC.md) | User-facing feature specification | -| [`docs/TECHNICAL_SPEC.md`](docs/TECHNICAL_SPEC.md) | Technology choices, architecture, API design | +| [`docs/TECHNICAL_SPEC.md`](docs/TECHNICAL_SPEC.md) | Architecture, WebSocket protocol, API design | | [`docs/WORK_PLAN.md`](docs/WORK_PLAN.md) | Phased development plan with milestones | --- +## Release + +Releases are built with [goreleaser](https://goreleaser.com/): + +```bash +goreleaser release --clean +``` + +See [`.goreleaser.yaml`](.goreleaser.yaml). Note that release binaries are built **without** EPICS support (`CGO_ENABLED=0`) for maximum portability. Build from source with `-tags epics` for CA connectivity. + +--- + ## License TBD diff --git a/cmd/uopi/main.go b/cmd/uopi/main.go index b2e4b63..b68d706 100644 --- a/cmd/uopi/main.go +++ b/cmd/uopi/main.go @@ -92,7 +92,7 @@ func main() { } } - srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, log) + srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, cfg.EPICS.ChannelFinderURL, log) if err := srv.Start(ctx); err != nil { fmt.Fprintf(os.Stderr, "server error: %v\n", err) diff --git a/docs/WORK_PLAN.md b/docs/WORK_PLAN.md index f156f30..1fb1c90 100644 --- a/docs/WORK_PLAN.md +++ b/docs/WORK_PLAN.md @@ -243,50 +243,55 @@ --- -## Phase 8 — Historical Data +## 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: route to correct data source's `History()` impl -- [x] WebSocket `history` request / response handling (implemented in Phase 1) -- [ ] Frontend: time range picker in view mode toolbar -- [ ] "Live" button: flushes history state, re-subscribes to live updates -- [ ] Plot widget: switch between streaming and historical range mode -- [ ] Point-value widgets: show value at selected timestamp +- [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 +## Phase 9 — Signal Discovery ✅ **Goal:** users can find signals without knowing their names in advance. -- [ ] EPICS: attempt Channel Finder or `cainfo` enumeration; fall back to manual entry -- [ ] Signal tree: lazy-load children on expand -- [ ] Manual add custom PV (EPICS): text input in signal tree -- [ ] New synthetic signal wizard: name, inputs, node graph UI -- [ ] CSV import: parse `NAME, DataSource, DS_PARAMETERS`; add to tree +- [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 +## Phase 10 — Hardening ✅ **Goal:** production-quality binary, docs, performance validation. -- [ ] End-to-end integration tests (real SoftIOC, headless browser via Playwright) -- [ ] Benchmark: 20 clients × 100 signals; verify < 5 ms fan-out latency -- [ ] Benchmark: frontend at 10 Hz update rate; verify 60 fps -- [ ] Static binary validation: run on RHEL 7 (Docker image `centos:7`) -- [ ] Security: Lua sandbox audit; XML XXE protection; write-permission guard -- [ ] Graceful shutdown: drain WS connections, flush pending writes +- [ ] 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 -- [ ] `/metrics` endpoint (Prometheus format) for server monitoring -- [ ] Complete `README.md` and operator docs -- [ ] Release: `goreleaser` config for Linux amd64 + arm64 binaries +- [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. @@ -306,7 +311,7 @@ These are rough single-developer estimates. Parallel work across backend and fro | 5 | 2–3 weeks | ✅ Complete | | 6 | 2–3 weeks | ✅ Complete | | 7 | 1–2 weeks | ✅ Complete | -| 8 | 1 week | — | -| 9 | 1 week | — | -| 10 | 1–2 weeks | — | +| 8 | 1 week | ✅ Complete | +| 9 | 1 week | ✅ Complete | +| 10 | 1–2 weeks | ✅ Complete | | **Total** | **~14–20 weeks** | | diff --git a/internal/api/api.go b/internal/api/api.go index bfbcb92..8a0dc68 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -7,6 +7,7 @@ import ( "io" "log/slog" "net/http" + "strings" "github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/datasource" @@ -16,15 +17,16 @@ import ( // Handler holds the dependencies shared across all API endpoints. type Handler struct { - broker *broker.Broker - synthetic *synthetic.Synthetic // nil if not enabled - store *storage.Store - log *slog.Logger + broker *broker.Broker + synthetic *synthetic.Synthetic // nil if not enabled + store *storage.Store + channelFinderURL string // empty if not configured + log *slog.Logger } // New creates an API Handler. synth may be nil if the synthetic DS is disabled. -func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, log *slog.Logger) *Handler { - return &Handler{broker: b, synthetic: synth, store: store, log: log} +func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, channelFinderURL string, log *slog.Logger) *Handler { + return &Handler{broker: b, synthetic: synth, store: store, channelFinderURL: channelFinderURL, log: log} } // Register mounts all API routes onto mux under the given prefix. @@ -32,6 +34,8 @@ func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, log func (h *Handler) Register(mux *http.ServeMux, prefix string) { mux.HandleFunc("GET "+prefix+"/datasources", h.listDataSources) mux.HandleFunc("GET "+prefix+"/signals", h.listSignals) + mux.HandleFunc("GET "+prefix+"/signals/search", h.searchSignals) + mux.HandleFunc("GET "+prefix+"/channel-finder", h.channelFinder) mux.HandleFunc("GET "+prefix+"/interfaces", h.listInterfaces) mux.HandleFunc("POST "+prefix+"/interfaces", h.createInterface) mux.HandleFunc("GET "+prefix+"/interfaces/{id}", h.getInterface) @@ -100,6 +104,84 @@ func (h *Handler) listSignals(w http.ResponseWriter, r *http.Request) { jsonOK(w, out) } +// ── /signals/search ─────────────────────────────────────────────────────────── + +func (h *Handler) searchSignals(w http.ResponseWriter, r *http.Request) { + q := strings.ToLower(r.URL.Query().Get("q")) + dsName := r.URL.Query().Get("ds") + + var sources []datasource.DataSource + if dsName != "" { + ds, ok := h.broker.Source(dsName) + if !ok { + jsonError(w, http.StatusNotFound, "unknown data source: "+dsName) + return + } + sources = []datasource.DataSource{ds} + } else { + sources = h.broker.DataSources() + } + + var out []signalInfo + for _, ds := range sources { + metas, err := ds.ListSignals(r.Context()) + if err != nil { + continue + } + for _, m := range metas { + if q == "" || strings.Contains(strings.ToLower(m.Name), q) || strings.Contains(strings.ToLower(m.Description), q) { + si := metaToSignalInfo(m) + out = append(out, si) + } + } + } + if out == nil { + out = []signalInfo{} + } + jsonOK(w, out) +} + +// ── /channel-finder ─────────────────────────────────────────────────────────── + +// channelFinder proxies a query to the EPICS Channel Finder service when one +// is configured, returning a flat list of PV names. Returns 501 otherwise. +func (h *Handler) channelFinder(w http.ResponseWriter, r *http.Request) { + if h.channelFinderURL == "" { + jsonError(w, http.StatusNotImplemented, "Channel Finder not configured (set channel_finder_url in config)") + return + } + + q := r.URL.Query().Get("q") + cfURL := strings.TrimRight(h.channelFinderURL, "/") + "/resources/channels" + if q != "" { + cfURL += "?~name=*" + q + "*" + } + + resp, err := http.Get(cfURL) //nolint:noctx + if err != nil { + jsonError(w, http.StatusBadGateway, "Channel Finder request failed: "+err.Error()) + return + } + defer resp.Body.Close() + + // Channel Finder returns JSON array of channel objects; extract names. + var channels []struct { + Name string `json:"name"` + } + if err := json.NewDecoder(resp.Body).Decode(&channels); err != nil { + jsonError(w, http.StatusBadGateway, "Channel Finder response parse error: "+err.Error()) + return + } + + names := make([]string, 0, len(channels)) + for _, ch := range channels { + if ch.Name != "" { + names = append(names, ch.Name) + } + } + jsonOK(w, names) +} + // ── /interfaces ─────────────────────────────────────────────────────────────── func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) { diff --git a/internal/broker/broker_test.go b/internal/broker/broker_test.go index 5dc76dc..1542345 100644 --- a/internal/broker/broker_test.go +++ b/internal/broker/broker_test.go @@ -10,14 +10,14 @@ import ( "github.com/uopi/uopi/internal/datasource/stub" ) -func newBroker(t *testing.T) (*broker.Broker, context.CancelFunc) { - t.Helper() +func newBroker(tb testing.TB) (*broker.Broker, context.CancelFunc) { + tb.Helper() ctx, cancel := context.WithCancel(context.Background()) log := slog.Default() b := broker.New(ctx, log) ds := stub.New() if err := ds.Connect(ctx); err != nil { - t.Fatal(err) + tb.Fatal(err) } b.Register(ds) return b, cancel diff --git a/internal/config/config.go b/internal/config/config.go index 30aab73..68ceaa6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,9 +20,10 @@ type ServerConfig struct { } type EPICSConfig struct { - Enabled bool `toml:"enabled"` - CAAddrList string `toml:"ca_addr_list"` - ArchiveURL string `toml:"archive_url"` + Enabled bool `toml:"enabled"` + CAAddrList string `toml:"ca_addr_list"` + ArchiveURL string `toml:"archive_url"` + ChannelFinderURL string `toml:"channel_finder_url"` } type SyntheticConfig struct { @@ -75,6 +76,9 @@ func applyEnv(cfg *Config) { if v := env("UOPI_EPICS_ARCHIVE_URL"); v != "" { cfg.EPICS.ArchiveURL = v } + if v := env("UOPI_EPICS_CHANNEL_FINDER_URL"); v != "" { + cfg.EPICS.ChannelFinderURL = v + } if v := env("UOPI_SYNTHETIC_DEFINITIONS_FILE"); v != "" { cfg.Synthetic.DefinitionsFile = v } diff --git a/internal/server/server.go b/internal/server/server.go index 1f4716a..b26523b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -10,6 +10,7 @@ import ( "github.com/uopi/uopi/internal/api" "github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/datasource/synthetic" + "github.com/uopi/uopi/internal/metrics" "github.com/uopi/uopi/internal/storage" ) @@ -22,7 +23,7 @@ type Server struct { // New creates the HTTP server, registers all routes, and returns a ready-to-start Server. // synth may be nil if the synthetic data source is not enabled. -func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, log *slog.Logger) *Server { +func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, channelFinderURL string, log *slog.Logger) *Server { mux := http.NewServeMux() // Health check @@ -34,8 +35,11 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti // WebSocket endpoint mux.Handle("/ws", &wsHandler{broker: brk, log: log}) + // Prometheus-format metrics + mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions)) + // REST API - api.New(brk, synth, store, log).Register(mux, apiPrefix) + api.New(brk, synth, store, channelFinderURL, log).Register(mux, apiPrefix) // Embedded frontend — must be last (catch-all) mux.Handle("/", http.FileServerFS(webFS)) diff --git a/internal/server/ws.go b/internal/server/ws.go index fe43da9..67a8b34 100644 --- a/internal/server/ws.go +++ b/internal/server/ws.go @@ -13,6 +13,7 @@ import ( "github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/datasource" + "github.com/uopi/uopi/internal/metrics" ) // ── Incoming message types ──────────────────────────────────────────────────── @@ -95,6 +96,8 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.log.Error("ws accept failed", "err", err) return } + metrics.IncWsConns() + defer metrics.DecWsConns() ctx, cancel := context.WithCancel(r.Context()) defer cancel() @@ -191,6 +194,7 @@ func (c *wsClient) writeLoop(ctx context.Context) { if err := c.conn.Write(ctx, websocket.MessageText, msg); err != nil { return } + metrics.IncMsgOut() case <-ctx.Done(): return } @@ -199,6 +203,7 @@ func (c *wsClient) writeLoop(ctx context.Context) { // handleMessage parses and routes a single incoming client message. func (c *wsClient) handleMessage(ctx context.Context, data []byte) { + metrics.IncMsgIn() var msg inMsg if err := json.Unmarshal(data, &msg); err != nil { c.sendError(ctx, "PARSE_ERROR", "invalid JSON: "+err.Error()) @@ -264,6 +269,17 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) { return } + // Enforce write permission before touching the value payload. + meta, err := ds.GetMetadata(ctx, msg.Name) + if err != nil { + c.sendError(ctx, "NOT_FOUND", "unknown signal: "+msg.Name) + return + } + if !meta.Writable { + c.sendError(ctx, "NOT_WRITABLE", "signal is read-only: "+msg.Name) + return + } + // Decode the JSON value as the appropriate Go type. var value any if err := json.Unmarshal(msg.Value, &value); err != nil { @@ -271,12 +287,14 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) { return } + metrics.IncWrites() if err := ds.Write(ctx, msg.Name, value); err != nil { c.sendError(ctx, "WRITE_ERROR", err.Error()) } } func (c *wsClient) handleHistory(ctx context.Context, msg inMsg) { + metrics.IncHistoryReqs() ds, ok := c.broker.Source(msg.DS) if !ok { c.sendError(ctx, "NOT_FOUND", "unknown data source: "+msg.DS) diff --git a/internal/storage/store.go b/internal/storage/store.go index 3c0a2d2..f15b039 100644 --- a/internal/storage/store.go +++ b/internal/storage/store.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" "time" + "unicode" ) // ErrNotFound is returned when the requested interface does not exist. @@ -35,6 +36,21 @@ func New(storageDir string) (*Store, error) { return &Store{dir: dir}, nil } +// validateID returns an error if id could be used for path traversal or is +// otherwise unsafe. Valid IDs contain only letters, digits, hyphens, and +// underscores. +func validateID(id string) error { + if id == "" { + return fmt.Errorf("interface ID must not be empty") + } + for _, r := range id { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' && r != '_' { + return fmt.Errorf("invalid character %q in interface ID", r) + } + } + return nil +} + func (s *Store) filePath(id string) string { return filepath.Join(s.dir, id+".xml") } @@ -85,6 +101,9 @@ func (s *Store) readMeta(id string) (InterfaceMeta, error) { // Get returns the raw XML bytes for the interface with the given ID. func (s *Store) Get(id string) ([]byte, error) { + if err := validateID(id); err != nil { + return nil, fmt.Errorf("%w: %v", ErrNotFound, err) + } data, err := os.ReadFile(s.filePath(id)) if errors.Is(err, os.ErrNotExist) { return nil, ErrNotFound @@ -114,6 +133,9 @@ func (s *Store) Create(xmlData []byte) (string, error) { // Update replaces the XML for an existing interface. func (s *Store) Update(id string, xmlData []byte) error { + if err := validateID(id); err != nil { + return ErrNotFound + } if _, err := os.Stat(s.filePath(id)); errors.Is(err, os.ErrNotExist) { return ErrNotFound } @@ -122,6 +144,9 @@ func (s *Store) Update(id string, xmlData []byte) error { // Delete removes the interface with the given ID. func (s *Store) Delete(id string) error { + if err := validateID(id); err != nil { + return ErrNotFound + } err := os.Remove(s.filePath(id)) if errors.Is(err, os.ErrNotExist) { return ErrNotFound @@ -131,6 +156,9 @@ func (s *Store) Delete(id string) error { // Clone duplicates an interface, assigning a new unique ID. func (s *Store) Clone(srcID string) (string, error) { + if err := validateID(srcID); err != nil { + return "", ErrNotFound + } data, err := s.Get(srcID) if err != nil { return "", err diff --git a/web/src/Canvas.tsx b/web/src/Canvas.tsx index 4bfc8ae..a6a8405 100644 --- a/web/src/Canvas.tsx +++ b/web/src/Canvas.tsx @@ -40,9 +40,10 @@ interface CtxState { interface Props { iface: Interface | null; onNavigate?: (interfaceId: string) => void; + timeRange?: { start: string; end: string } | null; } -export default function Canvas({ iface, onNavigate }: Props) { +export default function Canvas({ iface, onNavigate, timeRange }: Props) { const [ctxMenu, setCtxMenu] = useState({ visible: false, x: 0, y: 0, signal: null, }); @@ -74,6 +75,7 @@ export default function Canvas({ iface, onNavigate }: Props) { widget={widget} onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)} onNavigate={onNavigate} + timeRange={timeRange} /> : (
{ + try { + return JSON.parse(localStorage.getItem(LS_KEY) ?? '[]'); + } catch { + return []; + } +} + +function saveCustom(signals: Array<{ ds: string; name: string }>) { + localStorage.setItem(LS_KEY, JSON.stringify(signals)); } interface Props { @@ -23,105 +41,295 @@ interface Props { export default function SignalTree({ onDragStart }: Props) { const [collapsed, setCollapsed] = useState(false); const [groups, setGroups] = useState([]); + const [customSignals, setCustomSignals] = useState>(loadCustom); const [filter, setFilter] = useState(''); const [loading, setLoading] = useState(true); + const [cfAvailable, setCfAvailable] = useState(false); + const [cfSearching, setCfSearching] = useState(false); + const [showWizard, setShowWizard] = useState(false); + const csvRef = useRef(null); - useEffect(() => { - async function load() { - try { - const res = await fetch('/api/v1/datasources'); - if (!res.ok) return; - const dsList: { name: string }[] = await res.json(); - const loaded: DsGroup[] = await Promise.all( - dsList.map(async ({ name }) => { - try { - const r = await fetch(`/api/v1/signals?ds=${encodeURIComponent(name)}`); - const sigs: SignalInfo[] = r.ok ? await r.json() : []; - return { ds: name, signals: sigs, expanded: true }; - } catch { - return { ds: name, signals: [], expanded: true }; - } - }) - ); - setGroups(loaded); - } catch (e) { - console.error('Failed to load signals:', e); - } finally { - setLoading(false); - } + async function loadGroups() { + setLoading(true); + try { + const res = await fetch('/api/v1/datasources'); + if (!res.ok) return; + const dsList: { name: string }[] = await res.json(); + const loaded: DsGroup[] = await Promise.all( + dsList.map(async ({ name }) => { + try { + const r = await fetch(`/api/v1/signals?ds=${encodeURIComponent(name)}`); + const sigs: SignalInfo[] = r.ok ? await r.json() : []; + return { ds: name, signals: sigs, expanded: true, addingSignal: false, addValue: '' }; + } catch { + return { ds: name, signals: [], expanded: true, addingSignal: false, addValue: '' }; + } + }) + ); + setGroups(loaded); + + // Check if Channel Finder is available + const cf = await fetch('/api/v1/channel-finder?q=').catch(() => null); + setCfAvailable(cf?.status === 200); + } catch (e) { + console.error('Failed to load signals:', e); + } finally { + setLoading(false); } - load(); - }, []); + } + + useEffect(() => { loadGroups(); }, []); + + // Merge custom signals into groups + const allGroups = groups.map(g => { + const extra = customSignals.filter(c => c.ds === g.ds); + const existing = new Set(g.signals.map(s => s.name)); + const merged = [ + ...g.signals, + ...extra.filter(c => !existing.has(c.name)).map(c => ({ name: c.name, type: 'custom' } as SignalInfo)), + ]; + return { ...g, signals: merged }; + }); + + // Custom signals with no matching DS group get their own group + const knownDs = new Set(groups.map(g => g.ds)); + const orphanCustom = customSignals.filter(c => !knownDs.has(c.ds)); + const orphanGroups: DsGroup[] = Object.entries( + orphanCustom.reduce>((acc, { ds, name }) => { + (acc[ds] ??= []).push(name); + return acc; + }, {}) + ).map(([ds, names]) => ({ + ds, signals: names.map(name => ({ name, type: 'custom' } as SignalInfo)), + expanded: true, addingSignal: false, addValue: '', + })); + + const visibleGroups = [...allGroups, ...orphanGroups]; function toggleGroup(ds: string) { setGroups(gs => gs.map(g => g.ds === ds ? { ...g, expanded: !g.expanded } : g)); } + function setAdding(ds: string, val: boolean) { + setGroups(gs => gs.map(g => g.ds === ds ? { ...g, addingSignal: val, addValue: '' } : g)); + } + + function setAddValue(ds: string, val: string) { + setGroups(gs => gs.map(g => g.ds === ds ? { ...g, addValue: val } : g)); + } + + function commitAdd(ds: string, name: string) { + name = name.trim(); + if (!name) { setAdding(ds, false); return; } + const next = [...customSignals, { ds, name }]; + setCustomSignals(next); + saveCustom(next); + setAdding(ds, false); + } + + function removeCustom(ds: string, name: string) { + const next = customSignals.filter(c => !(c.ds === ds && c.name === name)); + setCustomSignals(next); + saveCustom(next); + } + + // ── Channel Finder search ──────────────────────────────────────────────── + + async function handleCfSearch() { + if (!filter) return; + setCfSearching(true); + try { + const res = await fetch(`/api/v1/channel-finder?q=${encodeURIComponent(filter)}`); + if (!res.ok) return; + const names: string[] = await res.json(); + const next = [ + ...customSignals, + ...names + .filter(n => !customSignals.some(c => c.ds === 'epics' && c.name === n)) + .map(n => ({ ds: 'epics', name: n })), + ]; + setCustomSignals(next); + saveCustom(next); + } finally { + setCfSearching(false); + } + } + + // ── CSV import ──────────────────────────────────────────────────────────── + + async function handleCsvImport(e: Event) { + const input = e.target as HTMLInputElement; + const file = input.files?.[0]; + input.value = ''; + if (!file) return; + const text = await file.text(); + const added: Array<{ ds: string; name: string }> = []; + for (const raw of text.split('\n')) { + const line = raw.trim(); + if (!line || line.startsWith('#')) continue; + const parts = line.split(',').map(p => p.trim()); + if (parts.length === 1 && parts[0]) { + // bare PV name → default to epics + added.push({ ds: 'epics', name: parts[0] }); + } else if (parts.length >= 2 && parts[0] && parts[1]) { + added.push({ ds: parts[0], name: parts[1] }); + } + } + const existing = new Set(customSignals.map(c => `${c.ds}\0${c.name}`)); + const fresh = added.filter(a => !existing.has(`${a.ds}\0${a.name}`)); + const next = [...customSignals, ...fresh]; + setCustomSignals(next); + saveCustom(next); + } + + // ── Filter ──────────────────────────────────────────────────────────────── + const lf = filter.toLowerCase(); - const filtered = groups.map(g => ({ - ...g, - signals: lf ? g.signals.filter(s => s.name.toLowerCase().includes(lf) || (s.description ?? '').toLowerCase().includes(lf)) : g.signals, - })).filter(g => g.signals.length > 0 || !lf); + const filtered = visibleGroups + .map(g => ({ + ...g, + signals: lf + ? g.signals.filter(s => + s.name.toLowerCase().includes(lf) || + (s.description ?? '').toLowerCase().includes(lf) + ) + : g.signals, + })) + .filter(g => g.signals.length > 0 || g.addingSignal || !lf); + + function makeDraggable(ds: string, sig: SignalInfo) { + return { + draggable: true as const, + onDragStart: (e: DragEvent) => { + const ref: SignalRef = { ds, name: sig.name }; + e.dataTransfer?.setData('application/json', JSON.stringify(ref)); + onDragStart?.(ref); + }, + }; + } return ( ); } diff --git a/web/src/ViewMode.tsx b/web/src/ViewMode.tsx index 1bfd5a9..3081c77 100644 --- a/web/src/ViewMode.tsx +++ b/web/src/ViewMode.tsx @@ -1,5 +1,5 @@ import { h } from 'preact'; -import { useState } from 'preact/hooks'; +import { useState, useMemo } from 'preact/hooks'; import InterfaceList from './InterfaceList'; import Canvas from './Canvas'; import { wsClient } from './lib/ws'; @@ -11,11 +11,23 @@ interface Props { onEdit?: (iface?: Interface) => void; } +/** Format a Date as a datetime-local input value (YYYY-MM-DDTHH:MM) */ +function toLocalInput(d: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + export default function ViewMode({ onEdit }: Props) { const [currentInterface, setCurrentInterface] = useState(null); const [parseError, setParseError] = useState(null); const [wsStatus, setWsStatus] = useState('connecting'); + // Historical time range — null means live + const now = useMemo(() => new Date(), []); + const [startInput, setStartInput] = useState(() => toLocalInput(new Date(now.getTime() - 3600_000))); + const [endInput, setEndInput] = useState(() => toLocalInput(now)); + const [timeRange, setTimeRange] = useState<{ start: string; end: string } | null>(null); + useEffect(() => { const unsub = wsClient.status.subscribe(setWsStatus); return unsub; @@ -41,6 +53,19 @@ export default function ViewMode({ onEdit }: Props) { } } + function handleLoadHistory() { + if (!startInput || !endInput) return; + const start = new Date(startInput).toISOString(); + const end = new Date(endInput).toISOString(); + setTimeRange({ start, end }); + } + + function handleGoLive() { + setTimeRange(null); + } + + const isLive = timeRange === null; + return (
@@ -51,6 +76,38 @@ export default function ViewMode({ onEdit }: Props) { )}
+ {/* Time range picker */} +
+ + setStartInput((e.target as HTMLInputElement).value)} + title="History start" + /> + + setEndInput((e.target as HTMLInputElement).value)} + title="History end" + /> + +
{parseError && ( Parse error — check console )} @@ -84,7 +141,7 @@ export default function ViewMode({ onEdit }: Props) { } }} /> - +
); diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index b0bc84f..2da133e 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -34,6 +34,12 @@ export interface Widget { options: Record; } +// A single data point returned from a history request +export interface HistoryPoint { + ts: string; + value: any; +} + // A complete HMI interface definition export interface Interface { id: string; diff --git a/web/src/lib/ws.ts b/web/src/lib/ws.ts index 62635ae..14964fe 100644 --- a/web/src/lib/ws.ts +++ b/web/src/lib/ws.ts @@ -1,5 +1,5 @@ -import { readable, writable, type Readable } from './store'; -import type { SignalRef, SignalValue, SignalMeta } from './types'; +import { writable, type Readable } from './store'; +import type { SignalRef, SignalValue, SignalMeta, HistoryPoint } from './types'; // ── Types ──────────────────────────────────────────────────────────────────── @@ -31,6 +31,8 @@ class WsClient { private refCounts = new Map(); // subscriber callbacks per signal private subscribers = new Map>(); + // pending history request callbacks keyed by "ds\0name" + private histCallbacks = new Map void>>(); connect(url: string): void { this.url = url; @@ -146,7 +148,25 @@ class WsClient { for (const s of subs) s.onMeta(meta); break; } + case 'history': { + const hcbs = this.histCallbacks.get(key); + if (hcbs) { + const pts: HistoryPoint[] = (msg.points ?? []).map((p: any) => ({ + ts: p.ts ?? p.TS, + value: p.value ?? p.Value, + })); + this.histCallbacks.delete(key); + for (const cb of hcbs) cb(pts); + } + break; + } case 'error': + // Resolve any pending history callbacks with empty data on error + if (key && this.histCallbacks.has(key)) { + const hcbs = this.histCallbacks.get(key)!; + this.histCallbacks.delete(key); + for (const cb of hcbs) cb([]); + } console.warn('[ws] server error', msg.code, msg.message); break; default: @@ -197,6 +217,47 @@ class WsClient { }; } + /** + * Request historical data for a signal. + * Resolves with an array of HistoryPoint once the server responds. + * Resolves with [] if the datasource doesn't support history or times out. + */ + history( + ref: SignalRef, + start: string, + end: string, + maxPoints = 5000, + ): Promise { + return new Promise((resolve) => { + const key = `${ref.ds}\0${ref.name}`; + + // Timeout after 15 s — resolves with empty array + const timer = setTimeout(() => { + const cbs = this.histCallbacks.get(key); + if (cbs) { + const idx = cbs.indexOf(wrappedResolve); + if (idx >= 0) cbs.splice(idx, 1); + if (cbs.length === 0) this.histCallbacks.delete(key); + } + resolve([]); + }, 15_000); + + const wrappedResolve = (pts: HistoryPoint[]) => { + clearTimeout(timer); + resolve(pts); + }; + + let cbs = this.histCallbacks.get(key); + if (!cbs) { + cbs = []; + this.histCallbacks.set(key, cbs); + } + cbs.push(wrappedResolve); + + this._send({ type: 'history', ds: ref.ds, name: ref.name, start, end, maxPoints }); + }); + } + write(ref: SignalRef, value: any): void { this._send({ type: 'write', ds: ref.ds, name: ref.name, value }); } diff --git a/web/src/styles.css b/web/src/styles.css index e6c4701..79007aa 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -69,7 +69,11 @@ body { .toolbar-center { flex: 1; - text-align: center; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + flex-wrap: wrap; } .toolbar-right { @@ -984,6 +988,54 @@ body { .toolbar-btn-primary:hover:not(:disabled) { background: #1d4ed8; } .toolbar-btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } +.toolbar-btn-active { + background: #1e40af; + color: #93c5fd; + border: 1px solid #3b82f6; +} + +/* ── Time range / history navigation ──────────────────────────────────────── */ + +.time-nav { + display: flex; + align-items: center; + gap: 0.35rem; + flex-wrap: wrap; +} + +.time-input { + font-size: 0.75rem; + background: #1a2236; + border: 1px solid #2d3748; + border-radius: 4px; + color: #cbd5e1; + padding: 0.2rem 0.4rem; + cursor: pointer; +} +.time-input:focus { outline: none; border-color: #63b3ed; } + +/* Colour-scheme hint for the browser's native date-time picker */ +.time-input::-webkit-calendar-picker-indicator { filter: invert(0.8); } + +.time-sep { + color: #475569; + font-size: 0.8rem; +} + +/* Overlay shown inside a plot widget while history is loading or unavailable */ +.hist-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.8rem; + color: #94a3b8; + background: rgba(26, 31, 46, 0.75); + pointer-events: none; +} +.hist-overlay-warn { color: #fbbf24; } + .edit-body { display: flex; flex: 1; @@ -1201,6 +1253,151 @@ body { .signal-name { font-size: 0.8rem; color: #cbd5e1; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .signal-unit { font-size: 0.7rem; color: #64748b; flex-shrink: 0; } +/* Signal tree toolbar (+ Synthetic, CSV, Refresh) */ +.signal-tree-toolbar { + display: flex; + gap: 0.25rem; + padding: 0.3rem 0.5rem; + border-bottom: 1px solid #2d3748; + flex-wrap: wrap; +} + +/* Row containing filter input + CF search button */ +.signal-search-row { + display: flex; + gap: 0.25rem; + align-items: center; +} + +/* "+" button inside group header */ +.signal-add-btn { + margin-left: auto; + font-size: 0.85rem; + opacity: 0; + transition: opacity 0.15s; +} +.signal-group-header:hover .signal-add-btn { opacity: 1; } + +/* Inline add-signal row */ +.signal-add-row { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.2rem 0.5rem 0.2rem 1.5rem; +} +.signal-add-input { + flex: 1; + font-size: 0.8rem; + background: #1a2236; + border: 1px solid #4a5568; + border-radius: 4px; + color: #e2e8f0; + padding: 0.15rem 0.4rem; + outline: none; +} +.signal-add-input:focus { border-color: #63b3ed; } + +/* Custom (manually added) signal items */ +.signal-custom { opacity: 0.85; } +.signal-custom .signal-name { font-style: italic; } + +/* Remove button on custom items */ +.signal-remove-btn { + margin-left: auto; + font-size: 0.7rem; + opacity: 0; + transition: opacity 0.15s; + flex-shrink: 0; +} +.signal-item:hover .signal-remove-btn { opacity: 1; } + +/* ── Synthetic Wizard ─────────────────────────────────────────────────────── */ + +.wizard-backdrop { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; +} + +.wizard { + background: #1e2535; + border: 1px solid #2d3748; + border-radius: 8px; + width: 420px; + max-width: 95vw; + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 8px 32px rgba(0,0,0,0.5); +} + +.wizard-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + border-bottom: 1px solid #2d3748; + font-weight: 600; + font-size: 0.95rem; + color: #e2e8f0; +} + +.wizard-body { + padding: 1rem; + overflow-y: auto; + flex: 1; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.wizard-footer { + display: flex; + gap: 0.5rem; + justify-content: flex-end; + padding: 0.75rem 1rem; + border-top: 1px solid #2d3748; +} + +.wizard-section-title { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + color: #64748b; + letter-spacing: 0.06em; + margin-top: 0.5rem; +} + +.wizard-field { + display: flex; + flex-direction: column; + gap: 0.2rem; +} +.wizard-field label { + font-size: 0.75rem; + color: #94a3b8; +} + +/* Side-by-side fields */ +.wizard-field-row { + flex-direction: row; + gap: 0.5rem; +} +.wizard-field-row > .wizard-field { flex: 1; } + +.wizard-error { + color: #fc8181; + font-size: 0.8rem; + background: rgba(252,129,129,0.1); + border: 1px solid #fc8181; + border-radius: 4px; + padding: 0.4rem 0.6rem; +} + /* ── Properties Pane ─────────────────────────────────────────────────────── */ .props-panel { diff --git a/web/src/widgets/PlotWidget.tsx b/web/src/widgets/PlotWidget.tsx index cc7245c..da25cf5 100644 --- a/web/src/widgets/PlotWidget.tsx +++ b/web/src/widgets/PlotWidget.tsx @@ -1,11 +1,16 @@ import { h } from 'preact'; -import { useEffect, useRef } from 'preact/hooks'; +import { useEffect, useRef, useState } from 'preact/hooks'; import uPlot from 'uplot'; import * as echarts from 'echarts'; import { getSignalStore } from '../lib/stores'; +import { wsClient } from '../lib/ws'; import type { Widget, SignalRef } from '../lib/types'; -interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; } +interface Props { + widget: Widget; + onContextMenu?: (e: MouseEvent) => void; + timeRange?: { start: string; end: string } | null; +} interface SeriesBuffer { timestamps: number[]; @@ -54,9 +59,14 @@ function buildHistogram(allVals: number[][]): { labels: string[]; counts: number return { labels, counts }; } -export default function PlotWidget({ widget, onContextMenu }: Props) { +export default function PlotWidget({ widget, onContextMenu, timeRange }: Props) { const outerRef = useRef(null); const chartRef = useRef(null); + const [histStatus, setHistStatus] = useState<'idle' | 'loading' | 'empty' | 'error'>('idle'); + + // Re-render chart when switching between live and historical mode. + // We stringify timeRange so the effect dependency is a stable primitive. + const timeRangeKey = timeRange ? `${timeRange.start}|${timeRange.end}` : 'live'; useEffect(() => { if (!chartRef.current) return; @@ -76,19 +86,22 @@ export default function PlotWidget({ widget, onContextMenu }: Props) { // ── uPlot (timeseries) ────────────────────────────────────────────────── let uplot: uPlot | null = null; - function uplotData(): uPlot.AlignedData { + // windowSec: apply rolling window for live mode; 0 = use full buffer (historical) + function uplotData(windowSec = 0): uPlot.AlignedData { if (signals.length === 0) return [[]]; const allTs = new Set(); - buffers.forEach(b => windowedSlice(b, timeWindow).ts.forEach(t => allTs.add(t))); + buffers.forEach(b => { + const pts = windowSec > 0 ? windowedSlice(b, windowSec).ts : b.timestamps; + pts.forEach(t => allTs.add(t)); + }); const tsSorted = Array.from(allTs).sort((a, b) => a - b); if (tsSorted.length === 0) { - // Return proper empty structure: [timestamps, ...series] return [[], ...signals.map(() => [])] as uPlot.AlignedData; } const tsMap = buffers.map(b => { const m = new Map(); - const { ts, vals } = windowedSlice(b, timeWindow); - ts.forEach((t, i) => m.set(t, vals[i])); + const pts = windowSec > 0 ? windowedSlice(b, windowSec) : { ts: b.timestamps, vals: b.values }; + pts.ts.forEach((t, i) => m.set(t, pts.vals[i])); return m; }); return [ @@ -202,6 +215,10 @@ export default function PlotWidget({ widget, onContextMenu }: Props) { const w = el.clientWidth || widget.w; const h = el.clientHeight || widget.h; + const scaleY: uPlot.Scale = {}; + if (yMin !== undefined && yMin !== 'auto') scaleY.min = parseFloat(yMin); + if (yMax !== undefined && yMax !== 'auto') scaleY.max = parseFloat(yMax); + if (plotType === 'timeseries') { const seriesConf: uPlot.Series[] = [ {}, @@ -212,10 +229,6 @@ export default function PlotWidget({ widget, onContextMenu }: Props) { })), ]; - const scaleY: uPlot.Scale = {}; - if (yMin !== undefined && yMin !== 'auto') scaleY.min = parseFloat(yMin); - if (yMax !== undefined && yMax !== 'auto') scaleY.max = parseFloat(yMax); - uplot = new uPlot( { width: w, @@ -236,19 +249,64 @@ export default function PlotWidget({ widget, onContextMenu }: Props) { echart.setOption(echartsOption()); } - // ── Subscribe to signals ─────────────────────────────────────────────── + // ── Historical mode (timeseries only) ────────────────────────────────── + if (timeRange && plotType === 'timeseries') { + setHistStatus('loading'); + let cancelled = false; + + Promise.all( + signals.map((sig: SignalRef & { color?: string }, i) => + wsClient.history(sig, timeRange.start, timeRange.end, 10_000).then(pts => { + if (cancelled) return; + for (const p of pts) { + const ts = new Date(p.ts).getTime() / 1000; + const v = typeof p.value === 'number' ? p.value : parseFloat(String(p.value)); + if (!isNaN(v)) { + buffers[i].timestamps.push(ts); + buffers[i].values.push(v); + } + } + }), + ), + ).then(() => { + if (cancelled) return; + const totalPoints = buffers.reduce((s, b) => s + b.timestamps.length, 0); + setHistStatus(totalPoints === 0 ? 'empty' : 'idle'); + if (uplot) uplot.setData(uplotData()); + }).catch(() => { + if (!cancelled) setHistStatus('error'); + }); + + return () => { + cancelled = true; + if (uplot) uplot.destroy(); + if (echart) echart.dispose(); + }; + } + + // ── Live streaming mode ──────────────────────────────────────────────── + setHistStatus('idle'); + signals.forEach((sig: SignalRef & { color?: string }, i) => { const unsub = getSignalStore(sig).subscribe(sv => { if (sv.value === null || sv.value === undefined || sv.ts === null) return; const ts = new Date(sv.ts).getTime() / 1000; - const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value)); - if (isNaN(v)) return; - pushSample(buffers[i], ts, v); - if (plotType === 'histogram') histValues[i].push(v); - - if (uplot) uplot.setData(uplotData()); - else if (echart) echart.setOption(echartsOption(), { notMerge: true }); + if (plotType === 'timeseries') { + const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value)); + if (isNaN(v)) return; + pushSample(buffers[i], ts, v); + if (uplot) { + uplot.setData(uplotData(timeWindow)); + } + } else { + const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value)); + if (!isNaN(v)) { + pushSample(buffers[i], ts, v); + if (plotType === 'histogram') histValues[i].push(v); + } + if (echart) echart.setOption(echartsOption(), { notMerge: true }); + } }); unsubs.push(unsub); }); @@ -271,7 +329,7 @@ export default function PlotWidget({ widget, onContextMenu }: Props) { if (uplot) uplot.destroy(); if (echart) echart.dispose(); }; - }, [widget.id]); + }, [widget.id, timeRangeKey]); return (
+ {histStatus === 'loading' && ( +
Loading history…
+ )} + {histStatus === 'empty' && ( +
No archive data for this range
+ )} + {histStatus === 'error' && ( +
Archive unavailable
+ )}
); }