Wworking on improving the tool
This commit is contained in:
+9
-2
@@ -88,7 +88,14 @@ func main() {
|
|||||||
// The pure-Go implementation is used by default (no CGo required).
|
// The pure-Go implementation is used by default (no CGo required).
|
||||||
// Build with -tags epics to use the CGo-based libca implementation instead.
|
// Build with -tags epics to use the CGo-based libca implementation instead.
|
||||||
if cfg.Datasource.EPICS.Enabled && epics.Available() {
|
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 {
|
if err := ds.Connect(ctx); err != nil {
|
||||||
log.Error("epics connect", "err", err)
|
log.Error("epics connect", "err", err)
|
||||||
} else {
|
} 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 {
|
if err := srv.Start(ctx); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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://<machine-ip>: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.
|
||||||
+63
-2
@@ -7,6 +7,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/uopi/uopi/internal/broker"
|
"github.com/uopi/uopi/internal/broker"
|
||||||
@@ -21,12 +22,20 @@ type Handler struct {
|
|||||||
synthetic *synthetic.Synthetic // nil if not enabled
|
synthetic *synthetic.Synthetic // nil if not enabled
|
||||||
store *storage.Store
|
store *storage.Store
|
||||||
channelFinderURL string // empty if not configured
|
channelFinderURL string // empty if not configured
|
||||||
|
archiverURL string // empty if not configured
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates an API Handler. synth may be nil if the synthetic DS is disabled.
|
// 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 {
|
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, log: log}
|
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.
|
// 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", h.listSignals)
|
||||||
mux.HandleFunc("GET "+prefix+"/signals/search", h.searchSignals)
|
mux.HandleFunc("GET "+prefix+"/signals/search", h.searchSignals)
|
||||||
mux.HandleFunc("GET "+prefix+"/channel-finder", h.channelFinder)
|
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("GET "+prefix+"/interfaces", h.listInterfaces)
|
||||||
mux.HandleFunc("POST "+prefix+"/interfaces", h.createInterface)
|
mux.HandleFunc("POST "+prefix+"/interfaces", h.createInterface)
|
||||||
mux.HandleFunc("GET "+prefix+"/interfaces/{id}", h.getInterface)
|
mux.HandleFunc("GET "+prefix+"/interfaces/{id}", h.getInterface)
|
||||||
@@ -80,6 +90,8 @@ type signalInfo struct {
|
|||||||
DisplayHigh float64 `json:"displayHigh"`
|
DisplayHigh float64 `json:"displayHigh"`
|
||||||
Writable bool `json:"writable"`
|
Writable bool `json:"writable"`
|
||||||
EnumStrings []string `json:"enumStrings,omitempty"`
|
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) {
|
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"
|
cfURL := strings.TrimRight(h.channelFinderURL, "/") + "/resources/channels"
|
||||||
if q != "" {
|
if q != "" {
|
||||||
cfURL += "?~name=*" + 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
|
resp, err := http.Get(cfURL) //nolint:noctx
|
||||||
@@ -187,6 +205,47 @@ func (h *Handler) channelFinder(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonOK(w, names)
|
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 ───────────────────────────────────────────────────────────────────
|
// ── /groups ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// getGroups returns the workspace group tree as a JSON array.
|
// getGroups returns the workspace group tree as a JSON array.
|
||||||
@@ -416,6 +475,8 @@ func metaToSignalInfo(m datasource.Metadata) signalInfo {
|
|||||||
DisplayHigh: m.DisplayHigh,
|
DisplayHigh: m.DisplayHigh,
|
||||||
Writable: m.Writable,
|
Writable: m.Writable,
|
||||||
EnumStrings: m.EnumStrings,
|
EnumStrings: m.EnumStrings,
|
||||||
|
Properties: m.Properties,
|
||||||
|
Tags: m.Tags,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func setup(t *testing.T) (*httptest.Server, func()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
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)
|
srv := httptest.NewServer(mux)
|
||||||
return srv, func() {
|
return srv, func() {
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ type EPICSConfig struct {
|
|||||||
CAAddrList string `toml:"ca_addr_list"`
|
CAAddrList string `toml:"ca_addr_list"`
|
||||||
ArchiveURL string `toml:"archive_url"`
|
ArchiveURL string `toml:"archive_url"`
|
||||||
ChannelFinderURL string `toml:"channel_finder_url"`
|
ChannelFinderURL string `toml:"channel_finder_url"`
|
||||||
|
AutoSyncFilter string `toml:"auto_sync_filter"`
|
||||||
|
AutoSyncFromArchiver bool `toml:"auto_sync_from_archiver"`
|
||||||
PVNames []string `toml:"pv_names"`
|
PVNames []string `toml:"pv_names"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +102,12 @@ func applyEnv(cfg *Config) {
|
|||||||
if v := env("UOPI_EPICS_CHANNEL_FINDER_URL"); v != "" {
|
if v := env("UOPI_EPICS_CHANNEL_FINDER_URL"); v != "" {
|
||||||
cfg.Datasource.EPICS.ChannelFinderURL = 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 != "" {
|
if v := env("EPICS_PVA_ADDR_LIST"); v != "" {
|
||||||
cfg.Datasource.PVA.AddrList = strings.Fields(v)
|
cfg.Datasource.PVA.AddrList = strings.Fields(v)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/uopi/uopi/internal/datasource"
|
"github.com/uopi/uopi/internal/datasource"
|
||||||
@@ -117,6 +118,55 @@ func fetchArchiveHistory(ctx context.Context, archiveURL, signal string, start,
|
|||||||
return out, nil
|
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
|
// coerceArchiveValue converts the raw JSON value (which json.Unmarshal decodes
|
||||||
// as float64, string, bool, []interface{}, or nil) into one of the types
|
// as float64, string, bool, []interface{}, or nil) into one of the types
|
||||||
// accepted by datasource.Value.Data.
|
// accepted by datasource.Value.Data.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import "C"
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -68,8 +69,12 @@ type caChannel struct {
|
|||||||
type EPICS struct {
|
type EPICS struct {
|
||||||
caAddrList string
|
caAddrList string
|
||||||
archiveURL 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
|
// because the C type (ca_client_context *) is opaque. Every goroutine
|
||||||
// that calls CA functions must call caAttachContext(caCtx) first, because
|
// that calls CA functions must call caAttachContext(caCtx) first, because
|
||||||
// Go goroutines can run on any OS thread and CA contexts are thread-local.
|
// Go goroutines can run on any OS thread and CA contexts are thread-local.
|
||||||
@@ -87,10 +92,13 @@ type EPICS struct {
|
|||||||
// Appliance instance for history queries (may be empty).
|
// Appliance instance for history queries (may be empty).
|
||||||
// pvNames is accepted for API compatibility with the pure-Go build but ignored
|
// pvNames is accepted for API compatibility with the pure-Go build but ignored
|
||||||
// in the CGo build (the CGo implementation populates ListSignals via callbacks).
|
// 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{
|
return &EPICS{
|
||||||
caAddrList: caAddrList,
|
caAddrList: caAddrList,
|
||||||
archiveURL: archiveURL,
|
archiveURL: archiveURL,
|
||||||
|
cfURL: cfURL,
|
||||||
|
autoSyncFilter: autoSyncFilter,
|
||||||
|
autoSyncFromArchiver: autoSyncFromArchiver,
|
||||||
channels: make(map[string]*caChannel),
|
channels: make(map[string]*caChannel),
|
||||||
metadata: make(map[string]datasource.Metadata),
|
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.
|
// Save the context so goroutines on other OS threads can attach to it.
|
||||||
e.caCtx = C.caCurrentContext()
|
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
|
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.
|
// attachCAContext attaches the saved CA context to the calling OS thread.
|
||||||
// Must be called at the top of every goroutine that uses CA functions,
|
// Must be called at the top of every goroutine that uses CA functions,
|
||||||
// because Go goroutines can be scheduled onto any OS thread.
|
// 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.
|
// 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) {
|
func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
|
||||||
e.mu.Lock()
|
e.mu.Lock()
|
||||||
defer e.mu.Unlock()
|
defer e.mu.Unlock()
|
||||||
|
|
||||||
|
slog.Debug("epics: listing signals", "count", len(e.metadata))
|
||||||
|
|
||||||
// Start with all fully-fetched metadata entries.
|
// Start with all fully-fetched metadata entries.
|
||||||
out := make([]datasource.Metadata, 0, len(e.channels))
|
out := make([]datasource.Metadata, 0, len(e.channels))
|
||||||
seen := make(map[string]bool, len(e.metadata))
|
seen := make(map[string]bool, len(e.metadata))
|
||||||
@@ -583,3 +643,27 @@ func nativeTimeType(chid C.chid) C.chtype {
|
|||||||
return C.DBR_TIME_DOUBLE
|
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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func newTestDS(t *testing.T, pvs []testca.PVSpec) (datasource.DataSource, *testc
|
|||||||
}
|
}
|
||||||
t.Cleanup(srv.Close)
|
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)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
t.Cleanup(cancel)
|
t.Cleanup(cancel)
|
||||||
if err := ds.Connect(ctx); err != nil {
|
if err := ds.Connect(ctx); err != nil {
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ func Available() bool { return true }
|
|||||||
type EPICS struct {
|
type EPICS struct {
|
||||||
caAddrList string
|
caAddrList string
|
||||||
archiveURL string
|
archiveURL string
|
||||||
|
cfURL string
|
||||||
|
autoSyncFilter string
|
||||||
|
autoSyncFromArchiver bool
|
||||||
pvNames []string // pre-fetched at connect time for ListSignals
|
pvNames []string // pre-fetched at connect time for ListSignals
|
||||||
|
|
||||||
client *ca.Client
|
client *ca.Client
|
||||||
@@ -47,10 +50,13 @@ type EPICS struct {
|
|||||||
// pvNames is an optional list of PV names whose metadata will be pre-fetched
|
// 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
|
// at connect time so they appear in ListSignals immediately (useful for the
|
||||||
// edit-mode signal tree).
|
// 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{
|
return &EPICS{
|
||||||
caAddrList: caAddrList,
|
caAddrList: caAddrList,
|
||||||
archiveURL: archiveURL,
|
archiveURL: archiveURL,
|
||||||
|
cfURL: cfURL,
|
||||||
|
autoSyncFilter: autoSyncFilter,
|
||||||
|
autoSyncFromArchiver: autoSyncFromArchiver,
|
||||||
pvNames: pvNames,
|
pvNames: pvNames,
|
||||||
metadata: make(map[string]datasource.Metadata),
|
metadata: make(map[string]datasource.Metadata),
|
||||||
}
|
}
|
||||||
@@ -75,16 +81,73 @@ func (e *EPICS) Connect(ctx context.Context) error {
|
|||||||
e.client = cli
|
e.client = cli
|
||||||
slog.Info("epics: CA client started", "addrs", cfg.AddrList)
|
slog.Info("epics: CA client started", "addrs", cfg.AddrList)
|
||||||
|
|
||||||
// Pre-fetch metadata for any configured PV names so they appear in
|
// Perform background sync from Channel Finder if configured.
|
||||||
// ListSignals as soon as the datasource is ready. We open a short-lived
|
if e.cfURL != "" && e.autoSyncFilter != "" {
|
||||||
// subscription (which drives channel connection) and then fetch CTRL info.
|
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 {
|
if len(e.pvNames) > 0 {
|
||||||
go func() {
|
go func() {
|
||||||
for _, pv := range e.pvNames {
|
for _, pv := range e.pvNames {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
func() {
|
e.prefetchPV(ctx, pv)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
tvCh := make(chan proto.TimeValue, 1)
|
||||||
subCtx, subCancel := context.WithTimeout(ctx, 15*time.Second)
|
subCtx, subCancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
defer subCancel()
|
defer subCancel()
|
||||||
@@ -97,12 +160,6 @@ func (e *EPICS) Connect(ctx context.Context) error {
|
|||||||
mctx, mcancel := context.WithTimeout(ctx, 5*time.Second)
|
mctx, mcancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
defer mcancel()
|
defer mcancel()
|
||||||
_, _ = e.GetMetadata(mctx, pv)
|
_, _ = e.GetMetadata(mctx, pv)
|
||||||
}()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------- //
|
// -------------------------------------------------------------------------- //
|
||||||
@@ -273,6 +330,7 @@ func (e *EPICS) ctrlToMeta(name string, ci ca.CtrlInfo) datasource.Metadata {
|
|||||||
func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
|
func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
|
||||||
e.mu.RLock()
|
e.mu.RLock()
|
||||||
defer e.mu.RUnlock()
|
defer e.mu.RUnlock()
|
||||||
|
slog.Debug("epics: listing signals", "count", len(e.metadata))
|
||||||
out := make([]datasource.Metadata, 0, len(e.metadata))
|
out := make([]datasource.Metadata, 0, len(e.metadata))
|
||||||
for _, m := range e.metadata {
|
for _, m := range e.metadata {
|
||||||
out = append(out, m)
|
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)
|
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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ type Metadata struct {
|
|||||||
DriveHigh float64
|
DriveHigh float64
|
||||||
EnumStrings []string // non-nil only for TypeEnum
|
EnumStrings []string // non-nil only for TypeEnum
|
||||||
Writable bool
|
Writable bool
|
||||||
|
Properties map[string]string // Channel Finder properties
|
||||||
|
Tags []string // Channel Finder tags
|
||||||
}
|
}
|
||||||
|
|
||||||
// CancelFunc cancels a subscription started by DataSource.Subscribe.
|
// CancelFunc cancels a subscription started by DataSource.Subscribe.
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ type Server struct {
|
|||||||
|
|
||||||
// New creates the HTTP server, registers all routes, and returns a ready-to-start Server.
|
// 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.
|
// 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()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// Health check
|
// 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))
|
mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions))
|
||||||
|
|
||||||
// REST API
|
// 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)
|
// Embedded frontend — must be last (catch-all)
|
||||||
mux.Handle("/", http.FileServerFS(webFS))
|
mux.Handle("/", http.FileServerFS(webFS))
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ type metaPayload struct {
|
|||||||
DriveHigh float64 `json:"driveHigh,omitempty"`
|
DriveHigh float64 `json:"driveHigh,omitempty"`
|
||||||
EnumStrings []string `json:"enumStrings,omitempty"`
|
EnumStrings []string `json:"enumStrings,omitempty"`
|
||||||
Writable bool `json:"writable"`
|
Writable bool `json:"writable"`
|
||||||
|
Properties map[string]string `json:"properties,omitempty"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type histPoint struct {
|
type histPoint struct {
|
||||||
@@ -396,6 +398,8 @@ func metadataToPayload(m datasource.Metadata) *metaPayload {
|
|||||||
DriveHigh: m.DriveHigh,
|
DriveHigh: m.DriveHigh,
|
||||||
EnumStrings: m.EnumStrings,
|
EnumStrings: m.EnumStrings,
|
||||||
Writable: m.Writable,
|
Writable: m.Writable,
|
||||||
|
Properties: m.Properties,
|
||||||
|
Tags: m.Tags,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
@@ -80,10 +81,16 @@ func (s *Store) List() ([]InterfaceMeta, error) {
|
|||||||
}
|
}
|
||||||
var out []InterfaceMeta
|
var out []InterfaceMeta
|
||||||
for _, e := range entries {
|
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
|
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)
|
meta, err := s.readMeta(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue // skip corrupt files silently
|
continue // skip corrupt files silently
|
||||||
@@ -96,6 +103,20 @@ func (s *Store) List() ([]InterfaceMeta, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isVersioned(name string) bool {
|
||||||
|
// Format: <id>.v<number>.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.
|
// rootAttrs is used to parse only the top-level XML attributes.
|
||||||
type rootAttrs struct {
|
type rootAttrs struct {
|
||||||
XMLName xml.Name `xml:"interface"`
|
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 {
|
if _, err := os.Stat(s.filePath(id)); err == nil {
|
||||||
id = id + "-" + fmt.Sprintf("%d", time.Now().UnixMilli())
|
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 {
|
func (s *Store) Update(id string, xmlData []byte) error {
|
||||||
if err := validateID(id); err != nil {
|
if err := validateID(id); err != nil {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(s.filePath(id)); errors.Is(err, os.ErrNotExist) {
|
|
||||||
|
oldPath := s.filePath(id)
|
||||||
|
oldData, err := os.ReadFile(oldPath)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
return os.WriteFile(s.filePath(id), xmlData, 0o644)
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 <tag />
|
||||||
|
}
|
||||||
|
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.
|
// Delete removes the interface with the given ID.
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ func main() {
|
|||||||
// Copy uPlot CSS and favicon
|
// Copy uPlot CSS and favicon
|
||||||
copyFile(filepath.Join(root, "web", "vendor", "uplot.css"), filepath.Join(distDir, "uplot.css"))
|
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.svg"))
|
||||||
|
copyFile(filepath.Join(root, "web", "public", "favicon.svg"), filepath.Join(distDir, "favicon.ico"))
|
||||||
|
|
||||||
// Write index.html
|
// Write index.html
|
||||||
html := `<!doctype html>
|
html := `<!doctype html>
|
||||||
@@ -88,6 +89,7 @@ func main() {
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>uopi</title>
|
<title>uopi</title>
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="stylesheet" href="/uplot.css" />
|
<link rel="stylesheet" href="/uplot.css" />
|
||||||
<link rel="stylesheet" href="/main.css" />
|
<link rel="stylesheet" href="/main.css" />
|
||||||
|
|||||||
+3
-1
@@ -5,7 +5,9 @@ storage_dir = "./interfaces"
|
|||||||
[datasource.epics]
|
[datasource.epics]
|
||||||
enabled = true
|
enabled = true
|
||||||
ca_addr_list = "" # overrides EPICS_CA_ADDR_LIST if set
|
ca_addr_list = "" # overrides EPICS_CA_ADDR_LIST if set
|
||||||
archive_url = "" # EPICS Archive Appliance base URL, e.g. http://archiver:17665/
|
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]
|
[datasource.synthetic]
|
||||||
enabled = true
|
enabled = true
|
||||||
|
|||||||
@@ -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<SearchSource>('cfs');
|
||||||
|
|
||||||
|
// CFS State
|
||||||
|
const [rules, setRules] = useState<Rule[]>([{ type: 'name', value: '' }]);
|
||||||
|
const [cfsResults, setCfsResults] = useState<Array<{ name: string, properties: any, tags: any }>>([]);
|
||||||
|
|
||||||
|
// Archiver State
|
||||||
|
const [archPattern, setArchPattern] = useState('');
|
||||||
|
const [archResults, setArchResults] = useState<string[]>([]);
|
||||||
|
|
||||||
|
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<Rule>) {
|
||||||
|
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 (
|
||||||
|
<div class="wizard-backdrop" onClick={onClose}>
|
||||||
|
<div class="wizard" onClick={e => e.stopPropagation()} style="max-width: 750px; height: 85vh;">
|
||||||
|
<div class="wizard-header">
|
||||||
|
<span>PV Discovery</span>
|
||||||
|
<button class="icon-btn" onClick={onClose}>✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wizard-body" style="display: flex; flex-direction: column;">
|
||||||
|
{/* Source Tabs */}
|
||||||
|
<div class="signal-tree-tabs" style="margin-bottom: 1.5rem;">
|
||||||
|
<button
|
||||||
|
class={`stab${source === 'cfs' ? ' stab-active' : ''}`}
|
||||||
|
onClick={() => { setSource('cfs'); setError(''); }}
|
||||||
|
>Channel Finder</button>
|
||||||
|
<button
|
||||||
|
class={`stab${source === 'archiver' ? ' stab-active' : ''}`}
|
||||||
|
onClick={() => { setSource('archiver'); setError(''); }}
|
||||||
|
>Archive Appliance</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{source === 'cfs' ? (
|
||||||
|
<div style="flex: 0 0 auto;">
|
||||||
|
<div class="wizard-section-title">Channel Finder Rules</div>
|
||||||
|
<p class="hint" style="padding: 0 0 0.5rem 0;">Filter by name, tags, or properties.</p>
|
||||||
|
|
||||||
|
{rules.map((rule, idx) => (
|
||||||
|
<div key={idx} class="wizard-field wizard-field-row" style="align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||||
|
<select
|
||||||
|
class="prop-select"
|
||||||
|
style="flex: 0 0 100px;"
|
||||||
|
value={rule.type}
|
||||||
|
onChange={e => updateRule(idx, { type: (e.target as HTMLSelectElement).value as any })}
|
||||||
|
>
|
||||||
|
<option value="name">Name</option>
|
||||||
|
<option value="tag">Tag</option>
|
||||||
|
<option value="property">Property</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{rule.type === 'property' && (
|
||||||
|
<input
|
||||||
|
class="prop-input"
|
||||||
|
style="flex: 1;"
|
||||||
|
placeholder="Property name"
|
||||||
|
value={rule.key}
|
||||||
|
onInput={e => updateRule(idx, { key: (e.target as HTMLInputElement).value })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
class="prop-input"
|
||||||
|
style="flex: 2;"
|
||||||
|
placeholder={rule.type === 'name' ? 'PV name pattern' : 'Value'}
|
||||||
|
value={rule.value}
|
||||||
|
onInput={e => updateRule(idx, { value: (e.target as HTMLInputElement).value })}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleCfsSearch()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="icon-btn"
|
||||||
|
onClick={() => removeRule(idx)}
|
||||||
|
disabled={rules.length === 1}
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
|
||||||
|
<button class="panel-btn" onClick={addRule}>+ Add Rule</button>
|
||||||
|
<button
|
||||||
|
class="toolbar-btn toolbar-btn-primary"
|
||||||
|
onClick={handleCfsSearch}
|
||||||
|
disabled={searching}
|
||||||
|
style="padding: 0 1.5rem;"
|
||||||
|
>
|
||||||
|
{searching ? 'Searching…' : 'Search CFS'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style="flex: 0 0 auto;">
|
||||||
|
<div class="wizard-section-title">Archiver Search</div>
|
||||||
|
<p class="hint" style="padding: 0 0 0.5rem 0;">Search for archived PVs using glob patterns (e.g. <code>*TEMP*</code>).</p>
|
||||||
|
|
||||||
|
<div class="wizard-field wizard-field-row" style="gap: 0.5rem;">
|
||||||
|
<input
|
||||||
|
class="prop-input"
|
||||||
|
style="flex: 1;"
|
||||||
|
autoFocus
|
||||||
|
placeholder="PV pattern..."
|
||||||
|
value={archPattern}
|
||||||
|
onInput={e => setArchPattern((e.target as HTMLInputElement).value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleArchSearch()}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="toolbar-btn toolbar-btn-primary"
|
||||||
|
onClick={handleArchSearch}
|
||||||
|
disabled={searching || !archPattern.trim()}
|
||||||
|
style="padding: 0 1.5rem;"
|
||||||
|
>
|
||||||
|
{searching ? 'Searching…' : 'Search Archiver'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div class="wizard-section-title" style="margin-top: 1.5rem; display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<span>Results {resultsCount > 0 && `(${resultsCount})`}</span>
|
||||||
|
{resultsCount > 0 && (
|
||||||
|
<button class="panel-btn" style="font-size: 0.7rem; padding: 0.1rem 0.4rem;" onClick={handleAddAll}>Add All to Sidebar</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p class="wizard-error">{error}</p>}
|
||||||
|
|
||||||
|
<div class="panel-list" style="flex: 1; border: 1px solid #2d3748; border-radius: 4px; background: #0f1117; overflow-y: auto;">
|
||||||
|
{resultsCount === 0 ? (
|
||||||
|
<p class="hint" style="text-align: center; padding: 2rem;">
|
||||||
|
{searching ? 'Searching…' : 'No results to show.'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul class="iface-list">
|
||||||
|
{source === 'cfs' ? (
|
||||||
|
cfsResults.map(r => (
|
||||||
|
<li key={r.name} class="iface-item" style="cursor: default;">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 2px;">
|
||||||
|
<span style="font-family: ui-monospace, monospace; color: #e2e8f0;">{r.name}</span>
|
||||||
|
<div style="display: flex; gap: 4px; flex-wrap: wrap;">
|
||||||
|
{r.tags?.map((t: any) => (
|
||||||
|
<span key={t.name} style="font-size: 0.65rem; background: #1e293b; color: #94a3b8; padding: 0 4px; border-radius: 3px; border: 1px solid #334155;">
|
||||||
|
{t.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="icon-btn"
|
||||||
|
title="Add to sidebar"
|
||||||
|
onClick={() => onAddSignals([{ ds: 'epics', name: r.name }])}
|
||||||
|
>+</button>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
archResults.map(name => (
|
||||||
|
<li key={name} class="iface-item" style="cursor: default;">
|
||||||
|
<span style="font-family: ui-monospace, monospace; color: #e2e8f0;">{name}</span>
|
||||||
|
<button
|
||||||
|
class="icon-btn"
|
||||||
|
title="Add to sidebar"
|
||||||
|
onClick={() => onAddSignals([{ ds: 'epics', name }])}
|
||||||
|
>+</button>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wizard-footer">
|
||||||
|
<button class="toolbar-btn" onClick={onClose}>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -210,6 +210,7 @@ export default function EditMode({ initial, onDone }: Props) {
|
|||||||
setIface(f => ({ ...f, id: json.id }));
|
setIface(f => ({ ...f, id: json.id }));
|
||||||
}
|
}
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
|
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -254,6 +255,9 @@ export default function EditMode({ initial, onDone }: Props) {
|
|||||||
onDone(iface);
|
onDone(iface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clipboard
|
||||||
|
const clipboard = useRef<Widget[]>([]);
|
||||||
|
|
||||||
// ── Keyboard shortcuts ─────────────────────────────────────────────────────
|
// ── Keyboard shortcuts ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
@@ -268,6 +272,33 @@ export default function EditMode({ initial, onDone }: Props) {
|
|||||||
setDirty(true);
|
setDirty(true);
|
||||||
return;
|
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 === '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 === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; }
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
||||||
|
|||||||
@@ -643,6 +643,8 @@ function SectionHistory() {
|
|||||||
|
|
||||||
function SectionShortcuts() {
|
function SectionShortcuts() {
|
||||||
const shortcuts: Array<[string, string]> = [
|
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+Z', 'Undo last change (Edit mode)'],
|
||||||
['Ctrl+Y / Ctrl+Shift+Z', 'Redo (Edit mode)'],
|
['Ctrl+Y / Ctrl+Shift+Z', 'Redo (Edit mode)'],
|
||||||
['Ctrl+A', 'Select all widgets (Edit mode)'],
|
['Ctrl+A', 'Select all widgets (Edit mode)'],
|
||||||
|
|||||||
@@ -89,6 +89,37 @@ export default function InfoPanel({ signal, onClose }: Props) {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
{meta.tags && meta.tags.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div class="info-sep" />
|
||||||
|
<div class="info-section-hdr">Tags</div>
|
||||||
|
<div style="display: flex; gap: 4px; flex-wrap: wrap; padding: 4px 0.75rem;">
|
||||||
|
{meta.tags.map(t => (
|
||||||
|
<span key={t} style="font-size: 0.7rem; background: #1e293b; color: #94a3b8; padding: 1px 6px; border-radius: 4px; border: 1px solid #334155;">
|
||||||
|
{t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{meta.properties && Object.keys(meta.properties).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div class="info-sep" />
|
||||||
|
<div class="info-section-hdr">Properties</div>
|
||||||
|
<table class="info-table">
|
||||||
|
<tbody>
|
||||||
|
{Object.entries(meta.properties).map(([k, v]) => (
|
||||||
|
<tr key={k}>
|
||||||
|
<td class="info-key">{k}</td>
|
||||||
|
<td class="info-val">{v}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{meta.enumStrings && meta.enumStrings.length > 0 && (
|
{meta.enumStrings && meta.enumStrings.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div class="info-sep" />
|
<div class="info-sep" />
|
||||||
|
|||||||
@@ -31,7 +31,12 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId, widt
|
|||||||
.finally(() => setLoading(false));
|
.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() {
|
function triggerImport() {
|
||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{w && (
|
{w && (
|
||||||
<div class="props-section">
|
<div class="props-section" key={w.id}>
|
||||||
<div class="props-section-title">{w.type}</div>
|
<div class="props-section-title">{w.type}</div>
|
||||||
|
|
||||||
{/* Position / size */}
|
{/* Position / size */}
|
||||||
|
|||||||
+197
-146
@@ -1,11 +1,13 @@
|
|||||||
import { h, Fragment } from 'preact';
|
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 type { SignalRef } from './lib/types';
|
||||||
import SyntheticWizard from './SyntheticWizard';
|
import SyntheticWizard from './SyntheticWizard';
|
||||||
import SyntheticEditor from './SyntheticEditor';
|
import SyntheticEditor from './SyntheticEditor';
|
||||||
import GroupsTree from './GroupsTree';
|
import GroupsTree from './GroupsTree';
|
||||||
|
import ChannelFinderModal from './ChannelFinderModal';
|
||||||
|
|
||||||
type TreeTab = 'sources' | 'groups';
|
type TreeTab = 'sources' | 'groups';
|
||||||
|
type GroupBy = 'datasource' | 'area' | 'system' | 'ioc';
|
||||||
|
|
||||||
interface SignalInfo {
|
interface SignalInfo {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -13,14 +15,17 @@ interface SignalInfo {
|
|||||||
unit?: string;
|
unit?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
writable?: boolean;
|
writable?: boolean;
|
||||||
|
properties?: Record<string, string>;
|
||||||
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DsGroup {
|
interface DisplayGroup {
|
||||||
ds: string;
|
id: string;
|
||||||
signals: SignalInfo[];
|
label: string;
|
||||||
|
signals: Array<SignalInfo & { ds: string }>;
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
addingSignal: boolean; // show add-PV input
|
addingSignal?: boolean;
|
||||||
addValue: string;
|
addValue?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom signals persisted in localStorage
|
// Custom signals persisted in localStorage
|
||||||
@@ -45,35 +50,44 @@ interface Props {
|
|||||||
|
|
||||||
export default function SignalTree({ onDragStart, width }: Props) {
|
export default function SignalTree({ onDragStart, width }: Props) {
|
||||||
const [treeTab, setTreeTab] = useState<TreeTab>('sources');
|
const [treeTab, setTreeTab] = useState<TreeTab>('sources');
|
||||||
|
const [groupBy, setGroupBy] = useState<GroupBy>('datasource');
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const [groups, setGroups] = useState<DsGroup[]>([]);
|
const [rawSignals, setRawSignals] = useState<Array<SignalInfo & { ds: string }>>([]);
|
||||||
const [customSignals, setCustomSignals] = useState<Array<{ ds: string; name: string }>>(loadCustom);
|
const [customSignals, setCustomSignals] = useState<Array<{ ds: string; name: string }>>(loadCustom);
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
|
||||||
const [filter, setFilter] = useState('');
|
const [filter, setFilter] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [cfAvailable, setCfAvailable] = useState(false);
|
const [cfAvailable, setCfAvailable] = useState(false);
|
||||||
const [cfSearching, setCfSearching] = useState(false);
|
|
||||||
const [showWizard, setShowWizard] = 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<string | null>(null);
|
||||||
|
const [addValue, setAddValue] = useState('');
|
||||||
const [editSynthetic, setEditSynthetic] = useState<string | null>(null);
|
const [editSynthetic, setEditSynthetic] = useState<string | null>(null);
|
||||||
const csvRef = useRef<HTMLInputElement>(null);
|
const csvRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
async function loadGroups() {
|
async function loadAllSignals() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/v1/datasources');
|
const res = await fetch('/api/v1/datasources');
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const dsList: { name: string }[] = await res.json();
|
const dsList: { name: string }[] = await res.json();
|
||||||
const loaded: DsGroup[] = await Promise.all(
|
|
||||||
|
const all: Array<SignalInfo & { ds: string }> = [];
|
||||||
|
await Promise.all(
|
||||||
dsList.map(async ({ name }) => {
|
dsList.map(async ({ name }) => {
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/v1/signals?ds=${encodeURIComponent(name)}`);
|
const r = await fetch(`/api/v1/signals?ds=${encodeURIComponent(name)}`);
|
||||||
const sigs: SignalInfo[] = r.ok ? await r.json() : [];
|
if (r.ok) {
|
||||||
return { ds: name, signals: sigs, expanded: true, addingSignal: false, addValue: '' };
|
const sigs: SignalInfo[] = await r.json();
|
||||||
} catch {
|
all.push(...sigs.map(s => ({ ...s, ds: name })));
|
||||||
return { ds: name, signals: [], expanded: true, addingSignal: false, addValue: '' };
|
|
||||||
}
|
}
|
||||||
|
} catch (e) { console.error(`Failed to load ${name}:`, e); }
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
setGroups(loaded);
|
setRawSignals(all);
|
||||||
|
|
||||||
// Check if Channel Finder is available
|
// Check if Channel Finder is available
|
||||||
const cf = await fetch('/api/v1/channel-finder?q=').catch(() => null);
|
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
|
// Merge custom signals that might not be in ListSignals yet
|
||||||
const allGroups = groups.map(g => {
|
const allSignals = useMemo(() => {
|
||||||
const extra = customSignals.filter(c => c.ds === g.ds);
|
const existing = new Set(rawSignals.map(s => `${s.ds}\0${s.name}`));
|
||||||
const existing = new Set(g.signals.map(s => s.name));
|
const extra = customSignals
|
||||||
const merged = [
|
.filter(c => !existing.has(`${c.ds}\0${c.name}`))
|
||||||
...g.signals,
|
.map(c => ({ name: c.name, ds: c.ds, type: 'custom' } as SignalInfo & { ds: string }));
|
||||||
...extra.filter(c => !existing.has(c.name)).map(c => ({ name: c.name, type: 'custom' } as SignalInfo)),
|
return [...rawSignals, ...extra];
|
||||||
];
|
}, [rawSignals, customSignals]);
|
||||||
return { ...g, signals: merged };
|
|
||||||
|
// 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 groups: Record<string, Array<SignalInfo & { ds: string }>> = {};
|
||||||
|
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Custom signals with no matching DS group get their own group
|
return Object.entries(groups).map(([id, signals]) => ({
|
||||||
const knownDs = new Set(groups.map(g => g.ds));
|
id,
|
||||||
const orphanCustom = customSignals.filter(c => !knownDs.has(c.ds));
|
label: id,
|
||||||
const orphanGroups: DsGroup[] = Object.entries(
|
signals: signals.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
orphanCustom.reduce<Record<string, string[]>>((acc, { ds, name }) => {
|
expanded: expandedGroups[id] ?? true,
|
||||||
(acc[ds] ??= []).push(name);
|
})).sort((a, b) => a.label.localeCompare(b.label));
|
||||||
return acc;
|
}, [allSignals, groupBy, expandedGroups, filter]);
|
||||||
}, {})
|
|
||||||
).map(([ds, names]) => ({
|
|
||||||
ds, signals: names.map(name => ({ name, type: 'custom' } as SignalInfo)),
|
|
||||||
expanded: true, addingSignal: false, addValue: '',
|
|
||||||
}));
|
|
||||||
|
|
||||||
const visibleGroups = [...allGroups, ...orphanGroups];
|
function toggleGroup(id: string) {
|
||||||
|
setExpandedGroups(prev => ({ ...prev, [id]: !(prev[id] ?? true) }));
|
||||||
function toggleGroup(ds: string) {
|
|
||||||
setGroups(gs => gs.map(g => g.ds === ds ? { ...g, expanded: !g.expanded } : g));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setAdding(ds: string, val: boolean) {
|
function startAdding(ds: string) {
|
||||||
setGroups(gs => gs.map(g => g.ds === ds ? { ...g, addingSignal: val, addValue: '' } : g));
|
setAddingTo(ds);
|
||||||
|
setAddValue('');
|
||||||
|
setExpandedGroups(prev => ({ ...prev, [ds]: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function setAddValue(ds: string, val: string) {
|
function commitAddManually() {
|
||||||
setGroups(gs => gs.map(g => g.ds === ds ? { ...g, addValue: val } : g));
|
const name = addValue.trim();
|
||||||
}
|
if (!name || !addingTo) { setAddingTo(null); return; }
|
||||||
|
const next = [...customSignals, { ds: addingTo, name }];
|
||||||
function commitAdd(ds: string, name: string) {
|
|
||||||
name = name.trim();
|
|
||||||
if (!name) { setAdding(ds, false); return; }
|
|
||||||
const next = [...customSignals, { ds, name }];
|
|
||||||
setCustomSignals(next);
|
setCustomSignals(next);
|
||||||
saveCustom(next);
|
saveCustom(next);
|
||||||
setAdding(ds, false);
|
setAddingTo(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeCustom(ds: string, name: string) {
|
function removeCustom(ds: string, name: string) {
|
||||||
@@ -140,26 +167,21 @@ export default function SignalTree({ onDragStart, width }: Props) {
|
|||||||
saveCustom(next);
|
saveCustom(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Channel Finder search ────────────────────────────────────────────────
|
function handleAddCfSignals(refs: SignalRef[]) {
|
||||||
|
const existing = new Set(customSignals.map(c => `${c.ds}\0${c.name}`));
|
||||||
async function handleCfSearch() {
|
const fresh = refs.filter(r => !existing.has(`${r.ds}\0${r.name}`));
|
||||||
if (!filter) return;
|
if (fresh.length === 0) return;
|
||||||
setCfSearching(true);
|
const next = [...customSignals, ...fresh];
|
||||||
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);
|
setCustomSignals(next);
|
||||||
saveCustom(next);
|
saveCustom(next);
|
||||||
} finally {
|
loadAllSignals(); // Refresh to fetch metadata for new ones
|
||||||
setCfSearching(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleManualAdd() {
|
||||||
|
if (!manualName.trim()) return;
|
||||||
|
handleAddCfSignals([{ ds: manualDs, name: manualName.trim() }]);
|
||||||
|
setManualName('');
|
||||||
|
setShowManualAdd(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── CSV import ────────────────────────────────────────────────────────────
|
// ── CSV import ────────────────────────────────────────────────────────────
|
||||||
@@ -176,39 +198,19 @@ export default function SignalTree({ onDragStart, width }: Props) {
|
|||||||
if (!line || line.startsWith('#')) continue;
|
if (!line || line.startsWith('#')) continue;
|
||||||
const parts = line.split(',').map(p => p.trim());
|
const parts = line.split(',').map(p => p.trim());
|
||||||
if (parts.length === 1 && parts[0]) {
|
if (parts.length === 1 && parts[0]) {
|
||||||
// bare PV name → default to epics
|
|
||||||
added.push({ ds: 'epics', name: parts[0] });
|
added.push({ ds: 'epics', name: parts[0] });
|
||||||
} else if (parts.length >= 2 && parts[0] && parts[1]) {
|
} else if (parts.length >= 2 && parts[0] && parts[1]) {
|
||||||
added.push({ ds: parts[0], name: parts[1] });
|
added.push({ ds: parts[0], name: parts[1] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const existing = new Set(customSignals.map(c => `${c.ds}\0${c.name}`));
|
handleAddCfSignals(added);
|
||||||
const fresh = added.filter(a => !existing.has(`${a.ds}\0${a.name}`));
|
|
||||||
const next = [...customSignals, ...fresh];
|
|
||||||
setCustomSignals(next);
|
|
||||||
saveCustom(next);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Filter ────────────────────────────────────────────────────────────────
|
function makeDraggable(sig: SignalInfo & { ds: string }) {
|
||||||
|
|
||||||
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) {
|
|
||||||
return {
|
return {
|
||||||
draggable: true as const,
|
draggable: true as const,
|
||||||
onDragStart: (e: DragEvent) => {
|
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));
|
e.dataTransfer?.setData('application/json', JSON.stringify(ref));
|
||||||
onDragStart?.(ref);
|
onDragStart?.(ref);
|
||||||
},
|
},
|
||||||
@@ -226,7 +228,6 @@ export default function SignalTree({ onDragStart, width }: Props) {
|
|||||||
|
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div class="signal-tree-body">
|
<div class="signal-tree-body">
|
||||||
{/* Tab bar: Sources | Groups */}
|
|
||||||
<div class="signal-tree-tabs">
|
<div class="signal-tree-tabs">
|
||||||
<button
|
<button
|
||||||
class={`stab${treeTab === 'sources' ? ' stab-active' : ''}`}
|
class={`stab${treeTab === 'sources' ? ' stab-active' : ''}`}
|
||||||
@@ -238,15 +239,12 @@ export default function SignalTree({ onDragStart, width }: Props) {
|
|||||||
>Groups</button>
|
>Groups</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Groups tab */}
|
|
||||||
{treeTab === 'groups' && (
|
{treeTab === 'groups' && (
|
||||||
<GroupsTree onDragStart={onDragStart} />
|
<GroupsTree onDragStart={onDragStart} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Sources tab contents */}
|
{treeTab === 'sources' && (
|
||||||
{treeTab === 'sources' && <>
|
<Fragment>
|
||||||
|
|
||||||
{/* Search / filter bar */}
|
|
||||||
<div class="signal-tree-search">
|
<div class="signal-tree-search">
|
||||||
<div class="signal-search-row">
|
<div class="signal-search-row">
|
||||||
<input
|
<input
|
||||||
@@ -256,62 +254,89 @@ export default function SignalTree({ onDragStart, width }: Props) {
|
|||||||
value={filter}
|
value={filter}
|
||||||
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
|
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
|
||||||
/>
|
/>
|
||||||
{cfAvailable && filter && (
|
{cfAvailable && (
|
||||||
<button class="icon-btn" title="Search Channel Finder" onClick={handleCfSearch} disabled={cfSearching}>
|
<button class="icon-btn" title="Advanced Search" onClick={() => setShowCfModal(true)}>
|
||||||
{cfSearching ? '…' : '🔍'}
|
🔍
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toolbar: New Synthetic | Import CSV | Refresh */}
|
<div class="signal-tree-toolbar" style="gap: 4px;">
|
||||||
<div class="signal-tree-toolbar">
|
<select
|
||||||
<button class="panel-btn" title="Create synthetic signal" onClick={() => setShowWizard(true)}>+ Synthetic</button>
|
class="prop-select"
|
||||||
<button class="panel-btn" title="Import CSV (ds,name or bare PV names)" onClick={() => csvRef.current?.click()}>CSV</button>
|
style="font-size: 0.7rem; height: 1.6rem; padding: 0 4px; flex: 1;"
|
||||||
<button class="panel-btn" title="Refresh signal list" onClick={loadGroups}>↺</button>
|
value={groupBy}
|
||||||
|
onChange={e => setGroupBy((e.target as HTMLSelectElement).value as any)}
|
||||||
|
>
|
||||||
|
<option value="datasource">By Source</option>
|
||||||
|
<option value="area">By Area</option>
|
||||||
|
<option value="system">By System</option>
|
||||||
|
<option value="ioc">By IOC</option>
|
||||||
|
</select>
|
||||||
|
<button class="panel-btn icon-only" title="Add Signal Manually" onClick={() => setShowManualAdd(true)}>+</button>
|
||||||
|
<button class="panel-btn icon-only" title="Create synthetic signal" onClick={() => setShowWizard(true)}>Σ</button>
|
||||||
|
<button class="panel-btn icon-only" title="Import CSV" onClick={() => csvRef.current?.click()}>CSV</button>
|
||||||
|
<button class="panel-btn icon-only" title="Refresh signal list" onClick={loadAllSignals}>↺</button>
|
||||||
<input ref={csvRef} type="file" accept=".csv,text/csv" style="display:none" onChange={handleCsvImport} />
|
<input ref={csvRef} type="file" accept=".csv,text/csv" style="display:none" onChange={handleCsvImport} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Signal groups */}
|
|
||||||
<div class="panel-list signal-list">
|
<div class="panel-list signal-list">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p class="hint">Loading signals…</p>
|
<p class="hint">Loading signals…</p>
|
||||||
) : filtered.length === 0 ? (
|
) : visibleGroups.length === 0 ? (
|
||||||
<p class="hint">No signals found.</p>
|
<p class="hint">No signals found.</p>
|
||||||
) : (
|
) : (
|
||||||
filtered.map(group => {
|
visibleGroups.map(group => (
|
||||||
const groupInGroups = groups.find(g => g.ds === group.ds);
|
<div key={group.id} class="signal-group">
|
||||||
return (
|
|
||||||
<div key={group.ds} class="signal-group">
|
|
||||||
<div class="signal-group-header">
|
<div class="signal-group-header">
|
||||||
<span class="signal-group-arrow" onClick={() => toggleGroup(group.ds)}>
|
<span class="signal-group-arrow" onClick={() => toggleGroup(group.id)}>
|
||||||
{group.expanded ? '▾' : '▸'}
|
{group.expanded ? '▾' : '▸'}
|
||||||
</span>
|
</span>
|
||||||
<span class="signal-group-name" onClick={() => toggleGroup(group.ds)}>
|
<span class="signal-group-name" title={group.label} onClick={() => toggleGroup(group.id)}>
|
||||||
{group.ds}
|
{group.label}
|
||||||
</span>
|
</span>
|
||||||
<span class="signal-group-count">{group.signals.length}</span>
|
<span class="signal-group-count">{group.signals.length}</span>
|
||||||
|
{groupBy === 'datasource' && group.id !== 'synthetic' && (
|
||||||
<button
|
<button
|
||||||
class="icon-btn signal-add-btn"
|
class="icon-btn signal-add-btn"
|
||||||
title={`Add custom signal to ${group.ds}`}
|
title={`Add custom signal to ${group.id}`}
|
||||||
onClick={() => setAdding(group.ds, true)}
|
onClick={(e) => { e.stopPropagation(); startAdding(group.id); }}
|
||||||
>+</button>
|
>+</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{group.expanded && (
|
{group.expanded && (
|
||||||
<div>
|
<div>
|
||||||
|
{addingTo === group.id && (
|
||||||
|
<div class="signal-add-row">
|
||||||
|
<input
|
||||||
|
class="signal-add-input"
|
||||||
|
placeholder="Signal / PV name…"
|
||||||
|
autoFocus
|
||||||
|
value={addValue}
|
||||||
|
onInput={(e) => setAddValue((e.target as HTMLInputElement).value)}
|
||||||
|
onKeyDown={(e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') commitAddManually();
|
||||||
|
if (e.key === 'Escape') setAddingTo(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button class="icon-btn" onClick={commitAddManually}>✓</button>
|
||||||
|
<button class="icon-btn" onClick={() => setAddingTo(null)}>✕</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{group.signals.map(sig => {
|
{group.signals.map(sig => {
|
||||||
const isCustom = !groups.find(g => g.ds === group.ds)?.signals.some(s => s.name === sig.name);
|
const isCustom = sig.type === 'custom' || !rawSignals.some(rs => rs.ds === sig.ds && rs.name === sig.name);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={sig.name}
|
key={`${sig.ds}:${sig.name}`}
|
||||||
class={`signal-item${isCustom ? ' signal-custom' : ''}`}
|
class={`signal-item${isCustom ? ' signal-custom' : ''}`}
|
||||||
title={`${group.ds}:${sig.name}${sig.unit ? ` [${sig.unit}]` : ''}${sig.description ? ` — ${sig.description}` : ''}`}
|
title={`${sig.ds}:${sig.name}${sig.unit ? ` [${sig.unit}]` : ''}${sig.description ? ` — ${sig.description}` : ''}`}
|
||||||
{...makeDraggable(group.ds, sig)}
|
{...makeDraggable(sig)}
|
||||||
>
|
>
|
||||||
<span class="signal-name">{sig.name}</span>
|
<span class="signal-name">{sig.name}</span>
|
||||||
{sig.unit && <span class="signal-unit">{sig.unit}</span>}
|
{sig.unit && <span class="signal-unit">{sig.unit}</span>}
|
||||||
{group.ds === 'synthetic' && (
|
{sig.ds === 'synthetic' && (
|
||||||
<button
|
<button
|
||||||
class="icon-btn signal-edit-btn"
|
class="icon-btn signal-edit-btn"
|
||||||
title="Edit pipeline"
|
title="Edit pipeline"
|
||||||
@@ -324,46 +349,27 @@ export default function SignalTree({ onDragStart, width }: Props) {
|
|||||||
class="icon-btn signal-remove-btn"
|
class="icon-btn signal-remove-btn"
|
||||||
title="Remove custom signal"
|
title="Remove custom signal"
|
||||||
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
|
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
|
||||||
onClick={(e: MouseEvent) => { e.stopPropagation(); removeCustom(group.ds, sig.name); }}
|
onClick={(e: MouseEvent) => { e.stopPropagation(); removeCustom(sig.ds, sig.name); }}
|
||||||
>✕</button>
|
>✕</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
</Fragment>
|
||||||
})
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showWizard && (
|
{showWizard && (
|
||||||
<SyntheticWizard
|
<SyntheticWizard
|
||||||
onClose={() => setShowWizard(false)}
|
onClose={() => setShowWizard(false)}
|
||||||
onCreated={loadGroups}
|
onCreated={loadAllSignals}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -371,9 +377,54 @@ export default function SignalTree({ onDragStart, width }: Props) {
|
|||||||
<SyntheticEditor
|
<SyntheticEditor
|
||||||
name={editSynthetic}
|
name={editSynthetic}
|
||||||
onClose={() => setEditSynthetic(null)}
|
onClose={() => setEditSynthetic(null)}
|
||||||
onSaved={loadGroups}
|
onSaved={loadAllSignals}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showCfModal && (
|
||||||
|
<ChannelFinderModal
|
||||||
|
cfURL="" // fetched by backend API
|
||||||
|
onClose={() => setShowCfModal(false)}
|
||||||
|
onAddSignals={handleAddCfSignals}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showManualAdd && (
|
||||||
|
<div class="wizard-backdrop" onClick={() => setShowManualAdd(false)}>
|
||||||
|
<div class="wizard" onClick={e => e.stopPropagation()} style="max-width: 400px;">
|
||||||
|
<div class="wizard-header">
|
||||||
|
<span>Add Signal Manually</span>
|
||||||
|
<button class="icon-btn" onClick={() => setShowManualAdd(false)}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="wizard-body">
|
||||||
|
<div class="wizard-field">
|
||||||
|
<label>Data Source</label>
|
||||||
|
<select class="prop-select" value={manualDs} onChange={e => setManualDs((e.target as HTMLSelectElement).value)}>
|
||||||
|
{Array.from(new Set(allSignals.map(s => s.ds))).map(ds => (
|
||||||
|
<option key={ds} value={ds}>{ds}</option>
|
||||||
|
))}
|
||||||
|
{!allSignals.some(s => s.ds === 'epics') && <option value="epics">epics</option>}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="wizard-field">
|
||||||
|
<label>Signal / PV Name</label>
|
||||||
|
<input
|
||||||
|
class="prop-input"
|
||||||
|
autoFocus
|
||||||
|
placeholder="e.g. MY:PV:NAME"
|
||||||
|
value={manualName}
|
||||||
|
onInput={e => setManualName((e.target as HTMLInputElement).value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleManualAdd()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wizard-footer">
|
||||||
|
<button class="toolbar-btn" onClick={() => setShowManualAdd(false)}>Cancel</button>
|
||||||
|
<button class="toolbar-btn toolbar-btn-primary" onClick={handleManualAdd}>Add Signal</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+164
-24
@@ -1,6 +1,7 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import { useState, useEffect } from 'preact/hooks';
|
import { useState, useEffect, useMemo } from 'preact/hooks';
|
||||||
import LuaEditor from './LuaEditor';
|
import LuaEditor from './LuaEditor';
|
||||||
|
import type { InputRef, SignalDef, PipelineNode } from './lib/types';
|
||||||
|
|
||||||
interface NodeParam {
|
interface NodeParam {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -20,48 +21,126 @@ const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> =
|
|||||||
]},
|
]},
|
||||||
{ type: 'derivative', label: 'Derivative', params: [] },
|
{ 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: '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: '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=input)', key: 'script', type: 'lua', default: 'return a' }] },
|
{ 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<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SignalDef {
|
|
||||||
name: string;
|
|
||||||
ds: string;
|
|
||||||
signal: string;
|
|
||||||
pipeline: PipelineNode[];
|
|
||||||
meta: {
|
|
||||||
unit?: string;
|
|
||||||
description?: string;
|
|
||||||
displayLow?: number;
|
|
||||||
displayHigh?: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
name: string;
|
name: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSaved: () => 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 (
|
||||||
|
<div class="search-select">
|
||||||
|
<div class="search-select-trigger" onClick={() => setOpen(!open)}>
|
||||||
|
{value || <span class="hint">{placeholder}</span>}
|
||||||
|
<span class="search-select-arrow">{open ? '▴' : '▾'}</span>
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<div class="search-select-dropdown">
|
||||||
|
<input
|
||||||
|
class="prop-input"
|
||||||
|
autoFocus
|
||||||
|
placeholder="Search…"
|
||||||
|
value={filter}
|
||||||
|
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<div class="search-select-list">
|
||||||
|
{filtered.length === 0 && <div class="search-select-item hint">No matches</div>}
|
||||||
|
{filtered.map(opt => (
|
||||||
|
<div
|
||||||
|
key={opt}
|
||||||
|
class={`search-select-item${opt === value ? ' search-select-item-selected' : ''}`}
|
||||||
|
onClick={() => { onSelect(opt); setOpen(false); setFilter(''); }}
|
||||||
|
>
|
||||||
|
{opt}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{open && <div class="search-select-backdrop" onClick={() => setOpen(false)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Editor ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function SyntheticEditor({ name, onClose, onSaved }: Props) {
|
export default function SyntheticEditor({ name, onClose, onSaved }: Props) {
|
||||||
const [def, setDef] = useState<SignalDef | null>(null);
|
const [def, setDef] = useState<SignalDef | null>(null);
|
||||||
|
const [inputs, setInputs] = useState<InputRef[]>([]);
|
||||||
const [pipeline, setPipeline] = useState<PipelineNode[]>([]);
|
const [pipeline, setPipeline] = useState<PipelineNode[]>([]);
|
||||||
const [addType, setAddType] = useState('gain');
|
const [addType, setAddType] = useState('gain');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// Loaded data for selectors
|
||||||
|
const [dataSources, setDataSources] = useState<string[]>([]);
|
||||||
|
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
|
||||||
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`)
|
fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`)
|
||||||
.then(r => r.ok ? r.json() : Promise.reject(r.statusText))
|
.then(r => r.ok ? r.json() : Promise.reject(r.statusText))
|
||||||
.then((d: SignalDef) => {
|
.then((d: SignalDef) => {
|
||||||
setDef(d);
|
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 ?? []);
|
setPipeline(d.pipeline ?? []);
|
||||||
|
|
||||||
|
// Load signals for existing input data sources
|
||||||
|
initialInputs.forEach(inp => loadSignals(inp.ds));
|
||||||
})
|
})
|
||||||
.catch(e => setError(String(e)))
|
.catch(e => setError(String(e)))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
@@ -107,12 +186,42 @@ export default function SyntheticEditor({ name, onClose, onSaved }: Props) {
|
|||||||
setPipeline(p => [...p, { type: addType, params }]);
|
setPipeline(p => [...p, { type: addType, params }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateInput(idx: number, patch: Partial<InputRef>) {
|
||||||
|
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() {
|
async function handleSave() {
|
||||||
if (!def) return;
|
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);
|
setSaving(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
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)}`, {
|
const res = await fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -135,7 +244,7 @@ export default function SyntheticEditor({ name, onClose, onSaved }: Props) {
|
|||||||
<div class="wizard-backdrop" onClick={onClose}>
|
<div class="wizard-backdrop" onClick={onClose}>
|
||||||
<div class="wizard" onClick={(e) => e.stopPropagation()} style="max-width:560px;">
|
<div class="wizard" onClick={(e) => e.stopPropagation()} style="max-width:560px;">
|
||||||
<div class="wizard-header">
|
<div class="wizard-header">
|
||||||
<span>Edit Pipeline — {name}</span>
|
<span>Edit Synthetic Signal — {name}</span>
|
||||||
<button class="icon-btn" onClick={onClose}>✕</button>
|
<button class="icon-btn" onClick={onClose}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -145,6 +254,37 @@ export default function SyntheticEditor({ name, onClose, onSaved }: Props) {
|
|||||||
<p class="hint">Loading…</p>
|
<p class="hint">Loading…</p>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
|
<div class="wizard-section-title">Input signals</div>
|
||||||
|
{inputs.map((inp, idx) => (
|
||||||
|
<div key={idx} class="wizard-field wizard-field-row" style="align-items: flex-end; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||||
|
<div class="wizard-field" style="flex:1;">
|
||||||
|
<label>Data source</label>
|
||||||
|
<SearchableSelect
|
||||||
|
value={inp.ds}
|
||||||
|
options={dataSources}
|
||||||
|
onSelect={(ds) => updateInput(idx, { ds })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="wizard-field" style="flex:2;">
|
||||||
|
<label>Signal</label>
|
||||||
|
<SearchableSelect
|
||||||
|
value={inp.signal}
|
||||||
|
options={dsSignals[inp.ds] || []}
|
||||||
|
onSelect={(signal) => updateInput(idx, { signal })}
|
||||||
|
placeholder={inp.ds ? 'Search signals…' : 'Select data source first'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="icon-btn"
|
||||||
|
style="margin-bottom: 0.25rem;"
|
||||||
|
title="Remove input"
|
||||||
|
onClick={() => removeInput(idx)}
|
||||||
|
disabled={inputs.length === 1}
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button class="panel-btn" style="margin-top: 0.5rem; margin-bottom: 1.5rem;" onClick={addInput}>+ Add input</button>
|
||||||
|
|
||||||
<div class="wizard-section-title">Pipeline nodes</div>
|
<div class="wizard-section-title">Pipeline nodes</div>
|
||||||
{pipeline.length === 0 && (
|
{pipeline.length === 0 && (
|
||||||
<p class="hint" style="padding:0.5rem 0;">No processing nodes — input passes through unchanged.</p>
|
<p class="hint" style="padding:0.5rem 0;">No processing nodes — input passes through unchanged.</p>
|
||||||
@@ -197,7 +337,7 @@ export default function SyntheticEditor({ name, onClose, onSaved }: Props) {
|
|||||||
<div class="wizard-footer">
|
<div class="wizard-footer">
|
||||||
<button class="toolbar-btn" onClick={onClose}>Cancel</button>
|
<button class="toolbar-btn" onClick={onClose}>Cancel</button>
|
||||||
<button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving || loading}>
|
<button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving || loading}>
|
||||||
{saving ? 'Saving…' : 'Save Pipeline'}
|
{saving ? 'Saving…' : 'Save Signal'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+142
-20
@@ -1,6 +1,7 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import { useState } from 'preact/hooks';
|
import { useState, useEffect, useMemo } from 'preact/hooks';
|
||||||
import LuaEditor from './LuaEditor';
|
import LuaEditor from './LuaEditor';
|
||||||
|
import type { InputRef, SignalDef } from './lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -25,14 +26,80 @@ const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> =
|
|||||||
]},
|
]},
|
||||||
{ type: 'derivative', label: 'Derivative', params: [] },
|
{ 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: '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: '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=input)', key: 'script', type: 'lua', default: 'return a' }] },
|
{ 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 (
|
||||||
|
<div class="search-select">
|
||||||
|
<div class="search-select-trigger" onClick={() => setOpen(!open)}>
|
||||||
|
{value || <span class="hint">{placeholder}</span>}
|
||||||
|
<span class="search-select-arrow">{open ? '▴' : '▾'}</span>
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<div class="search-select-dropdown">
|
||||||
|
<input
|
||||||
|
class="prop-input"
|
||||||
|
autoFocus
|
||||||
|
placeholder="Search…"
|
||||||
|
value={filter}
|
||||||
|
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<div class="search-select-list">
|
||||||
|
{filtered.length === 0 && <div class="search-select-item hint">No matches</div>}
|
||||||
|
{filtered.map(opt => (
|
||||||
|
<div
|
||||||
|
key={opt}
|
||||||
|
class={`search-select-item${opt === value ? ' search-select-item-selected' : ''}`}
|
||||||
|
onClick={() => { onSelect(opt); setOpen(false); setFilter(''); }}
|
||||||
|
>
|
||||||
|
{opt}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{open && <div class="search-select-backdrop" onClick={() => setOpen(false)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Wizard ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function SyntheticWizard({ onClose, onCreated }: Props) {
|
export default function SyntheticWizard({ onClose, onCreated }: Props) {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [inputDs, setInputDs] = useState('stub');
|
const [inputs, setInputs] = useState<InputRef[]>([{ ds: 'stub', signal: 'sine_1hz' }]);
|
||||||
const [inputSig, setInputSig] = useState('sine_1hz');
|
|
||||||
const [nodeType, setNodeType] = useState('gain');
|
const [nodeType, setNodeType] = useState('gain');
|
||||||
const [params, setParams] = useState<Record<string, string>>({});
|
const [params, setParams] = useState<Record<string, string>>({});
|
||||||
const [unit, setUnit] = useState('');
|
const [unit, setUnit] = useState('');
|
||||||
@@ -42,6 +109,27 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// Loaded data
|
||||||
|
const [dataSources, setDataSources] = useState<string[]>([]);
|
||||||
|
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
|
||||||
|
|
||||||
|
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)!;
|
const nodeDef = NODE_TYPES.find(n => n.type === nodeType)!;
|
||||||
|
|
||||||
function getParam(key: string, def: string): string {
|
function getParam(key: string, def: string): string {
|
||||||
@@ -52,9 +140,29 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) {
|
|||||||
setParams(p => ({ ...p, [key]: val }));
|
setParams(p => ({ ...p, [key]: val }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateInput(idx: number, patch: Partial<InputRef>) {
|
||||||
|
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() {
|
async function handleCreate() {
|
||||||
if (!name.trim()) { setError('Name is required'); return; }
|
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<string, any> = {};
|
const nodeParams: Record<string, any> = {};
|
||||||
for (const p of nodeDef.params) {
|
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;
|
nodeParams[p.key] = p.type === 'number' ? parseFloat(raw) : raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
const def = {
|
const def: SignalDef = {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
ds: inputDs,
|
inputs,
|
||||||
signal: inputSig.trim(),
|
|
||||||
pipeline: [{ type: nodeType, params: nodeParams }],
|
pipeline: [{ type: nodeType, params: nodeParams }],
|
||||||
meta: {
|
meta: {
|
||||||
unit: unit || undefined,
|
unit: unit || undefined,
|
||||||
@@ -115,23 +222,38 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) {
|
|||||||
onInput={(e) => setName((e.target as HTMLInputElement).value)} />
|
onInput={(e) => setName((e.target as HTMLInputElement).value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="wizard-section-title">Input signal</div>
|
<div class="wizard-section-title">Input signals</div>
|
||||||
<div class="wizard-field wizard-field-row">
|
{inputs.map((inp, idx) => (
|
||||||
<div class="wizard-field">
|
<div key={idx} class="wizard-field wizard-field-row" style="align-items: flex-end; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||||
|
<div class="wizard-field" style="flex:1;">
|
||||||
<label>Data source</label>
|
<label>Data source</label>
|
||||||
<input class="prop-input" value={inputDs}
|
<SearchableSelect
|
||||||
placeholder="stub"
|
value={inp.ds}
|
||||||
onInput={(e) => setInputDs((e.target as HTMLInputElement).value)} />
|
options={dataSources}
|
||||||
|
onSelect={(ds) => updateInput(idx, { ds })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="wizard-field" style="flex:2;">
|
<div class="wizard-field" style="flex:2;">
|
||||||
<label>Signal name</label>
|
<label>Signal</label>
|
||||||
<input class="prop-input" value={inputSig}
|
<SearchableSelect
|
||||||
placeholder="sine_1hz"
|
value={inp.signal}
|
||||||
onInput={(e) => setInputSig((e.target as HTMLInputElement).value)} />
|
options={dsSignals[inp.ds] || []}
|
||||||
|
onSelect={(signal) => updateInput(idx, { signal })}
|
||||||
|
placeholder={inp.ds ? 'Search signals…' : 'Select data source first'}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
class="icon-btn"
|
||||||
|
style="margin-bottom: 0.25rem;"
|
||||||
|
title="Remove input"
|
||||||
|
onClick={() => removeInput(idx)}
|
||||||
|
disabled={inputs.length === 1}
|
||||||
|
>✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
<button class="panel-btn" style="margin-top: 0.5rem;" onClick={addInput}>+ Add input</button>
|
||||||
|
|
||||||
<div class="wizard-section-title">Processing</div>
|
<div class="wizard-section-title" style="margin-top: 1.5rem;">Processing</div>
|
||||||
<div class="wizard-field">
|
<div class="wizard-field">
|
||||||
<label>Node type</label>
|
<label>Node type</label>
|
||||||
<select class="prop-select" value={nodeType}
|
<select class="prop-select" value={nodeType}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface SignalMeta {
|
|||||||
writable: boolean;
|
writable: boolean;
|
||||||
enumStrings?: string[];
|
enumStrings?: string[];
|
||||||
description?: string;
|
description?: string;
|
||||||
|
properties?: Record<string, string>;
|
||||||
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// A single widget in an interface
|
// A single widget in an interface
|
||||||
@@ -55,3 +57,30 @@ export interface Interface {
|
|||||||
h: number;
|
h: number;
|
||||||
widgets: Widget[];
|
widgets: Widget[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Synthetic signals ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface InputRef {
|
||||||
|
ds: string;
|
||||||
|
signal: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PipelineNode {
|
||||||
|
type: string;
|
||||||
|
params: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignalDef {
|
||||||
|
name: string;
|
||||||
|
ds?: string; // legacy single input
|
||||||
|
signal?: string; // legacy single input
|
||||||
|
inputs?: InputRef[];
|
||||||
|
pipeline: PipelineNode[];
|
||||||
|
meta: {
|
||||||
|
unit?: string;
|
||||||
|
description?: string;
|
||||||
|
displayLow?: number;
|
||||||
|
displayHigh?: number;
|
||||||
|
writable?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -2768,3 +2768,136 @@ kbd {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Searchable Select ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.search-select {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-select-trigger {
|
||||||
|
background: #0f1117;
|
||||||
|
border: 1px solid #2d3748;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0.15rem 0.35rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-select-trigger:hover {
|
||||||
|
border-color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-select-arrow {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: #64748b;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-select-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: #1a1f2e;
|
||||||
|
border: 1px solid #4a9eff;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 2px;
|
||||||
|
z-index: 1000;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0.25rem;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-select-list {
|
||||||
|
max-height: 180px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-select-item {
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-select-item:hover {
|
||||||
|
background: #2d3748;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-select-item-selected {
|
||||||
|
background: rgba(74, 158, 255, 0.15);
|
||||||
|
color: #4a9eff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-select-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Signal Tree Refinements ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.signal-tree-toolbar select.prop-select {
|
||||||
|
border-color: #2d3748;
|
||||||
|
background-color: #0f1117;
|
||||||
|
color: #94a3b8;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-tree-toolbar select.prop-select:hover {
|
||||||
|
border-color: #4a5568;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: grab;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-item:hover {
|
||||||
|
background: #2d3748;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-unit {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #64748b;
|
||||||
|
background: #1a1f2e;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid #2d3748;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-btn.icon-only {
|
||||||
|
padding: 0;
|
||||||
|
width: 1.6rem;
|
||||||
|
height: 1.6rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,6 +43,25 @@ record(calc, "UOPI:TEMP") {
|
|||||||
field(HHSV, "MAJOR")
|
field(HHSV, "MAJOR")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
record(calc, "UOPI:TEMP_A") {
|
||||||
|
field(DESC, "Simulated temperature")
|
||||||
|
field(SCAN, ".1 second")
|
||||||
|
field(CALC, "10+20*SIN(A+10)")
|
||||||
|
field(INPA, "UOPI:TICK NPP NMS")
|
||||||
|
field(EGU, "degC")
|
||||||
|
field(PREC, "2")
|
||||||
|
field(LOPR, "0")
|
||||||
|
field(HOPR, "100")
|
||||||
|
field(LOW, "20")
|
||||||
|
field(HIGH, "78")
|
||||||
|
field(LOLO, "12")
|
||||||
|
field(HIHI, "88")
|
||||||
|
field(LSV, "MINOR")
|
||||||
|
field(HSV, "MINOR")
|
||||||
|
field(LLSV, "MAJOR")
|
||||||
|
field(HHSV, "MAJOR")
|
||||||
|
}
|
||||||
|
|
||||||
# Pressure: sawtooth 0–10 bar, ~20 s period
|
# Pressure: sawtooth 0–10 bar, ~20 s period
|
||||||
# Tests: gauge, barh, different EGU
|
# Tests: gauge, barh, different EGU
|
||||||
record(calc, "UOPI:PRESSURE") {
|
record(calc, "UOPI:PRESSURE") {
|
||||||
|
|||||||
Reference in New Issue
Block a user