From 71430bc3b049fb1abd6160fd2337f31c307d2347 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Thu, 21 May 2026 07:41:56 +0200 Subject: [PATCH] Wworking on improving the tool --- cmd/uopi/main.go | 11 +- debug_api.go | 32 ++ docs/ITER_GUIDE.md | 86 ++++ internal/api/api.go | 81 ++- internal/api/api_test.go | 2 +- internal/config/config.go | 10 +- internal/datasource/epics/archive.go | 50 ++ internal/datasource/epics/cf_client.go | 67 +++ internal/datasource/epics/discovery_test.go | 82 +++ internal/datasource/epics/epics.go | 106 +++- .../datasource/epics/epics_purego_test.go | 2 +- internal/datasource/epics/noop.go | 132 ++++- internal/datasource/iface.go | 2 + internal/server/server.go | 4 +- internal/server/ws.go | 22 +- internal/storage/store.go | 104 +++- tools/buildfrontend/main.go | 2 + uopi.example.toml | 8 +- web/src/ChannelFinderModal.tsx | 267 ++++++++++ web/src/EditMode.tsx | 31 ++ web/src/HelpModal.tsx | 2 + web/src/InfoPanel.tsx | 31 ++ web/src/InterfaceList.tsx | 7 +- web/src/PropertiesPane.tsx | 2 +- web/src/SignalTree.tsx | 469 ++++++++++-------- web/src/SyntheticEditor.tsx | 188 ++++++- web/src/SyntheticWizard.tsx | 170 ++++++- web/src/lib/types.ts | 29 ++ web/src/styles.css | 133 +++++ workspace/softioc/uopi_test.db | 19 + 30 files changed, 1820 insertions(+), 331 deletions(-) create mode 100644 debug_api.go create mode 100644 docs/ITER_GUIDE.md create mode 100644 internal/datasource/epics/cf_client.go create mode 100644 internal/datasource/epics/discovery_test.go create mode 100644 web/src/ChannelFinderModal.tsx diff --git a/cmd/uopi/main.go b/cmd/uopi/main.go index 92ec7cf..78469b5 100644 --- a/cmd/uopi/main.go +++ b/cmd/uopi/main.go @@ -88,7 +88,14 @@ func main() { // The pure-Go implementation is used by default (no CGo required). // Build with -tags epics to use the CGo-based libca implementation instead. if cfg.Datasource.EPICS.Enabled && epics.Available() { - ds := epics.New(cfg.Datasource.EPICS.CAAddrList, cfg.Datasource.EPICS.ArchiveURL, cfg.Datasource.EPICS.PVNames) + ds := epics.New( + cfg.Datasource.EPICS.CAAddrList, + cfg.Datasource.EPICS.ArchiveURL, + cfg.Datasource.EPICS.ChannelFinderURL, + cfg.Datasource.EPICS.AutoSyncFilter, + cfg.Datasource.EPICS.AutoSyncFromArchiver, + cfg.Datasource.EPICS.PVNames, + ) if err := ds.Connect(ctx); err != nil { log.Error("epics connect", "err", err) } else { @@ -106,7 +113,7 @@ func main() { } } - srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, cfg.Datasource.EPICS.ChannelFinderURL, log) + srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, log) if err := srv.Start(ctx); err != nil { fmt.Fprintf(os.Stderr, "server error: %v\n", err) diff --git a/debug_api.go b/debug_api.go new file mode 100644 index 0000000..3d50ac1 --- /dev/null +++ b/debug_api.go @@ -0,0 +1,32 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "os" + + "github.com/uopi/uopi/internal/api" + "github.com/uopi/uopi/internal/broker" + "github.com/uopi/uopi/internal/storage" +) + +func main() { + log := slog.New(slog.NewTextHandler(os.Stderr, nil)) + store, _ := storage.New("./interfaces") + // broker.New(ctx, log, maxRate) + brk := broker.New(context.Background(), log, 0) + + h := api.New(brk, nil, store, "", "", log) + mux := http.NewServeMux() + h.Register(mux, "/api/v1") + + req := httptest.NewRequest("GET", "/api/v1/interfaces", nil) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + fmt.Printf("Status: %d\n", rr.Code) + fmt.Printf("Body: %s\n", rr.Body.String()) +} diff --git a/docs/ITER_GUIDE.md b/docs/ITER_GUIDE.md new file mode 100644 index 0000000..aa7b932 --- /dev/null +++ b/docs/ITER_GUIDE.md @@ -0,0 +1,86 @@ +# Guide: Configuring uopi for ITER CS-Studio + +This guide explains how to configure **uopi** to work within the **ITER CODAC (Control System Studio / Phoebus)** environment. Since uopi is a single portable binary, it can be deployed alongside or as a lightweight web-based alternative to standard CS-Studio screens. + +--- + +## 1. Prerequisite Information + +To connect uopi to the ITER control system, you need three pieces of information usually found in your standard CODAC environment or terminal: + +| Info Needed | Purpose | Where to find it in ITER | +| :--- | :--- | :--- | +| **CA Addr List** | PV Discovery via UDP | Run `echo $EPICS_CA_ADDR_LIST` in a terminal. | +| **Archiver URL** | Historical Trends | Check your CS-Studio preferences under *Archive Appliance* (usually `http://arch-mgmt:17665/`). | +| **CFS URL** | Automatic PV Search | Check CS-Studio preferences under *Channel Finder* (usually `http://cf-service:8080/ChannelFinder`). | + +--- + +## 2. Configuration (`uopi.toml`) + +Create a file named `uopi.toml` in the directory where you will run uopi. Use the values found above: + +```toml +[server] +listen = ":8080" # The port uopi will serve the web HMI on +storage_dir = "./interfaces" # Where your XML screens will be saved + +[datasource.epics] +enabled = true +# Paste the output of 'echo $EPICS_CA_ADDR_LIST' here +ca_addr_list = "10.0.x.x 10.0.x.255" +# ITER Archive Appliance URL +archive_url = "http://arch-mgmt:17665/" +# ITER Channel Finder URL +channel_finder_url = "http://cf-service:8080/ChannelFinder" + +# Automatic discovery on startup +auto_sync_from_archiver = true +auto_sync_filter = "tags=production" +``` + +--- + +## 3. Deployment in the ITER Environment + +### A. Running via Terminal +If you are on a CODAC workstation or a server with network access to the Plant System: +1. Copy the `uopi` binary to the machine. +2. Run it pointing to your config: + ```bash + ./uopi -config uopi.toml + ``` +3. Open a browser and navigate to `http://:8080`. + +### B. Offline Usage (No Internet) +uopi is built with **zero external dependencies**. All scripts and the favicon are embedded. No `npm` or `curl` calls are made at runtime. It will function perfectly on isolated ITER networks. + +--- + +## 4. Discovering ITER PVs + +Once uopi is running: + +1. **Automatic Discovery:** If `auto_sync_from_archiver` is set to `true`, the sidebar will be pre-populated with thousands of ITER PVs on boot. +2. **Smart Grouping:** Click the **"Group By"** dropdown in the sidebar. Select **"Area"** or **"System"**. uopi will use the metadata from the ITER Channel Finder to organize PVs into folders (e.g., `Vacuum`, `Cryogenics`, `PowerSupply`). +3. **Advanced Search:** + * Click the **πŸ” (Advanced Search)** icon in the sidebar. + * Use the **Channel Finder** tab to find signals by tags (e.g., `interlock`) or properties (e.g., `iocName`). + * Use the **Archive Appliance** tab to perform a glob search (e.g., `*TEMP*`) directly against the ITER Archiver database. + +--- + +## 5. Transitioning from CS-Studio (`.bob` / `.opi`) + +uopi uses a custom XML format for its screens, but the layout logic is similar to Phoebus/CS-Studio. + +* **Signals:** Just like in CS-Studio, you can drag PVs from the sidebar directly onto the canvas to create widgets. +* **Historical Data:** Any PV discovered via the Archiver tab or configured with the Archiver URL will automatically show historical data when added to a **Plot Widget**. +* **Synthetic Signals:** You can create complex ITER-specific calculations (e.g., `R = V/I`) by clicking the **Ξ£** icon and combining multiple EPICS PVs into a single virtual signal. + +--- + +## 6. Troubleshooting + +* **Timeout Error:** If `caget` times out in uopi, verify that `ca_addr_list` includes the correct broadcast address for your ITER subnet (e.g., ends in `.255`). +* **Metadata Missing:** If PVs show up but have no units/descriptions, ensure the `channel_finder_url` is reachable from the uopi binary. diff --git a/internal/api/api.go b/internal/api/api.go index ef21f6a..cfd517f 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -7,6 +7,7 @@ import ( "io" "log/slog" "net/http" + "net/url" "strings" "github.com/uopi/uopi/internal/broker" @@ -21,12 +22,20 @@ type Handler struct { synthetic *synthetic.Synthetic // nil if not enabled store *storage.Store channelFinderURL string // empty if not configured + archiverURL 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, channelFinderURL string, log *slog.Logger) *Handler { - return &Handler{broker: b, synthetic: synth, store: store, channelFinderURL: channelFinderURL, log: log} +func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, channelFinderURL, archiverURL string, log *slog.Logger) *Handler { + return &Handler{ + broker: b, + synthetic: synth, + store: store, + channelFinderURL: channelFinderURL, + archiverURL: archiverURL, + log: log, + } } // Register mounts all API routes onto mux under the given prefix. @@ -36,6 +45,7 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) { 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+"/archiver/search", h.archiverSearch) mux.HandleFunc("GET "+prefix+"/interfaces", h.listInterfaces) mux.HandleFunc("POST "+prefix+"/interfaces", h.createInterface) mux.HandleFunc("GET "+prefix+"/interfaces/{id}", h.getInterface) @@ -72,14 +82,16 @@ func (h *Handler) listDataSources(w http.ResponseWriter, r *http.Request) { // ── /signals ────────────────────────────────────────────────────────────────── type signalInfo struct { - Name string `json:"name"` - Type string `json:"type"` - Unit string `json:"unit,omitempty"` - Description string `json:"description,omitempty"` - DisplayLow float64 `json:"displayLow"` - DisplayHigh float64 `json:"displayHigh"` - Writable bool `json:"writable"` - EnumStrings []string `json:"enumStrings,omitempty"` + Name string `json:"name"` + Type string `json:"type"` + Unit string `json:"unit,omitempty"` + Description string `json:"description,omitempty"` + DisplayLow float64 `json:"displayLow"` + DisplayHigh float64 `json:"displayHigh"` + Writable bool `json:"writable"` + EnumStrings []string `json:"enumStrings,omitempty"` + Properties map[string]string `json:"properties,omitempty"` + Tags []string `json:"tags,omitempty"` } func (h *Handler) listSignals(w http.ResponseWriter, r *http.Request) { @@ -160,6 +172,12 @@ func (h *Handler) channelFinder(w http.ResponseWriter, r *http.Request) { cfURL := strings.TrimRight(h.channelFinderURL, "/") + "/resources/channels" if q != "" { cfURL += "?~name=*" + q + "*" + } else if r.URL.RawQuery != "" && r.URL.RawQuery != "q=" { + cfURL += "?" + r.URL.RawQuery + } else { + // Empty query check: return empty results to signal CFS is configured. + jsonOK(w, []struct{}{}) + return } resp, err := http.Get(cfURL) //nolint:noctx @@ -187,6 +205,47 @@ func (h *Handler) channelFinder(w http.ResponseWriter, r *http.Request) { jsonOK(w, names) } +// ── /archiver/search ────────────────────────────────────────────────────────── + +// archiverSearch proxies a search query to the EPICS Archive Appliance +// management API when configured. Returns 501 otherwise. +func (h *Handler) archiverSearch(w http.ResponseWriter, r *http.Request) { + if h.archiverURL == "" { + jsonError(w, http.StatusNotImplemented, "Archiver not configured (set archive_url in config)") + return + } + + pattern := r.URL.Query().Get("q") + if pattern == "" { + pattern = "*" + } + // AA expects glob patterns. If no glob characters, wrap in stars. + if !strings.ContainsAny(pattern, "*?[]") { + pattern = "*" + pattern + "*" + } + + searchURL := strings.TrimRight(h.archiverURL, "/") + "/mgmt/bpl/getAllPVs?pv=" + url.QueryEscape(pattern) + resp, err := http.Get(searchURL) //nolint:noctx + if err != nil { + jsonError(w, http.StatusBadGateway, "Archiver request failed: "+err.Error()) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + jsonError(w, http.StatusBadGateway, "Archiver returned status "+resp.Status) + return + } + + var pvs []string + if err := json.NewDecoder(resp.Body).Decode(&pvs); err != nil { + jsonError(w, http.StatusBadGateway, "Archiver response parse error: "+err.Error()) + return + } + + jsonOK(w, pvs) +} + // ── /groups ─────────────────────────────────────────────────────────────────── // getGroups returns the workspace group tree as a JSON array. @@ -416,6 +475,8 @@ func metaToSignalInfo(m datasource.Metadata) signalInfo { DisplayHigh: m.DisplayHigh, Writable: m.Writable, EnumStrings: m.EnumStrings, + Properties: m.Properties, + Tags: m.Tags, } } diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 4701f53..b88d5c1 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -40,7 +40,7 @@ func setup(t *testing.T) (*httptest.Server, func()) { } mux := http.NewServeMux() - api.New(brk, nil, store, "", log).Register(mux, "/api/v1") + api.New(brk, nil, store, "", "", log).Register(mux, "/api/v1") srv := httptest.NewServer(mux) return srv, func() { diff --git a/internal/config/config.go b/internal/config/config.go index 1700aec..7b18489 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -36,7 +36,9 @@ type EPICSConfig struct { CAAddrList string `toml:"ca_addr_list"` ArchiveURL string `toml:"archive_url"` ChannelFinderURL string `toml:"channel_finder_url"` - PVNames []string `toml:"pv_names"` + AutoSyncFilter string `toml:"auto_sync_filter"` + AutoSyncFromArchiver bool `toml:"auto_sync_from_archiver"` + PVNames []string `toml:"pv_names"` } type PVAConfig struct { @@ -100,6 +102,12 @@ func applyEnv(cfg *Config) { if v := env("UOPI_EPICS_CHANNEL_FINDER_URL"); v != "" { cfg.Datasource.EPICS.ChannelFinderURL = v } + if v := env("UOPI_EPICS_AUTO_SYNC_FILTER"); v != "" { + cfg.Datasource.EPICS.AutoSyncFilter = v + } + if v := env("UOPI_EPICS_AUTO_SYNC_FROM_ARCHIVER"); v != "" { + cfg.Datasource.EPICS.AutoSyncFromArchiver = (v == "true" || v == "YES") + } if v := env("EPICS_PVA_ADDR_LIST"); v != "" { cfg.Datasource.PVA.AddrList = strings.Fields(v) } diff --git a/internal/datasource/epics/archive.go b/internal/datasource/epics/archive.go index 5c7ff80..409556d 100644 --- a/internal/datasource/epics/archive.go +++ b/internal/datasource/epics/archive.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" "strconv" + "strings" "time" "github.com/uopi/uopi/internal/datasource" @@ -117,6 +118,55 @@ func fetchArchiveHistory(ctx context.Context, archiveURL, signal string, start, return out, nil } +// searchArchivePVs queries the Archive Appliance management API for PV names +// matching the given pattern (glob style, e.g. "PV:*"). +func searchArchivePVs(ctx context.Context, archiveURL, pattern string) ([]string, error) { + if archiveURL == "" { + return nil, nil + } + + // Build the request URL. + // Format: GET {archiveURL}/mgmt/bpl/getAllPVs?pv={pattern} + reqURL, err := url.Parse(archiveURL) + if err != nil { + return nil, fmt.Errorf("epics archive: invalid archive URL %q: %w", archiveURL, err) + } + reqURL = reqURL.JoinPath("mgmt", "bpl", "getAllPVs") + + q := reqURL.Query() + if pattern == "" { + pattern = "*" + } + // AA expects glob patterns. If no glob characters, wrap in stars. + if !strings.ContainsAny(pattern, "*?[]") { + pattern = "*" + pattern + "*" + } + q.Set("pv", pattern) + reqURL.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil) + if err != nil { + return nil, fmt.Errorf("epics archive: building request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("epics archive: HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("epics archive: unexpected status %d for search %q", resp.StatusCode, pattern) + } + + var pvs []string + if err := json.NewDecoder(resp.Body).Decode(&pvs); err != nil { + return nil, fmt.Errorf("epics archive: decoding response: %w", err) + } + + return pvs, nil +} + // coerceArchiveValue converts the raw JSON value (which json.Unmarshal decodes // as float64, string, bool, []interface{}, or nil) into one of the types // accepted by datasource.Value.Data. diff --git a/internal/datasource/epics/cf_client.go b/internal/datasource/epics/cf_client.go new file mode 100644 index 0000000..eb1402e --- /dev/null +++ b/internal/datasource/epics/cf_client.go @@ -0,0 +1,67 @@ +package epics + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "strings" + "time" +) + +// cfChannel is the JSON structure returned by the Channel Finder Service. +type cfChannel struct { + Name string `json:"name"` + Owner string `json:"owner"` + Properties []cfProperty `json:"properties"` + Tags []cfTag `json:"tags"` +} + +type cfProperty struct { + Name string `json:"name"` + Value string `json:"value"` + Owner string `json:"owner"` +} + +type cfTag struct { + Name string `json:"name"` + Owner string `json:"owner"` +} + +// queryChannelFinder fetches channels from CFS matching the given query string. +func queryChannelFinder(cfURL, query string) ([]cfChannel, error) { + if cfURL == "" { + return nil, nil + } + + url := strings.TrimRight(cfURL, "/") + "/resources/channels" + if query != "" { + if strings.Contains(query, "=") || strings.Contains(query, "~") { + // Query is already a structured filter (e.g. area=L1) + url += "?" + query + } else { + // Query is just a PV name pattern + url += "?~name=*" + query + "*" + } + } + + client := &http.Client{Timeout: 10 * time.Second} + slog.Info("CFS query", "url", url) + resp, err := client.Get(url) + if err != nil { + return nil, fmt.Errorf("CFS request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("CFS returned status %d", resp.StatusCode) + } + + var channels []cfChannel + if err := json.NewDecoder(resp.Body).Decode(&channels); err != nil { + return nil, fmt.Errorf("CFS response parse error: %w", err) + } + slog.Info("CFS results", "count", len(channels)) + + return channels, nil +} diff --git a/internal/datasource/epics/discovery_test.go b/internal/datasource/epics/discovery_test.go new file mode 100644 index 0000000..941f8e2 --- /dev/null +++ b/internal/datasource/epics/discovery_test.go @@ -0,0 +1,82 @@ +package epics_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/uopi/goca/proto" + "github.com/uopi/goca/testca" + "github.com/uopi/uopi/internal/datasource/epics" +) + +func TestDiscovery_EnvironmentVariables(t *testing.T) { + // 1. Setup a fake CA server. + srv, err := testca.New([]testca.PVSpec{ + {Name: "DISCO:PV", DBFType: proto.DBFDouble, Count: 1, Value: 123.45}, + }) + if err != nil { + t.Fatalf("testca.New: %v", err) + } + defer srv.Close() + + // 2. Set the environment variable to point to this server. + os.Setenv("EPICS_CA_ADDR_LIST", srv.UDPAddr()) + os.Setenv("EPICS_CA_AUTO_ADDR_LIST", "NO") + defer os.Unsetenv("EPICS_CA_ADDR_LIST") + defer os.Unsetenv("EPICS_CA_AUTO_ADDR_LIST") + + // 3. Create datasource with NO explicit address (should pick up from env). + ds := epics.New("", "", "", "", false, nil) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := ds.Connect(ctx); err != nil { + t.Fatalf("Connect: %v", err) + } + + // 4. Verify discovery works. + meta, err := ds.GetMetadata(ctx, "DISCO:PV") + if err != nil { + t.Fatalf("GetMetadata failed (discovery failed): %v", err) + } + if meta.Name != "DISCO:PV" { + t.Errorf("got %q, want DISCO:PV", meta.Name) + } +} + +func TestDiscovery_ConfigOverride(t *testing.T) { + // 1. Setup TWO fake CA servers. + srv1, _ := testca.New([]testca.PVSpec{{Name: "PV:1", DBFType: proto.DBFDouble, Value: 1.0}}) + defer srv1.Close() + srv2, _ := testca.New([]testca.PVSpec{{Name: "PV:2", DBFType: proto.DBFDouble, Value: 2.0}}) + defer srv2.Close() + + // 2. Point env to srv1. + os.Setenv("EPICS_CA_ADDR_LIST", srv1.UDPAddr()) + os.Setenv("EPICS_CA_AUTO_ADDR_LIST", "NO") + defer os.Unsetenv("EPICS_CA_ADDR_LIST") + defer os.Unsetenv("EPICS_CA_AUTO_ADDR_LIST") + + // 3. Create datasource pointing explicitly to srv2 (should OVERRIDE env). + ds := epics.New(srv2.UDPAddr(), "", "", "", false, nil) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := ds.Connect(ctx); err != nil { + t.Fatalf("Connect: %v", err) + } + + // 4. PV from srv2 should be found. + if _, err := ds.GetMetadata(ctx, "PV:2"); err != nil { + t.Errorf("PV:2 from config address not found: %v", err) + } + + // 5. PV from srv1 should NOT be found (since AutoAddrList is disabled by config override). + ctx2, cancel2 := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel2() + if _, err := ds.GetMetadata(ctx2, "PV:1"); err == nil { + t.Error("PV:1 from env address was found, but config should have overridden it") + } +} diff --git a/internal/datasource/epics/epics.go b/internal/datasource/epics/epics.go index 6ebe19a..e8df9a3 100644 --- a/internal/datasource/epics/epics.go +++ b/internal/datasource/epics/epics.go @@ -26,6 +26,7 @@ import "C" import ( "context" "fmt" + "log/slog" "os" "sync" "sync/atomic" @@ -66,10 +67,14 @@ type caChannel struct { // EPICS is the Channel Access data source. type EPICS struct { - caAddrList string - archiveURL string + caAddrList string + archiveURL string + cfURL string + autoSyncFilter string + autoSyncFromArchiver bool - // caCtx is the CA context created in Connect(). Stored as unsafe.Pointer + // caCtx is the CA context created in Connect(). + Stored as unsafe.Pointer // because the C type (ca_client_context *) is opaque. Every goroutine // that calls CA functions must call caAttachContext(caCtx) first, because // Go goroutines can run on any OS thread and CA contexts are thread-local. @@ -87,12 +92,15 @@ type EPICS struct { // Appliance instance for history queries (may be empty). // pvNames is accepted for API compatibility with the pure-Go build but ignored // in the CGo build (the CGo implementation populates ListSignals via callbacks). -func New(caAddrList, archiveURL string, pvNames []string) datasource.DataSource { +func New(caAddrList, archiveURL, cfURL, autoSyncFilter string, autoSyncFromArchiver bool, pvNames []string) datasource.DataSource { return &EPICS{ - caAddrList: caAddrList, - archiveURL: archiveURL, - channels: make(map[string]*caChannel), - metadata: make(map[string]datasource.Metadata), + caAddrList: caAddrList, + archiveURL: archiveURL, + cfURL: cfURL, + autoSyncFilter: autoSyncFilter, + autoSyncFromArchiver: autoSyncFromArchiver, + channels: make(map[string]*caChannel), + metadata: make(map[string]datasource.Metadata), } } @@ -122,9 +130,62 @@ func (e *EPICS) Connect(_ context.Context) error { } // Save the context so goroutines on other OS threads can attach to it. e.caCtx = C.caCurrentContext() + + // Perform background sync from Channel Finder if configured. + if e.cfURL != "" && e.autoSyncFilter != "" { + go e.syncFromChannelFinder(context.Background()) + } + + // Perform background sync from Archive Appliance if configured. + if e.archiveURL != "" && e.autoSyncFromArchiver { + go e.syncFromArchiver(context.Background()) + } + return nil } +func (e *EPICS) syncFromChannelFinder(ctx context.Context) { + time.Sleep(500 * time.Millisecond) + slog.Info("epics: syncing signals from Channel Finder", "url", e.cfURL, "filter", e.autoSyncFilter) + channels, err := queryChannelFinder(e.cfURL, e.autoSyncFilter) + if err != nil { + slog.Error("epics: Channel Finder sync failed", "err", err) + return + } + + for _, ch := range channels { + // Convert CFS properties and tags + props := make(map[string]string) + for _, p := range ch.Properties { + props[p.Name] = p.Value + } + tags := make([]string, len(ch.Tags)) + for i, t := range ch.Tags { + tags[i] = t.Name + } + + e.mu.Lock() + if _, exists := e.metadata[ch.Name]; !exists { + e.metadata[ch.Name] = datasource.Metadata{ + Name: ch.Name, + Properties: props, + Tags: tags, + } + } + e.mu.Unlock() + + // Background pre-fetch of actual record info + go e.prefetchPV(ctx, ch.Name) + } + slog.Info("epics: synced signals from Channel Finder", "count", len(channels)) +} + +func (e *EPICS) prefetchPV(ctx context.Context, pv string) { + mctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + _, _ = e.GetMetadata(mctx, pv) +} + // attachCAContext attaches the saved CA context to the calling OS thread. // Must be called at the top of every goroutine that uses CA functions, // because Go goroutines can be scheduled onto any OS thread. @@ -422,13 +483,12 @@ func (e *EPICS) fetchMetadata(chid C.chid, name string) datasource.Metadata { } // ListSignals returns metadata for all currently tracked channels. -// Channels with cached metadata return full info; others return a stub entry -// with the PV name so the signal tree can display them immediately after -// a manual add, before GetMetadata has been called. func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) { e.mu.Lock() defer e.mu.Unlock() + slog.Debug("epics: listing signals", "count", len(e.metadata)) + // Start with all fully-fetched metadata entries. out := make([]datasource.Metadata, 0, len(e.channels)) seen := make(map[string]bool, len(e.metadata)) @@ -583,3 +643,27 @@ func nativeTimeType(chid C.chid) C.chtype { return C.DBR_TIME_DOUBLE } } + +func (e *EPICS) syncFromArchiver(ctx context.Context) { + time.Sleep(1 * time.Second) // wait for network + slog.Info("epics: syncing signals from Archiver", "url", e.archiveURL) + pvs, err := searchArchivePVs(ctx, e.archiveURL, "*") + if err != nil { + slog.Error("epics: Archiver sync failed", "err", err) + return + } + + for _, name := range pvs { + if ctx.Err() != nil { + return + } + e.mu.Lock() + if _, exists := e.metadata[name]; !exists { + e.metadata[name] = datasource.Metadata{Name: name} + } + e.mu.Unlock() + // Background pre-fetch of record info + go e.prefetchPV(ctx, name) + } + slog.Info("epics: synced signals from Archiver", "count", len(pvs)) +} diff --git a/internal/datasource/epics/epics_purego_test.go b/internal/datasource/epics/epics_purego_test.go index b9dc540..7a93f20 100644 --- a/internal/datasource/epics/epics_purego_test.go +++ b/internal/datasource/epics/epics_purego_test.go @@ -26,7 +26,7 @@ func newTestDS(t *testing.T, pvs []testca.PVSpec) (datasource.DataSource, *testc } t.Cleanup(srv.Close) - ds := epics.New(srv.UDPAddr(), "", nil) + ds := epics.New(srv.UDPAddr(), "", "", "", false, nil) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) t.Cleanup(cancel) if err := ds.Connect(ctx); err != nil { diff --git a/internal/datasource/epics/noop.go b/internal/datasource/epics/noop.go index 9caef84..f1b6a2d 100644 --- a/internal/datasource/epics/noop.go +++ b/internal/datasource/epics/noop.go @@ -28,9 +28,12 @@ func Available() bool { return true } // EPICS is the pure-Go Channel Access data source. type EPICS struct { - caAddrList string - archiveURL string - pvNames []string // pre-fetched at connect time for ListSignals + caAddrList string + archiveURL string + cfURL string + autoSyncFilter string + autoSyncFromArchiver bool + pvNames []string // pre-fetched at connect time for ListSignals client *ca.Client @@ -47,12 +50,15 @@ type EPICS struct { // pvNames is an optional list of PV names whose metadata will be pre-fetched // at connect time so they appear in ListSignals immediately (useful for the // edit-mode signal tree). -func New(caAddrList, archiveURL string, pvNames []string) datasource.DataSource { +func New(caAddrList, archiveURL, cfURL, autoSyncFilter string, autoSyncFromArchiver bool, pvNames []string) datasource.DataSource { return &EPICS{ - caAddrList: caAddrList, - archiveURL: archiveURL, - pvNames: pvNames, - metadata: make(map[string]datasource.Metadata), + caAddrList: caAddrList, + archiveURL: archiveURL, + cfURL: cfURL, + autoSyncFilter: autoSyncFilter, + autoSyncFromArchiver: autoSyncFromArchiver, + pvNames: pvNames, + metadata: make(map[string]datasource.Metadata), } } @@ -75,29 +81,24 @@ func (e *EPICS) Connect(ctx context.Context) error { e.client = cli slog.Info("epics: CA client started", "addrs", cfg.AddrList) - // Pre-fetch metadata for any configured PV names so they appear in - // ListSignals as soon as the datasource is ready. We open a short-lived - // subscription (which drives channel connection) and then fetch CTRL info. + // Perform background sync from Channel Finder if configured. + if e.cfURL != "" && e.autoSyncFilter != "" { + go e.syncFromChannelFinder(ctx) + } + + // Perform background sync from Archive Appliance if configured. + if e.archiveURL != "" && e.autoSyncFromArchiver { + go e.syncFromArchiver(ctx) + } + + // Pre-fetch metadata for any configured PV names. if len(e.pvNames) > 0 { go func() { for _, pv := range e.pvNames { if ctx.Err() != nil { return } - func() { - tvCh := make(chan proto.TimeValue, 1) - subCtx, subCancel := context.WithTimeout(ctx, 15*time.Second) - defer subCancel() - cancel, err := e.client.Subscribe(subCtx, pv, tvCh) - if err != nil { - return - } - defer cancel() - // Channel is now connected; fetch CTRL metadata. - mctx, mcancel := context.WithTimeout(ctx, 5*time.Second) - defer mcancel() - _, _ = e.GetMetadata(mctx, pv) - }() + e.prefetchPV(ctx, pv) } }() } @@ -105,6 +106,62 @@ func (e *EPICS) Connect(ctx context.Context) error { return nil } +func (e *EPICS) syncFromChannelFinder(ctx context.Context) { + time.Sleep(500 * time.Millisecond) + slog.Info("epics: syncing signals from Channel Finder", "url", e.cfURL, "filter", e.autoSyncFilter) + channels, err := queryChannelFinder(e.cfURL, e.autoSyncFilter) + if err != nil { + slog.Error("epics: Channel Finder sync failed", "err", err) + return + } + + for _, ch := range channels { + if ctx.Err() != nil { + return + } + // Convert CFS properties and tags to datasource.Metadata + props := make(map[string]string) + for _, p := range ch.Properties { + props[p.Name] = p.Value + } + tags := make([]string, len(ch.Tags)) + for i, t := range ch.Tags { + tags[i] = t.Name + } + + // Initialize metadata with CFS info. Full metadata (type, units, etc.) + // will be populated on first access via DBR_CTRL GET. + e.mu.Lock() + if _, exists := e.metadata[ch.Name]; !exists { + e.metadata[ch.Name] = datasource.Metadata{ + Name: ch.Name, + Properties: props, + Tags: tags, + } + } + e.mu.Unlock() + + // Background pre-fetch of actual record info (type, etc.) + go e.prefetchPV(ctx, ch.Name) + } + slog.Info("epics: synced signals from Channel Finder", "count", len(channels)) +} + +func (e *EPICS) prefetchPV(ctx context.Context, pv string) { + tvCh := make(chan proto.TimeValue, 1) + subCtx, subCancel := context.WithTimeout(ctx, 15*time.Second) + defer subCancel() + cancel, err := e.client.Subscribe(subCtx, pv, tvCh) + if err != nil { + return + } + defer cancel() + // Channel is now connected; fetch CTRL metadata. + mctx, mcancel := context.WithTimeout(ctx, 5*time.Second) + defer mcancel() + _, _ = e.GetMetadata(mctx, pv) +} + // -------------------------------------------------------------------------- // // Subscribe // // -------------------------------------------------------------------------- // @@ -273,6 +330,7 @@ func (e *EPICS) ctrlToMeta(name string, ci ca.CtrlInfo) datasource.Metadata { func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) { e.mu.RLock() defer e.mu.RUnlock() + slog.Debug("epics: listing signals", "count", len(e.metadata)) out := make([]datasource.Metadata, 0, len(e.metadata)) for _, m := range e.metadata { out = append(out, m) @@ -305,3 +363,27 @@ func (e *EPICS) History(ctx context.Context, signal string, start, end time.Time } return fetchArchiveHistory(ctx, e.archiveURL, signal, start, end, maxPoints) } + +func (e *EPICS) syncFromArchiver(ctx context.Context) { + time.Sleep(1 * time.Second) // wait for network + slog.Info("epics: syncing signals from Archiver", "url", e.archiveURL) + pvs, err := searchArchivePVs(ctx, e.archiveURL, "*") + if err != nil { + slog.Error("epics: Archiver sync failed", "err", err) + return + } + + for _, name := range pvs { + if ctx.Err() != nil { + return + } + e.mu.Lock() + if _, exists := e.metadata[name]; !exists { + e.metadata[name] = datasource.Metadata{Name: name} + } + e.mu.Unlock() + // Background pre-fetch of record info + go e.prefetchPV(ctx, name) + } + slog.Info("epics: synced signals from Archiver", "count", len(pvs)) +} diff --git a/internal/datasource/iface.go b/internal/datasource/iface.go index e321b3c..a61389f 100644 --- a/internal/datasource/iface.go +++ b/internal/datasource/iface.go @@ -60,6 +60,8 @@ type Metadata struct { DriveHigh float64 EnumStrings []string // non-nil only for TypeEnum Writable bool + Properties map[string]string // Channel Finder properties + Tags []string // Channel Finder tags } // CancelFunc cancels a subscription started by DataSource.Subscribe. diff --git a/internal/server/server.go b/internal/server/server.go index b26523b..a8375ed 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -23,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, channelFinderURL string, log *slog.Logger) *Server { +func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, channelFinderURL, archiverURL string, log *slog.Logger) *Server { mux := http.NewServeMux() // Health check @@ -39,7 +39,7 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions)) // REST API - api.New(brk, synth, store, channelFinderURL, log).Register(mux, apiPrefix) + api.New(brk, synth, store, channelFinderURL, archiverURL, 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 953e0df..0717b6e 100644 --- a/internal/server/ws.go +++ b/internal/server/ws.go @@ -64,15 +64,17 @@ type outMsg struct { } type metaPayload struct { - Type string `json:"type"` - Unit string `json:"unit,omitempty"` - Description string `json:"description,omitempty"` - DisplayLow float64 `json:"displayLow"` - DisplayHigh float64 `json:"displayHigh"` - DriveLow float64 `json:"driveLow,omitempty"` - DriveHigh float64 `json:"driveHigh,omitempty"` - EnumStrings []string `json:"enumStrings,omitempty"` - Writable bool `json:"writable"` + Type string `json:"type"` + Unit string `json:"unit,omitempty"` + Description string `json:"description,omitempty"` + DisplayLow float64 `json:"displayLow"` + DisplayHigh float64 `json:"displayHigh"` + DriveLow float64 `json:"driveLow,omitempty"` + DriveHigh float64 `json:"driveHigh,omitempty"` + EnumStrings []string `json:"enumStrings,omitempty"` + Writable bool `json:"writable"` + Properties map[string]string `json:"properties,omitempty"` + Tags []string `json:"tags,omitempty"` } type histPoint struct { @@ -396,6 +398,8 @@ func metadataToPayload(m datasource.Metadata) *metaPayload { DriveHigh: m.DriveHigh, EnumStrings: m.EnumStrings, Writable: m.Writable, + Properties: m.Properties, + Tags: m.Tags, } } diff --git a/internal/storage/store.go b/internal/storage/store.go index 525a54b..c09ca26 100644 --- a/internal/storage/store.go +++ b/internal/storage/store.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "strconv" "strings" "time" "unicode" @@ -80,10 +81,16 @@ func (s *Store) List() ([]InterfaceMeta, error) { } var out []InterfaceMeta for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(e.Name(), ".xml") { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(strings.ToLower(name), ".xml") { continue } - id := strings.TrimSuffix(e.Name(), ".xml") + // Skip versioned backups: id.v1.xml, id.v2.xml, etc. + if isVersioned(name) { + continue + } + + id := strings.TrimSuffix(name, filepath.Ext(name)) meta, err := s.readMeta(id) if err != nil { continue // skip corrupt files silently @@ -96,6 +103,20 @@ func (s *Store) List() ([]InterfaceMeta, error) { return out, nil } +func isVersioned(name string) bool { + // Format: .v.xml + parts := strings.Split(name, ".") + if len(parts) != 3 { + return false + } + vPart := parts[1] + if !strings.HasPrefix(vPart, "v") { + return false + } + _, err := strconv.Atoi(vPart[1:]) + return err == nil +} + // rootAttrs is used to parse only the top-level XML attributes. type rootAttrs struct { XMLName xml.Name `xml:"interface"` @@ -145,18 +166,87 @@ func (s *Store) Create(xmlData []byte) (string, error) { if _, err := os.Stat(s.filePath(id)); err == nil { id = id + "-" + fmt.Sprintf("%d", time.Now().UnixMilli()) } - return id, os.WriteFile(s.filePath(id), xmlData, 0o644) + + // Initialize version to 1. + updatedData, err := setXMLAttribute(xmlData, "version", "1") + if err != nil { + return "", fmt.Errorf("initialize version attribute: %w", err) + } + + return id, os.WriteFile(s.filePath(id), updatedData, 0o644) } -// Update replaces the XML for an existing interface. +// Update replaces the XML for an existing interface, preserving the previous +// version as a separate file. 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 + + oldPath := s.filePath(id) + oldData, err := os.ReadFile(oldPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return ErrNotFound + } + return err } - return os.WriteFile(s.filePath(id), xmlData, 0o644) + + // Parse current version to name the backup file. + var oldRoot rootAttrs + if err := xml.Unmarshal(oldData, &oldRoot); err != nil { + return fmt.Errorf("parse existing XML: %w", err) + } + if oldRoot.Version < 1 { + oldRoot.Version = 1 + } + + // Move old file to backup path: id.vN.xml + backupPath := filepath.Join(s.dir, fmt.Sprintf("%s.v%d.xml", id, oldRoot.Version)) + if err := os.WriteFile(backupPath, oldData, 0o644); err != nil { + return fmt.Errorf("create backup: %w", err) + } + + // Update version in new data. + newVersion := oldRoot.Version + 1 + updatedData, err := setXMLAttribute(xmlData, "version", fmt.Sprintf("%d", newVersion)) + if err != nil { + return fmt.Errorf("update version attribute: %w", err) + } + + return os.WriteFile(oldPath, updatedData, 0o644) +} + +// setXMLAttribute is a primitive helper to update a top-level attribute in an +// XML blob without full unmarshal/marshal (which might mess up formatting). +func setXMLAttribute(data []byte, attr, value string) ([]byte, error) { + // Very basic implementation: look for the attribute in the first 512 bytes. + // A proper implementation would use an XML encoder or a better regex. + s := string(data) + tagEnd := strings.Index(s, ">") + if tagEnd == -1 { + return nil, errors.New("malformed XML: no root tag end") + } + rootTag := s[:tagEnd] + + attrSearch := " " + attr + "=\"" + idx := strings.Index(rootTag, attrSearch) + if idx == -1 { + // Attribute not found, insert it before the tag ends. + insertIdx := tagEnd + if s[tagEnd-1] == '/' { + insertIdx-- // handle + } + return []byte(s[:insertIdx] + fmt.Sprintf(" %s=\"%s\"", attr, value) + s[insertIdx:]), nil + } + + // Attribute found, replace its value. + valStart := idx + len(attrSearch) + valEnd := strings.Index(s[valStart:], "\"") + if valEnd == -1 { + return nil, errors.New("malformed XML: attribute value not closed") + } + return []byte(s[:valStart] + value + s[valStart+valEnd:]), nil } // Delete removes the interface with the given ID. diff --git a/tools/buildfrontend/main.go b/tools/buildfrontend/main.go index 4180d1f..08d5c78 100644 --- a/tools/buildfrontend/main.go +++ b/tools/buildfrontend/main.go @@ -80,6 +80,7 @@ func main() { // Copy uPlot CSS and favicon copyFile(filepath.Join(root, "web", "vendor", "uplot.css"), filepath.Join(distDir, "uplot.css")) copyFile(filepath.Join(root, "web", "public", "favicon.svg"), filepath.Join(distDir, "favicon.svg")) + copyFile(filepath.Join(root, "web", "public", "favicon.svg"), filepath.Join(distDir, "favicon.ico")) // Write index.html html := ` @@ -88,6 +89,7 @@ func main() { uopi + diff --git a/uopi.example.toml b/uopi.example.toml index 929f8eb..5e03ea8 100644 --- a/uopi.example.toml +++ b/uopi.example.toml @@ -3,9 +3,11 @@ listen = ":8080" storage_dir = "./interfaces" [datasource.epics] -enabled = true -ca_addr_list = "" # overrides EPICS_CA_ADDR_LIST if set -archive_url = "" # EPICS Archive Appliance base URL, e.g. http://archiver:17665/ +enabled = true +ca_addr_list = "" # overrides EPICS_CA_ADDR_LIST if set +archive_url = "" # EPICS Archive Appliance base URL +channel_finder_url = "" # e.g. http://channelfinder:8080/ChannelFinder +auto_sync_filter = "" # e.g. area=StorageRing or tags=production [datasource.synthetic] enabled = true diff --git a/web/src/ChannelFinderModal.tsx b/web/src/ChannelFinderModal.tsx new file mode 100644 index 0000000..2b9c395 --- /dev/null +++ b/web/src/ChannelFinderModal.tsx @@ -0,0 +1,267 @@ +import { h } from 'preact'; +import { useState, useMemo } from 'preact/hooks'; +import type { SignalRef } from './lib/types'; + +interface Props { + onClose: () => void; + onAddSignals: (signals: SignalRef[]) => void; +} + +interface Rule { + type: 'name' | 'tag' | 'property'; + key?: string; + value: string; +} + +type SearchSource = 'cfs' | 'archiver'; + +export default function ChannelFinderModal({ onClose, onAddSignals }: Props) { + const [source, setSource] = useState('cfs'); + + // CFS State + const [rules, setRules] = useState([{ type: 'name', value: '' }]); + const [cfsResults, setCfsResults] = useState>([]); + + // Archiver State + const [archPattern, setArchPattern] = useState(''); + const [archResults, setArchResults] = useState([]); + + const [searching, setSearching] = useState(false); + const [error, setError] = useState(''); + + async function handleCfsSearch() { + if (!rules.some(r => r.value.trim() !== '')) return; + setSearching(true); + setError(''); + + try { + const params = new URLSearchParams(); + for (const r of rules) { + if (!r.value.trim()) continue; + if (r.type === 'name') { + params.append('~name', r.value.includes('*') ? r.value : `*${r.value}*`); + } else if (r.type === 'tag') { + params.append('~tag', r.value); + } else if (r.type === 'property' && r.key) { + params.append(r.key, r.value); + } + } + + const res = await fetch(`/api/v1/channel-finder?${params.toString()}`); + if (!res.ok) throw new Error(`CFS Search failed: ${res.statusText}`); + const data = await res.json(); + setCfsResults(data); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSearching(false); + } + } + + async function handleArchSearch() { + if (!archPattern.trim()) return; + setSearching(true); + setError(''); + + try { + const res = await fetch(`/api/v1/archiver/search?q=${encodeURIComponent(archPattern)}`); + if (!res.ok) { + if (res.status === 501) throw new Error("Archiver discovery not configured"); + throw new Error(`Archiver Search failed: ${res.statusText}`); + } + const data = await res.json(); + setArchResults(data); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSearching(false); + } + } + + function addRule() { + setRules([...rules, { type: 'property', key: '', value: '' }]); + } + + function updateRule(idx: number, patch: Partial) { + setRules(rules.map((r, i) => i === idx ? { ...r, ...patch } : r)); + } + + function removeRule(idx: number) { + setRules(rules.filter((_, i) => i !== idx)); + } + + function handleAddAll() { + const refs = source === 'cfs' + ? cfsResults.map(r => ({ ds: 'epics', name: r.name })) + : archResults.map(name => ({ ds: 'epics', name })); + onAddSignals(refs); + onClose(); + } + + const resultsCount = source === 'cfs' ? cfsResults.length : archResults.length; + + return ( +
+
e.stopPropagation()} style="max-width: 750px; height: 85vh;"> +
+ PV Discovery + +
+ +
+ {/* Source Tabs */} +
+ + +
+ + {source === 'cfs' ? ( +
+
Channel Finder Rules
+

Filter by name, tags, or properties.

+ + {rules.map((rule, idx) => ( +
+ + + {rule.type === 'property' && ( + updateRule(idx, { key: (e.target as HTMLInputElement).value })} + /> + )} + + updateRule(idx, { value: (e.target as HTMLInputElement).value })} + onKeyDown={e => e.key === 'Enter' && handleCfsSearch()} + /> + + +
+ ))} + +
+ + +
+
+ ) : ( +
+
Archiver Search
+

Search for archived PVs using glob patterns (e.g. *TEMP*).

+ +
+ setArchPattern((e.target as HTMLInputElement).value)} + onKeyDown={e => e.key === 'Enter' && handleArchSearch()} + /> + +
+
+ )} + +
+ Results {resultsCount > 0 && `(${resultsCount})`} + {resultsCount > 0 && ( + + )} +
+ + {error &&

{error}

} + +
+ {resultsCount === 0 ? ( +

+ {searching ? 'Searching…' : 'No results to show.'} +

+ ) : ( +
    + {source === 'cfs' ? ( + cfsResults.map(r => ( +
  • +
    + {r.name} +
    + {r.tags?.map((t: any) => ( + + {t.name} + + ))} +
    +
    + +
  • + )) + ) : ( + archResults.map(name => ( +
  • + {name} + +
  • + )) + )} +
+ )} +
+
+ + +
+
+ ); +} diff --git a/web/src/EditMode.tsx b/web/src/EditMode.tsx index fcfb919..66b9e6d 100644 --- a/web/src/EditMode.tsx +++ b/web/src/EditMode.tsx @@ -210,6 +210,7 @@ export default function EditMode({ initial, onDone }: Props) { setIface(f => ({ ...f, id: json.id })); } setDirty(false); + window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces')); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { @@ -254,6 +255,9 @@ export default function EditMode({ initial, onDone }: Props) { onDone(iface); } + // Clipboard + const clipboard = useRef([]); + // ── Keyboard shortcuts ───────────────────────────────────────────────────── function handleKeyDown(e: KeyboardEvent) { @@ -268,6 +272,33 @@ export default function EditMode({ initial, onDone }: Props) { setDirty(true); return; } + + // Copy + if ((e.ctrlKey || e.metaKey) && e.key === 'c' && selectedIds.length > 0) { + e.preventDefault(); + clipboard.current = iface.widgets + .filter(w => selectedIds.includes(w.id)) + .map(w => ({ ...w })); + return; + } + + // Paste + if ((e.ctrlKey || e.metaKey) && e.key === 'v' && clipboard.current.length > 0) { + e.preventDefault(); + pushUndo(); + const offset = 20; + const pasted = clipboard.current.map(w => ({ + ...w, + id: genWidgetId(), + x: w.x + offset, + y: w.y + offset, + })); + setIface(f => ({ ...f, widgets: [...f.widgets, ...pasted] })); + setSelectedIds(pasted.map(w => w.id)); + setDirty(true); + return; + } + if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; } if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; } if ((e.ctrlKey || e.metaKey) && e.key === 'a') { diff --git a/web/src/HelpModal.tsx b/web/src/HelpModal.tsx index 65d0cff..db9a1ce 100644 --- a/web/src/HelpModal.tsx +++ b/web/src/HelpModal.tsx @@ -643,6 +643,8 @@ function SectionHistory() { function SectionShortcuts() { const shortcuts: Array<[string, string]> = [ + ['Ctrl+C', 'Copy selected widgets (Edit mode)'], + ['Ctrl+V', 'Paste widgets from clipboard (Edit mode)'], ['Ctrl+Z', 'Undo last change (Edit mode)'], ['Ctrl+Y / Ctrl+Shift+Z', 'Redo (Edit mode)'], ['Ctrl+A', 'Select all widgets (Edit mode)'], diff --git a/web/src/InfoPanel.tsx b/web/src/InfoPanel.tsx index 8bbfea6..0fc0f64 100644 --- a/web/src/InfoPanel.tsx +++ b/web/src/InfoPanel.tsx @@ -89,6 +89,37 @@ export default function InfoPanel({ signal, onClose }: Props) { + {meta.tags && meta.tags.length > 0 && ( +
+
+
Tags
+
+ {meta.tags.map(t => ( + + {t} + + ))} +
+
+ )} + + {meta.properties && Object.keys(meta.properties).length > 0 && ( +
+
+
Properties
+ + + {Object.entries(meta.properties).map(([k, v]) => ( + + + + + ))} + +
{k}{v}
+
+ )} + {meta.enumStrings && meta.enumStrings.length > 0 && (
diff --git a/web/src/InterfaceList.tsx b/web/src/InterfaceList.tsx index 5aed343..0690eab 100644 --- a/web/src/InterfaceList.tsx +++ b/web/src/InterfaceList.tsx @@ -31,7 +31,12 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId, widt .finally(() => setLoading(false)); } - useEffect(() => { refresh(); }, []); + useEffect(() => { + refresh(); + const handleRefresh = () => refresh(); + window.addEventListener('uopi:refresh-interfaces', handleRefresh); + return () => window.removeEventListener('uopi:refresh-interfaces', handleRefresh); + }, []); function triggerImport() { fileInputRef.current?.click(); diff --git a/web/src/PropertiesPane.tsx b/web/src/PropertiesPane.tsx index c839751..eeb5e8c 100644 --- a/web/src/PropertiesPane.tsx +++ b/web/src/PropertiesPane.tsx @@ -107,7 +107,7 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
{w && ( -
+
{w.type}
{/* Position / size */} diff --git a/web/src/SignalTree.tsx b/web/src/SignalTree.tsx index da06efe..4213f4c 100644 --- a/web/src/SignalTree.tsx +++ b/web/src/SignalTree.tsx @@ -1,11 +1,13 @@ import { h, Fragment } from 'preact'; -import { useState, useEffect, useRef } from 'preact/hooks'; +import { useState, useEffect, useRef, useMemo } from 'preact/hooks'; import type { SignalRef } from './lib/types'; import SyntheticWizard from './SyntheticWizard'; import SyntheticEditor from './SyntheticEditor'; import GroupsTree from './GroupsTree'; +import ChannelFinderModal from './ChannelFinderModal'; type TreeTab = 'sources' | 'groups'; +type GroupBy = 'datasource' | 'area' | 'system' | 'ioc'; interface SignalInfo { name: string; @@ -13,14 +15,17 @@ interface SignalInfo { unit?: string; description?: string; writable?: boolean; + properties?: Record; + tags?: string[]; } -interface DsGroup { - ds: string; - signals: SignalInfo[]; +interface DisplayGroup { + id: string; + label: string; + signals: Array; expanded: boolean; - addingSignal: boolean; // show add-PV input - addValue: string; + addingSignal?: boolean; + addValue?: string; } // Custom signals persisted in localStorage @@ -45,35 +50,44 @@ interface Props { export default function SignalTree({ onDragStart, width }: Props) { const [treeTab, setTreeTab] = useState('sources'); + const [groupBy, setGroupBy] = useState('datasource'); const [collapsed, setCollapsed] = useState(false); - const [groups, setGroups] = useState([]); + const [rawSignals, setRawSignals] = useState>([]); const [customSignals, setCustomSignals] = useState>(loadCustom); + const [expandedGroups, setExpandedGroups] = useState>({}); 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 [showCfModal, setShowCfModal] = useState(false); + const [showManualAdd, setShowManualAdd] = useState(false); + const [manualDs, setManualDs] = useState('epics'); + const [manualName, setManualName] = useState(''); + const [addingTo, setAddingTo] = useState(null); + const [addValue, setAddValue] = useState(''); const [editSynthetic, setEditSynthetic] = useState(null); const csvRef = useRef(null); - async function loadGroups() { + async function loadAllSignals() { 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( + + const all: Array = []; + 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: '' }; - } + if (r.ok) { + const sigs: SignalInfo[] = await r.json(); + all.push(...sigs.map(s => ({ ...s, ds: name }))); + } + } catch (e) { console.error(`Failed to load ${name}:`, e); } }) ); - setGroups(loaded); + setRawSignals(all); // Check if Channel Finder is available const cf = await fetch('/api/v1/channel-finder?q=').catch(() => null); @@ -85,53 +99,66 @@ export default function SignalTree({ onDragStart, width }: Props) { } } - useEffect(() => { loadGroups(); }, []); + useEffect(() => { loadAllSignals(); }, []); - // 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 }; - }); + // Merge custom signals that might not be in ListSignals yet + const allSignals = useMemo(() => { + const existing = new Set(rawSignals.map(s => `${s.ds}\0${s.name}`)); + const extra = customSignals + .filter(c => !existing.has(`${c.ds}\0${c.name}`)) + .map(c => ({ name: c.name, ds: c.ds, type: 'custom' } as SignalInfo & { ds: string })); + return [...rawSignals, ...extra]; + }, [rawSignals, customSignals]); - // 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: '', - })); + // Group signals based on selected criteria + const visibleGroups = useMemo(() => { + const lf = filter.toLowerCase(); + const filtered = allSignals.filter(s => + s.name.toLowerCase().includes(lf) || + (s.description ?? '').toLowerCase().includes(lf) + ); - const visibleGroups = [...allGroups, ...orphanGroups]; + const groups: Record> = {}; - function toggleGroup(ds: string) { - setGroups(gs => gs.map(g => g.ds === ds ? { ...g, expanded: !g.expanded } : g)); + filtered.forEach(s => { + let key = 'unknown'; + if (groupBy === 'datasource') { + key = s.ds; + } else if (groupBy === 'area') { + key = s.properties?.area || 'no-area'; + } else if (groupBy === 'system') { + key = s.properties?.system || 'no-system'; + } else if (groupBy === 'ioc') { + key = s.properties?.iocName || 'no-ioc'; + } + (groups[key] ??= []).push(s); + }); + + return Object.entries(groups).map(([id, signals]) => ({ + id, + label: id, + signals: signals.sort((a, b) => a.name.localeCompare(b.name)), + expanded: expandedGroups[id] ?? true, + })).sort((a, b) => a.label.localeCompare(b.label)); + }, [allSignals, groupBy, expandedGroups, filter]); + + function toggleGroup(id: string) { + setExpandedGroups(prev => ({ ...prev, [id]: !(prev[id] ?? true) })); } - function setAdding(ds: string, val: boolean) { - setGroups(gs => gs.map(g => g.ds === ds ? { ...g, addingSignal: val, addValue: '' } : g)); + function startAdding(ds: string) { + setAddingTo(ds); + setAddValue(''); + setExpandedGroups(prev => ({ ...prev, [ds]: true })); } - 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 }]; + function commitAddManually() { + const name = addValue.trim(); + if (!name || !addingTo) { setAddingTo(null); return; } + const next = [...customSignals, { ds: addingTo, name }]; setCustomSignals(next); saveCustom(next); - setAdding(ds, false); + setAddingTo(null); } function removeCustom(ds: string, name: string) { @@ -140,26 +167,21 @@ export default function SignalTree({ onDragStart, width }: Props) { saveCustom(next); } - // ── Channel Finder search ──────────────────────────────────────────────── + function handleAddCfSignals(refs: SignalRef[]) { + const existing = new Set(customSignals.map(c => `${c.ds}\0${c.name}`)); + const fresh = refs.filter(r => !existing.has(`${r.ds}\0${r.name}`)); + if (fresh.length === 0) return; + const next = [...customSignals, ...fresh]; + setCustomSignals(next); + saveCustom(next); + loadAllSignals(); // Refresh to fetch metadata for new ones + } - 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); - } + function handleManualAdd() { + if (!manualName.trim()) return; + handleAddCfSignals([{ ds: manualDs, name: manualName.trim() }]); + setManualName(''); + setShowManualAdd(false); } // ── CSV import ──────────────────────────────────────────────────────────── @@ -176,39 +198,19 @@ export default function SignalTree({ onDragStart, width }: Props) { 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); + handleAddCfSignals(added); } - // ── Filter ──────────────────────────────────────────────────────────────── - - const lf = filter.toLowerCase(); - 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) { + function makeDraggable(sig: SignalInfo & { ds: string }) { return { draggable: true as const, onDragStart: (e: DragEvent) => { - const ref: SignalRef = { ds, name: sig.name }; + const ref: SignalRef = { ds: sig.ds, name: sig.name }; e.dataTransfer?.setData('application/json', JSON.stringify(ref)); onDragStart?.(ref); }, @@ -226,7 +228,6 @@ export default function SignalTree({ onDragStart, width }: Props) { {!collapsed && (
- {/* Tab bar: Sources | Groups */}
- {/* Groups tab */} {treeTab === 'groups' && ( )} - {/* Sources tab contents */} - {treeTab === 'sources' && <> + {treeTab === 'sources' && ( + + - {/* Search / filter bar */} - +
+ + + + + + +
- {/* Toolbar: New Synthetic | Import CSV | Refresh */} -
- - - - -
- - {/* Signal groups */} -
- {loading ? ( -

Loading signals…

- ) : filtered.length === 0 ? ( -

No signals found.

- ) : ( - filtered.map(group => { - const groupInGroups = groups.find(g => g.ds === group.ds); - return ( -
-
- toggleGroup(group.ds)}> - {group.expanded ? 'β–Ύ' : 'β–Έ'} - - toggleGroup(group.ds)}> - {group.ds} - - {group.signals.length} - -
- - {group.expanded && ( -
- {group.signals.map(sig => { - const isCustom = !groups.find(g => g.ds === group.ds)?.signals.some(s => s.name === sig.name); - return ( -
- {sig.name} - {sig.unit && {sig.unit}} - {group.ds === 'synthetic' && ( - - )} - {isCustom && ( - - )} -
- ); - })} - - {/* Add-signal input row */} - {(groupInGroups?.addingSignal) && ( -
- 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); - }} - /> - - -
+
+ {loading ? ( +

Loading signals…

+ ) : visibleGroups.length === 0 ? ( +

No signals found.

+ ) : ( + visibleGroups.map(group => ( +
+
+ toggleGroup(group.id)}> + {group.expanded ? 'β–Ύ' : 'β–Έ'} + + toggleGroup(group.id)}> + {group.label} + + {group.signals.length} + {groupBy === 'datasource' && group.id !== 'synthetic' && ( + )}
- )} -
- ); - }) - )} -
- } + + {group.expanded && ( +
+ {addingTo === group.id && ( +
+ setAddValue((e.target as HTMLInputElement).value)} + onKeyDown={(e: KeyboardEvent) => { + if (e.key === 'Enter') commitAddManually(); + if (e.key === 'Escape') setAddingTo(null); + }} + /> + + +
+ )} + {group.signals.map(sig => { + const isCustom = sig.type === 'custom' || !rawSignals.some(rs => rs.ds === sig.ds && rs.name === sig.name); + return ( +
+ {sig.name} + {sig.unit && {sig.unit}} + {sig.ds === 'synthetic' && ( + + )} + {isCustom && ( + + )} +
+ ); + })} +
+ )} +
+ )) + )} +
+ + )}
)} {showWizard && ( setShowWizard(false)} - onCreated={loadGroups} + onCreated={loadAllSignals} /> )} @@ -371,9 +377,54 @@ export default function SignalTree({ onDragStart, width }: Props) { setEditSynthetic(null)} - onSaved={loadGroups} + onSaved={loadAllSignals} /> )} + + {showCfModal && ( + setShowCfModal(false)} + onAddSignals={handleAddCfSignals} + /> + )} + + {showManualAdd && ( +
setShowManualAdd(false)}> +
e.stopPropagation()} style="max-width: 400px;"> +
+ Add Signal Manually + +
+
+
+ + +
+
+ + setManualName((e.target as HTMLInputElement).value)} + onKeyDown={e => e.key === 'Enter' && handleManualAdd()} + /> +
+
+ +
+
+ )} ); } diff --git a/web/src/SyntheticEditor.tsx b/web/src/SyntheticEditor.tsx index ae41300..4d54247 100644 --- a/web/src/SyntheticEditor.tsx +++ b/web/src/SyntheticEditor.tsx @@ -1,6 +1,7 @@ import { h } from 'preact'; -import { useState, useEffect } from 'preact/hooks'; +import { useState, useEffect, useMemo } from 'preact/hooks'; import LuaEditor from './LuaEditor'; +import type { InputRef, SignalDef, PipelineNode } from './lib/types'; interface NodeParam { label: string; @@ -20,48 +21,126 @@ const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> = ]}, { type: 'derivative', label: 'Derivative', params: [] }, { type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] }, - { type: 'expr', label: 'Formula', params: [{ label: 'Expression (a=input)', key: 'expr', type: 'text', default: 'a * 1.0' }] }, - { type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a=input)', key: 'script', type: 'lua', default: 'return a' }] }, + { type: 'expr', label: 'Formula', params: [{ label: 'Expression (a,b,c,d = inputs)', key: 'expr', type: 'text', default: 'a * 1.0' }] }, + { type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a,b,c,d = inputs)', key: 'script', type: 'lua', default: 'return a' }] }, ]; -interface PipelineNode { - type: string; - params: Record; -} - -interface SignalDef { - name: string; - ds: string; - signal: string; - pipeline: PipelineNode[]; - meta: { - unit?: string; - description?: string; - displayLow?: number; - displayHigh?: number; - }; -} - interface Props { name: string; onClose: () => void; onSaved: () => void; } +interface DataSource { + name: string; +} + +interface SignalInfo { + name: string; + description?: string; +} + +// ── Searchable Selector ────────────────────────────────────────────────────── + +function SearchableSelect({ + value, + options, + onSelect, + placeholder = 'Select…' +}: { + value: string; + options: string[]; + onSelect: (val: string) => void; + placeholder?: string; +}) { + const [open, setOpen] = useState(false); + const [filter, setFilter] = useState(''); + + const filtered = useMemo(() => { + const f = filter.toLowerCase(); + return options.filter(o => o.toLowerCase().includes(f)); + }, [options, filter]); + + return ( +
+
setOpen(!open)}> + {value || {placeholder}} + {open ? 'β–΄' : 'β–Ύ'} +
+ {open && ( +
+ setFilter((e.target as HTMLInputElement).value)} + onClick={(e) => e.stopPropagation()} + /> +
+ {filtered.length === 0 &&
No matches
} + {filtered.map(opt => ( +
{ onSelect(opt); setOpen(false); setFilter(''); }} + > + {opt} +
+ ))} +
+
+ )} + {open &&
setOpen(false)} />} +
+ ); +} + +// ── Main Editor ────────────────────────────────────────────────────────────── + export default function SyntheticEditor({ name, onClose, onSaved }: Props) { const [def, setDef] = useState(null); + const [inputs, setInputs] = useState([]); const [pipeline, setPipeline] = useState([]); const [addType, setAddType] = useState('gain'); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); + // Loaded data for selectors + const [dataSources, setDataSources] = useState([]); + const [dsSignals, setDsSignals] = useState>({}); + + useEffect(() => { + fetch('/api/v1/datasources') + .then(r => r.ok ? r.json() : []) + .then((ds: DataSource[]) => setDataSources(ds.map(d => d.name))) + .catch(() => {}); + }, []); + + async function loadSignals(ds: string) { + if (!ds || dsSignals[ds]) return; + try { + const res = await fetch(`/api/v1/signals?ds=${encodeURIComponent(ds)}`); + if (!res.ok) return; + const sigs: SignalInfo[] = await res.json(); + setDsSignals(prev => ({ ...prev, [ds]: sigs.map(s => s.name) })); + } catch {} + } + useEffect(() => { fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`) .then(r => r.ok ? r.json() : Promise.reject(r.statusText)) .then((d: SignalDef) => { setDef(d); + const initialInputs = d.inputs && d.inputs.length > 0 + ? d.inputs + : (d.ds && d.signal ? [{ ds: d.ds, signal: d.signal }] : []); + setInputs(initialInputs); setPipeline(d.pipeline ?? []); + + // Load signals for existing input data sources + initialInputs.forEach(inp => loadSignals(inp.ds)); }) .catch(e => setError(String(e))) .finally(() => setLoading(false)); @@ -107,12 +186,42 @@ export default function SyntheticEditor({ name, onClose, onSaved }: Props) { setPipeline(p => [...p, { type: addType, params }]); } + function updateInput(idx: number, patch: Partial) { + setInputs(prev => prev.map((inp, i) => { + if (i !== idx) return inp; + const next = { ...inp, ...patch }; + if (patch.ds) { + next.signal = ''; + loadSignals(patch.ds); + } + return next; + })); + } + + function addInput() { + setInputs(prev => [...prev, { ds: dataSources[0] || '', signal: '' }]); + } + + function removeInput(idx: number) { + setInputs(prev => prev.filter((_, i) => i !== idx)); + } + async function handleSave() { if (!def) return; + if (inputs.length === 0) { setError('At least one input is required'); return; } + if (inputs.some(i => !i.ds || !i.signal)) { setError('All inputs must have a data source and signal'); return; } + setSaving(true); setError(''); try { - const body = { ...def, pipeline }; + const body = { + ...def, + inputs, + pipeline, + // Clear legacy fields if present + ds: undefined, + signal: undefined + }; const res = await fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -135,7 +244,7 @@ export default function SyntheticEditor({ name, onClose, onSaved }: Props) {
e.stopPropagation()} style="max-width:560px;">
- Edit Pipeline β€” {name} + Edit Synthetic Signal β€” {name}
@@ -145,6 +254,37 @@ export default function SyntheticEditor({ name, onClose, onSaved }: Props) {

Loading…

) : (
+
Input signals
+ {inputs.map((inp, idx) => ( +
+
+ + updateInput(idx, { ds })} + /> +
+
+ + updateInput(idx, { signal })} + placeholder={inp.ds ? 'Search signals…' : 'Select data source first'} + /> +
+ +
+ ))} + +
Pipeline nodes
{pipeline.length === 0 && (

No processing nodes β€” input passes through unchanged.

@@ -197,7 +337,7 @@ export default function SyntheticEditor({ name, onClose, onSaved }: Props) {
diff --git a/web/src/SyntheticWizard.tsx b/web/src/SyntheticWizard.tsx index 1c9ec5c..373285f 100644 --- a/web/src/SyntheticWizard.tsx +++ b/web/src/SyntheticWizard.tsx @@ -1,6 +1,7 @@ import { h } from 'preact'; -import { useState } from 'preact/hooks'; +import { useState, useEffect, useMemo } from 'preact/hooks'; import LuaEditor from './LuaEditor'; +import type { InputRef, SignalDef } from './lib/types'; interface Props { onClose: () => void; @@ -25,14 +26,80 @@ const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> = ]}, { type: 'derivative', label: 'Derivative', params: [] }, { type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] }, - { type: 'expr', label: 'Formula', params: [{ label: 'Expression (a=input)', key: 'expr', type: 'text', default: 'a * 1.0' }] }, - { type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a=input)', key: 'script', type: 'lua', default: 'return a' }] }, + { type: 'expr', label: 'Formula', params: [{ label: 'Expression (a,b,c,d = inputs)', key: 'expr', type: 'text', default: 'a * 1.0' }] }, + { type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a,b,c,d = inputs)', key: 'script', type: 'lua', default: 'return a' }] }, ]; +interface DataSource { + name: string; +} + +interface SignalInfo { + name: string; + description?: string; +} + +// ── Searchable Selector ────────────────────────────────────────────────────── + +function SearchableSelect({ + value, + options, + onSelect, + placeholder = 'Select…' +}: { + value: string; + options: string[]; + onSelect: (val: string) => void; + placeholder?: string; +}) { + const [open, setOpen] = useState(false); + const [filter, setFilter] = useState(''); + + const filtered = useMemo(() => { + const f = filter.toLowerCase(); + return options.filter(o => o.toLowerCase().includes(f)); + }, [options, filter]); + + return ( +
+
setOpen(!open)}> + {value || {placeholder}} + {open ? 'β–΄' : 'β–Ύ'} +
+ {open && ( +
+ setFilter((e.target as HTMLInputElement).value)} + onClick={(e) => e.stopPropagation()} + /> +
+ {filtered.length === 0 &&
No matches
} + {filtered.map(opt => ( +
{ onSelect(opt); setOpen(false); setFilter(''); }} + > + {opt} +
+ ))} +
+
+ )} + {open &&
setOpen(false)} />} +
+ ); +} + +// ── Main Wizard ────────────────────────────────────────────────────────────── + export default function SyntheticWizard({ onClose, onCreated }: Props) { const [name, setName] = useState(''); - const [inputDs, setInputDs] = useState('stub'); - const [inputSig, setInputSig] = useState('sine_1hz'); + const [inputs, setInputs] = useState([{ ds: 'stub', signal: 'sine_1hz' }]); const [nodeType, setNodeType] = useState('gain'); const [params, setParams] = useState>({}); const [unit, setUnit] = useState(''); @@ -42,6 +109,27 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) { const [saving, setSaving] = useState(false); const [error, setError] = useState(''); + // Loaded data + const [dataSources, setDataSources] = useState([]); + const [dsSignals, setDsSignals] = useState>({}); + + useEffect(() => { + fetch('/api/v1/datasources') + .then(r => r.ok ? r.json() : []) + .then((ds: DataSource[]) => setDataSources(ds.map(d => d.name))) + .catch(() => {}); + }, []); + + async function loadSignals(ds: string) { + if (dsSignals[ds]) return; + try { + const res = await fetch(`/api/v1/signals?ds=${encodeURIComponent(ds)}`); + if (!res.ok) return; + const sigs: SignalInfo[] = await res.json(); + setDsSignals(prev => ({ ...prev, [ds]: sigs.map(s => s.name) })); + } catch {} + } + const nodeDef = NODE_TYPES.find(n => n.type === nodeType)!; function getParam(key: string, def: string): string { @@ -52,9 +140,29 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) { setParams(p => ({ ...p, [key]: val })); } + function updateInput(idx: number, patch: Partial) { + setInputs(prev => prev.map((inp, i) => { + if (i !== idx) return inp; + const next = { ...inp, ...patch }; + if (patch.ds) { + next.signal = ''; + loadSignals(patch.ds); + } + return next; + })); + } + + function addInput() { + setInputs(prev => [...prev, { ds: dataSources[0] || '', signal: '' }]); + } + + function removeInput(idx: number) { + setInputs(prev => prev.filter((_, i) => i !== idx)); + } + async function handleCreate() { if (!name.trim()) { setError('Name is required'); return; } - if (!inputSig.trim()) { setError('Input signal is required'); return; } + if (inputs.some(i => !i.ds || !i.signal)) { setError('All inputs must have a data source and signal'); return; } const nodeParams: Record = {}; for (const p of nodeDef.params) { @@ -62,10 +170,9 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) { nodeParams[p.key] = p.type === 'number' ? parseFloat(raw) : raw; } - const def = { + const def: SignalDef = { name: name.trim(), - ds: inputDs, - signal: inputSig.trim(), + inputs, pipeline: [{ type: nodeType, params: nodeParams }], meta: { unit: unit || undefined, @@ -115,23 +222,38 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) { onInput={(e) => setName((e.target as HTMLInputElement).value)} />
-
Input signal
-
-
- - setInputDs((e.target as HTMLInputElement).value)} /> +
Input signals
+ {inputs.map((inp, idx) => ( +
+
+ + updateInput(idx, { ds })} + /> +
+
+ + updateInput(idx, { signal })} + placeholder={inp.ds ? 'Search signals…' : 'Select data source first'} + /> +
+
-
- - setInputSig((e.target as HTMLInputElement).value)} /> -
-
+ ))} + -
Processing
+
Processing