phase 10
This commit is contained in:
@@ -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 ./...
|
||||
|
||||
|
||||
@@ -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=<name>` | List signals for a data source |
|
||||
| `GET` | `/api/v1/signals/search?q=<query>` | Search signals across all sources |
|
||||
| `GET` | `/api/v1/channel-finder?q=<query>` | 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://<host>/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
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+32
-27
@@ -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** | |
|
||||
|
||||
+88
-6
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-1
@@ -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<CtxState>({
|
||||
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}
|
||||
/>
|
||||
: (
|
||||
<div
|
||||
|
||||
+276
-68
@@ -1,19 +1,37 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||
import type { SignalRef } from './lib/types';
|
||||
import SyntheticWizard from './SyntheticWizard';
|
||||
|
||||
interface SignalInfo {
|
||||
name: string;
|
||||
type: string;
|
||||
type?: string;
|
||||
unit?: string;
|
||||
description?: string;
|
||||
writable: boolean;
|
||||
writable?: boolean;
|
||||
}
|
||||
|
||||
interface DsGroup {
|
||||
ds: string;
|
||||
signals: SignalInfo[];
|
||||
expanded: boolean;
|
||||
addingSignal: boolean; // show add-PV input
|
||||
addValue: string;
|
||||
}
|
||||
|
||||
// Custom signals persisted in localStorage
|
||||
const LS_KEY = 'uopi:custom-signals';
|
||||
|
||||
function loadCustom(): Array<{ ds: string; name: string }> {
|
||||
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<DsGroup[]>([]);
|
||||
const [customSignals, setCustomSignals] = useState<Array<{ ds: string; name: string }>>(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<HTMLInputElement>(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<Record<string, string[]>>((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 (
|
||||
<aside class={`panel signal-tree-panel${collapsed ? ' collapsed' : ''}`}>
|
||||
<div class="panel-header">
|
||||
{!collapsed && <span class="panel-title">Signals</span>}
|
||||
<button
|
||||
class="icon-btn"
|
||||
onClick={() => setCollapsed(c => !c)}
|
||||
title={collapsed ? 'Expand' : 'Collapse'}
|
||||
>
|
||||
<button class="icon-btn" onClick={() => setCollapsed(c => !c)} title={collapsed ? 'Expand' : 'Collapse'}>
|
||||
{collapsed ? '▶' : '◀'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div class="signal-tree-body">
|
||||
{/* Search / filter bar */}
|
||||
<div class="signal-tree-search">
|
||||
<input
|
||||
class="signal-filter"
|
||||
type="search"
|
||||
placeholder="Filter signals…"
|
||||
value={filter}
|
||||
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<div class="signal-search-row">
|
||||
<input
|
||||
class="signal-filter"
|
||||
type="search"
|
||||
placeholder="Filter signals…"
|
||||
value={filter}
|
||||
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
{cfAvailable && filter && (
|
||||
<button class="icon-btn" title="Search Channel Finder" onClick={handleCfSearch} disabled={cfSearching}>
|
||||
{cfSearching ? '…' : '🔍'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar: New Synthetic | Import CSV | Refresh */}
|
||||
<div class="signal-tree-toolbar">
|
||||
<button class="panel-btn" title="Create synthetic signal" onClick={() => setShowWizard(true)}>+ Synthetic</button>
|
||||
<button class="panel-btn" title="Import CSV (ds,name or bare PV names)" onClick={() => csvRef.current?.click()}>CSV</button>
|
||||
<button class="panel-btn" title="Refresh signal list" onClick={loadGroups}>↺</button>
|
||||
<input ref={csvRef} type="file" accept=".csv,text/csv" style="display:none" onChange={handleCsvImport} />
|
||||
</div>
|
||||
|
||||
{/* Signal groups */}
|
||||
<div class="panel-list signal-list">
|
||||
{loading ? (
|
||||
<p class="hint">Loading signals…</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p class="hint">No signals found.</p>
|
||||
) : (
|
||||
filtered.map(group => (
|
||||
<div key={group.ds} class="signal-group">
|
||||
<div class="signal-group-header" onClick={() => toggleGroup(group.ds)}>
|
||||
<span class="signal-group-arrow">{group.expanded ? '▾' : '▸'}</span>
|
||||
<span class="signal-group-name">{group.ds}</span>
|
||||
<span class="signal-group-count">{group.signals.length}</span>
|
||||
</div>
|
||||
{group.expanded && group.signals.map(sig => (
|
||||
<div
|
||||
key={sig.name}
|
||||
class="signal-item"
|
||||
draggable
|
||||
title={`${group.ds}:${sig.name}${sig.unit ? ` [${sig.unit}]` : ''}${sig.description ? ` — ${sig.description}` : ''}`}
|
||||
onDragStart={(e: DragEvent) => {
|
||||
const ref: SignalRef = { ds: group.ds, name: sig.name };
|
||||
e.dataTransfer?.setData('application/json', JSON.stringify(ref));
|
||||
onDragStart?.(ref);
|
||||
}}
|
||||
>
|
||||
<span class="signal-name">{sig.name}</span>
|
||||
{sig.unit && <span class="signal-unit">{sig.unit}</span>}
|
||||
filtered.map(group => {
|
||||
const groupInGroups = groups.find(g => g.ds === group.ds);
|
||||
return (
|
||||
<div key={group.ds} class="signal-group">
|
||||
<div class="signal-group-header">
|
||||
<span class="signal-group-arrow" onClick={() => toggleGroup(group.ds)}>
|
||||
{group.expanded ? '▾' : '▸'}
|
||||
</span>
|
||||
<span class="signal-group-name" onClick={() => toggleGroup(group.ds)}>
|
||||
{group.ds}
|
||||
</span>
|
||||
<span class="signal-group-count">{group.signals.length}</span>
|
||||
<button
|
||||
class="icon-btn signal-add-btn"
|
||||
title={`Add custom signal to ${group.ds}`}
|
||||
onClick={() => setAdding(group.ds, true)}
|
||||
>+</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
|
||||
{group.expanded && (
|
||||
<div>
|
||||
{group.signals.map(sig => {
|
||||
const isCustom = !groups.find(g => g.ds === group.ds)?.signals.some(s => s.name === sig.name);
|
||||
return (
|
||||
<div
|
||||
key={sig.name}
|
||||
class={`signal-item${isCustom ? ' signal-custom' : ''}`}
|
||||
title={`${group.ds}:${sig.name}${sig.unit ? ` [${sig.unit}]` : ''}${sig.description ? ` — ${sig.description}` : ''}`}
|
||||
{...makeDraggable(group.ds, sig)}
|
||||
>
|
||||
<span class="signal-name">{sig.name}</span>
|
||||
{sig.unit && <span class="signal-unit">{sig.unit}</span>}
|
||||
{isCustom && (
|
||||
<button
|
||||
class="icon-btn signal-remove-btn"
|
||||
title="Remove custom signal"
|
||||
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
|
||||
onClick={(e: MouseEvent) => { e.stopPropagation(); removeCustom(group.ds, sig.name); }}
|
||||
>✕</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Add-signal input row */}
|
||||
{(groupInGroups?.addingSignal) && (
|
||||
<div class="signal-add-row">
|
||||
<input
|
||||
class="signal-add-input"
|
||||
placeholder="Signal / PV name…"
|
||||
autoFocus
|
||||
value={groupInGroups?.addValue ?? ''}
|
||||
onInput={(e) => setAddValue(group.ds, (e.target as HTMLInputElement).value)}
|
||||
onKeyDown={(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') commitAdd(group.ds, (e.target as HTMLInputElement).value);
|
||||
if (e.key === 'Escape') setAdding(group.ds, false);
|
||||
}}
|
||||
/>
|
||||
<button class="icon-btn" onClick={() => commitAdd(group.ds, groupInGroups?.addValue ?? '')}>✓</button>
|
||||
<button class="icon-btn" onClick={() => setAdding(group.ds, false)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showWizard && (
|
||||
<SyntheticWizard
|
||||
onClose={() => setShowWizard(false)}
|
||||
onCreated={loadGroups}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
+59
-2
@@ -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<Interface | null>(null);
|
||||
const [parseError, setParseError] = useState<string | null>(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 (
|
||||
<div class="view-mode">
|
||||
<header class="toolbar">
|
||||
@@ -51,6 +76,38 @@ export default function ViewMode({ onEdit }: Props) {
|
||||
)}
|
||||
</div>
|
||||
<div class="toolbar-center">
|
||||
{/* Time range picker */}
|
||||
<div class="time-nav">
|
||||
<button
|
||||
class={`toolbar-btn${isLive ? ' toolbar-btn-active' : ''}`}
|
||||
title="Switch to live data"
|
||||
onClick={handleGoLive}
|
||||
>
|
||||
● Live
|
||||
</button>
|
||||
<input
|
||||
class="time-input"
|
||||
type="datetime-local"
|
||||
value={startInput}
|
||||
onInput={(e) => setStartInput((e.target as HTMLInputElement).value)}
|
||||
title="History start"
|
||||
/>
|
||||
<span class="time-sep">→</span>
|
||||
<input
|
||||
class="time-input"
|
||||
type="datetime-local"
|
||||
value={endInput}
|
||||
onInput={(e) => setEndInput((e.target as HTMLInputElement).value)}
|
||||
title="History end"
|
||||
/>
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
title="Load historical data for this time range"
|
||||
onClick={handleLoadHistory}
|
||||
>
|
||||
Load
|
||||
</button>
|
||||
</div>
|
||||
{parseError && (
|
||||
<span class="parse-error" title={parseError}>Parse error — check console</span>
|
||||
)}
|
||||
@@ -84,7 +141,7 @@ export default function ViewMode({ onEdit }: Props) {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Canvas iface={currentInterface} onNavigate={handleNavigate} />
|
||||
<Canvas iface={currentInterface} onNavigate={handleNavigate} timeRange={timeRange} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -34,6 +34,12 @@ export interface Widget {
|
||||
options: Record<string, string>;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
+63
-2
@@ -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<string, number>();
|
||||
// subscriber callbacks per signal
|
||||
private subscribers = new Map<string, Set<SubscriberEntry>>();
|
||||
// pending history request callbacks keyed by "ds\0name"
|
||||
private histCallbacks = new Map<string, Array<(pts: HistoryPoint[]) => 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<HistoryPoint[]> {
|
||||
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 });
|
||||
}
|
||||
|
||||
+198
-1
@@ -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 {
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const chartRef = useRef<HTMLDivElement>(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<number>();
|
||||
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<number, number>();
|
||||
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 (
|
||||
<div
|
||||
@@ -281,6 +339,15 @@ export default function PlotWidget({ widget, onContextMenu }: Props) {
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<div class="chart-area" ref={chartRef} style={`width:${widget.w}px;height:${widget.h}px;`} />
|
||||
{histStatus === 'loading' && (
|
||||
<div class="hist-overlay">Loading history…</div>
|
||||
)}
|
||||
{histStatus === 'empty' && (
|
||||
<div class="hist-overlay hist-overlay-warn">No archive data for this range</div>
|
||||
)}
|
||||
{histStatus === 'error' && (
|
||||
<div class="hist-overlay hist-overlay-warn">Archive unavailable</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user