Wworking on improving the tool

This commit is contained in:
Martino Ferrari
2026-05-21 07:41:56 +02:00
parent 6ff8fb5c25
commit 71430bc3b0
30 changed files with 1820 additions and 331 deletions
+9 -2
View File
@@ -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)
+32
View File
@@ -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())
}
+86
View File
@@ -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.
+71 -10
View File
@@ -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)
@@ -72,14 +82,16 @@ func (h *Handler) listDataSources(w http.ResponseWriter, r *http.Request) {
// ── /signals ────────────────────────────────────────────────────────────────── // ── /signals ──────────────────────────────────────────────────────────────────
type signalInfo struct { type signalInfo struct {
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
Unit string `json:"unit,omitempty"` Unit string `json:"unit,omitempty"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
DisplayLow float64 `json:"displayLow"` DisplayLow float64 `json:"displayLow"`
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,
} }
} }
+1 -1
View File
@@ -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() {
+9 -1
View File
@@ -36,7 +36,9 @@ 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"`
PVNames []string `toml:"pv_names"` AutoSyncFilter string `toml:"auto_sync_filter"`
AutoSyncFromArchiver bool `toml:"auto_sync_from_archiver"`
PVNames []string `toml:"pv_names"`
} }
type PVAConfig struct { type PVAConfig struct {
@@ -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)
} }
+50
View File
@@ -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.
+67
View File
@@ -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")
}
}
+95 -11
View File
@@ -26,6 +26,7 @@ import "C"
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog"
"os" "os"
"sync" "sync"
"sync/atomic" "sync/atomic"
@@ -66,10 +67,14 @@ type caChannel struct {
// EPICS is the Channel Access data source. // EPICS is the Channel Access data source.
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,12 +92,15 @@ 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,
channels: make(map[string]*caChannel), cfURL: cfURL,
metadata: make(map[string]datasource.Metadata), autoSyncFilter: autoSyncFilter,
autoSyncFromArchiver: autoSyncFromArchiver,
channels: make(map[string]*caChannel),
metadata: make(map[string]datasource.Metadata),
} }
} }
@@ -122,9 +130,62 @@ func (e *EPICS) Connect(_ context.Context) error {
} }
// Save the context so goroutines on other OS threads can attach to it. // 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 {
+107 -25
View File
@@ -28,9 +28,12 @@ func Available() bool { return true }
// EPICS is the pure-Go Channel Access data source. // EPICS is the pure-Go Channel Access data source.
type EPICS struct { type EPICS struct {
caAddrList string caAddrList string
archiveURL string archiveURL string
pvNames []string // pre-fetched at connect time for ListSignals cfURL string
autoSyncFilter string
autoSyncFromArchiver bool
pvNames []string // pre-fetched at connect time for ListSignals
client *ca.Client client *ca.Client
@@ -47,12 +50,15 @@ 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,
pvNames: pvNames, cfURL: cfURL,
metadata: make(map[string]datasource.Metadata), autoSyncFilter: autoSyncFilter,
autoSyncFromArchiver: autoSyncFromArchiver,
pvNames: pvNames,
metadata: make(map[string]datasource.Metadata),
} }
} }
@@ -75,29 +81,24 @@ func (e *EPICS) Connect(ctx context.Context) error {
e.client = cli 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)
tvCh := make(chan proto.TimeValue, 1)
subCtx, subCancel := context.WithTimeout(ctx, 15*time.Second)
defer subCancel()
cancel, err := e.client.Subscribe(subCtx, pv, tvCh)
if err != nil {
return
}
defer cancel()
// Channel is now connected; fetch CTRL metadata.
mctx, mcancel := context.WithTimeout(ctx, 5*time.Second)
defer mcancel()
_, _ = e.GetMetadata(mctx, pv)
}()
} }
}() }()
} }
@@ -105,6 +106,62 @@ func (e *EPICS) Connect(ctx context.Context) error {
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 {
if ctx.Err() != nil {
return
}
// Convert CFS properties and tags to datasource.Metadata
props := make(map[string]string)
for _, p := range ch.Properties {
props[p.Name] = p.Value
}
tags := make([]string, len(ch.Tags))
for i, t := range ch.Tags {
tags[i] = t.Name
}
// Initialize metadata with CFS info. Full metadata (type, units, etc.)
// will be populated on first access via DBR_CTRL GET.
e.mu.Lock()
if _, exists := e.metadata[ch.Name]; !exists {
e.metadata[ch.Name] = datasource.Metadata{
Name: ch.Name,
Properties: props,
Tags: tags,
}
}
e.mu.Unlock()
// Background pre-fetch of actual record info (type, etc.)
go e.prefetchPV(ctx, ch.Name)
}
slog.Info("epics: synced signals from Channel Finder", "count", len(channels))
}
func (e *EPICS) prefetchPV(ctx context.Context, pv string) {
tvCh := make(chan proto.TimeValue, 1)
subCtx, subCancel := context.WithTimeout(ctx, 15*time.Second)
defer subCancel()
cancel, err := e.client.Subscribe(subCtx, pv, tvCh)
if err != nil {
return
}
defer cancel()
// Channel is now connected; fetch CTRL metadata.
mctx, mcancel := context.WithTimeout(ctx, 5*time.Second)
defer mcancel()
_, _ = e.GetMetadata(mctx, pv)
}
// -------------------------------------------------------------------------- // // -------------------------------------------------------------------------- //
// Subscribe // // Subscribe //
// -------------------------------------------------------------------------- // // -------------------------------------------------------------------------- //
@@ -273,6 +330,7 @@ func (e *EPICS) ctrlToMeta(name string, ci ca.CtrlInfo) datasource.Metadata {
func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) { 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))
}
+2
View File
@@ -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.
+2 -2
View File
@@ -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))
+13 -9
View File
@@ -64,15 +64,17 @@ type outMsg struct {
} }
type metaPayload struct { type metaPayload struct {
Type string `json:"type"` Type string `json:"type"`
Unit string `json:"unit,omitempty"` Unit string `json:"unit,omitempty"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
DisplayLow float64 `json:"displayLow"` DisplayLow float64 `json:"displayLow"`
DisplayHigh float64 `json:"displayHigh"` DisplayHigh float64 `json:"displayHigh"`
DriveLow float64 `json:"driveLow,omitempty"` DriveLow float64 `json:"driveLow,omitempty"`
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,
} }
} }
+97 -7
View File
@@ -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) {
return ErrNotFound oldPath := s.filePath(id)
oldData, err := os.ReadFile(oldPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return ErrNotFound
}
return err
} }
return os.WriteFile(s.filePath(id), xmlData, 0o644)
// Parse current version to name the backup file.
var oldRoot rootAttrs
if err := xml.Unmarshal(oldData, &oldRoot); err != nil {
return fmt.Errorf("parse existing XML: %w", err)
}
if oldRoot.Version < 1 {
oldRoot.Version = 1
}
// Move old file to backup path: id.vN.xml
backupPath := filepath.Join(s.dir, fmt.Sprintf("%s.v%d.xml", id, oldRoot.Version))
if err := os.WriteFile(backupPath, oldData, 0o644); err != nil {
return fmt.Errorf("create backup: %w", err)
}
// Update version in new data.
newVersion := oldRoot.Version + 1
updatedData, err := setXMLAttribute(xmlData, "version", fmt.Sprintf("%d", newVersion))
if err != nil {
return fmt.Errorf("update version attribute: %w", err)
}
return os.WriteFile(oldPath, updatedData, 0o644)
}
// setXMLAttribute is a primitive helper to update a top-level attribute in an
// XML blob without full unmarshal/marshal (which might mess up formatting).
func setXMLAttribute(data []byte, attr, value string) ([]byte, error) {
// Very basic implementation: look for the attribute in the first 512 bytes.
// A proper implementation would use an XML encoder or a better regex.
s := string(data)
tagEnd := strings.Index(s, ">")
if tagEnd == -1 {
return nil, errors.New("malformed XML: no root tag end")
}
rootTag := s[:tagEnd]
attrSearch := " " + attr + "=\""
idx := strings.Index(rootTag, attrSearch)
if idx == -1 {
// Attribute not found, insert it before the tag ends.
insertIdx := tagEnd
if s[tagEnd-1] == '/' {
insertIdx-- // handle <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.
+2
View File
@@ -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" />
+5 -3
View File
@@ -3,9 +3,11 @@ listen = ":8080"
storage_dir = "./interfaces" 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
+267
View File
@@ -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>
);
}
+31
View File
@@ -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') {
+2
View File
@@ -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)'],
+31
View File
@@ -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" />
+6 -1
View File
@@ -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();
+1 -1
View File
@@ -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 */}
+260 -209
View File
@@ -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 };
});
// Custom signals with no matching DS group get their own group // Group signals based on selected criteria
const knownDs = new Set(groups.map(g => g.ds)); const visibleGroups = useMemo(() => {
const orphanCustom = customSignals.filter(c => !knownDs.has(c.ds)); const lf = filter.toLowerCase();
const orphanGroups: DsGroup[] = Object.entries( const filtered = allSignals.filter(s =>
orphanCustom.reduce<Record<string, string[]>>((acc, { ds, name }) => { s.name.toLowerCase().includes(lf) ||
(acc[ds] ??= []).push(name); (s.description ?? '').toLowerCase().includes(lf)
return acc; );
}, {})
).map(([ds, names]) => ({
ds, signals: names.map(name => ({ name, type: 'custom' } as SignalInfo)),
expanded: true, addingSignal: false, addValue: '',
}));
const visibleGroups = [...allGroups, ...orphanGroups]; const groups: Record<string, Array<SignalInfo & { ds: string }>> = {};
function toggleGroup(ds: string) { filtered.forEach(s => {
setGroups(gs => gs.map(g => g.ds === ds ? { ...g, expanded: !g.expanded } : g)); let key = 'unknown';
if (groupBy === 'datasource') {
key = s.ds;
} else if (groupBy === 'area') {
key = s.properties?.area || 'no-area';
} else if (groupBy === 'system') {
key = s.properties?.system || 'no-system';
} else if (groupBy === 'ioc') {
key = s.properties?.iocName || 'no-ioc';
}
(groups[key] ??= []).push(s);
});
return Object.entries(groups).map(([id, signals]) => ({
id,
label: id,
signals: signals.sort((a, b) => a.name.localeCompare(b.name)),
expanded: expandedGroups[id] ?? true,
})).sort((a, b) => a.label.localeCompare(b.label));
}, [allSignals, groupBy, expandedGroups, filter]);
function toggleGroup(id: string) {
setExpandedGroups(prev => ({ ...prev, [id]: !(prev[id] ?? true) }));
} }
function setAdding(ds: string, val: boolean) { 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}`));
const fresh = refs.filter(r => !existing.has(`${r.ds}\0${r.name}`));
if (fresh.length === 0) return;
const next = [...customSignals, ...fresh];
setCustomSignals(next);
saveCustom(next);
loadAllSignals(); // Refresh to fetch metadata for new ones
}
async function handleCfSearch() { function handleManualAdd() {
if (!filter) return; if (!manualName.trim()) return;
setCfSearching(true); handleAddCfSignals([{ ds: manualDs, name: manualName.trim() }]);
try { setManualName('');
const res = await fetch(`/api/v1/channel-finder?q=${encodeURIComponent(filter)}`); setShowManualAdd(false);
if (!res.ok) return;
const names: string[] = await res.json();
const next = [
...customSignals,
...names
.filter(n => !customSignals.some(c => c.ds === 'epics' && c.name === n))
.map(n => ({ ds: 'epics', name: n })),
];
setCustomSignals(next);
saveCustom(next);
} finally {
setCfSearching(false);
}
} }
// ── CSV import ──────────────────────────────────────────────────────────── // ── 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,132 +239,137 @@ 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>
<div class="signal-tree-search">
<div class="signal-search-row">
<input
class="signal-filter"
type="search"
placeholder="Filter signals…"
value={filter}
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
/>
{cfAvailable && (
<button class="icon-btn" title="Advanced Search" onClick={() => setShowCfModal(true)}>
🔍
</button>
)}
</div>
</div>
{/* Search / filter bar */} <div class="signal-tree-toolbar" style="gap: 4px;">
<div class="signal-tree-search"> <select
<div class="signal-search-row"> class="prop-select"
<input style="font-size: 0.7rem; height: 1.6rem; padding: 0 4px; flex: 1;"
class="signal-filter" value={groupBy}
type="search" onChange={e => setGroupBy((e.target as HTMLSelectElement).value as any)}
placeholder="Filter signals…" >
value={filter} <option value="datasource">By Source</option>
onInput={(e) => setFilter((e.target as HTMLInputElement).value)} <option value="area">By Area</option>
/> <option value="system">By System</option>
{cfAvailable && filter && ( <option value="ioc">By IOC</option>
<button class="icon-btn" title="Search Channel Finder" onClick={handleCfSearch} disabled={cfSearching}> </select>
{cfSearching ? '…' : '🔍'} <button class="panel-btn icon-only" title="Add Signal Manually" onClick={() => setShowManualAdd(true)}>+</button>
</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>
</div> <button class="panel-btn icon-only" title="Refresh signal list" onClick={loadAllSignals}></button>
</div> <input ref={csvRef} type="file" accept=".csv,text/csv" style="display:none" onChange={handleCsvImport} />
</div>
{/* Toolbar: New Synthetic | Import CSV | Refresh */} <div class="panel-list signal-list">
<div class="signal-tree-toolbar"> {loading ? (
<button class="panel-btn" title="Create synthetic signal" onClick={() => setShowWizard(true)}>+ Synthetic</button> <p class="hint">Loading signals</p>
<button class="panel-btn" title="Import CSV (ds,name or bare PV names)" onClick={() => csvRef.current?.click()}>CSV</button> ) : visibleGroups.length === 0 ? (
<button class="panel-btn" title="Refresh signal list" onClick={loadGroups}></button> <p class="hint">No signals found.</p>
<input ref={csvRef} type="file" accept=".csv,text/csv" style="display:none" onChange={handleCsvImport} /> ) : (
</div> visibleGroups.map(group => (
<div key={group.id} class="signal-group">
{/* Signal groups */} <div class="signal-group-header">
<div class="panel-list signal-list"> <span class="signal-group-arrow" onClick={() => toggleGroup(group.id)}>
{loading ? ( {group.expanded ? '▾' : '▸'}
<p class="hint">Loading signals</p> </span>
) : filtered.length === 0 ? ( <span class="signal-group-name" title={group.label} onClick={() => toggleGroup(group.id)}>
<p class="hint">No signals found.</p> {group.label}
) : ( </span>
filtered.map(group => { <span class="signal-group-count">{group.signals.length}</span>
const groupInGroups = groups.find(g => g.ds === group.ds); {groupBy === 'datasource' && group.id !== 'synthetic' && (
return ( <button
<div key={group.ds} class="signal-group"> class="icon-btn signal-add-btn"
<div class="signal-group-header"> title={`Add custom signal to ${group.id}`}
<span class="signal-group-arrow" onClick={() => toggleGroup(group.ds)}> onClick={(e) => { e.stopPropagation(); startAdding(group.id); }}
{group.expanded ? '▾' : '▸'} >+</button>
</span>
<span class="signal-group-name" onClick={() => toggleGroup(group.ds)}>
{group.ds}
</span>
<span class="signal-group-count">{group.signals.length}</span>
<button
class="icon-btn signal-add-btn"
title={`Add custom signal to ${group.ds}`}
onClick={() => setAdding(group.ds, true)}
>+</button>
</div>
{group.expanded && (
<div>
{group.signals.map(sig => {
const isCustom = !groups.find(g => g.ds === group.ds)?.signals.some(s => s.name === sig.name);
return (
<div
key={sig.name}
class={`signal-item${isCustom ? ' signal-custom' : ''}`}
title={`${group.ds}:${sig.name}${sig.unit ? ` [${sig.unit}]` : ''}${sig.description ? `${sig.description}` : ''}`}
{...makeDraggable(group.ds, sig)}
>
<span class="signal-name">{sig.name}</span>
{sig.unit && <span class="signal-unit">{sig.unit}</span>}
{group.ds === 'synthetic' && (
<button
class="icon-btn signal-edit-btn"
title="Edit pipeline"
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
onClick={(e: MouseEvent) => { e.stopPropagation(); setEditSynthetic(sig.name); }}
></button>
)}
{isCustom && (
<button
class="icon-btn signal-remove-btn"
title="Remove custom signal"
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
onClick={(e: MouseEvent) => { e.stopPropagation(); removeCustom(group.ds, sig.name); }}
></button>
)}
</div>
);
})}
{/* Add-signal input row */}
{(groupInGroups?.addingSignal) && (
<div class="signal-add-row">
<input
class="signal-add-input"
placeholder="Signal / PV name…"
autoFocus
value={groupInGroups?.addValue ?? ''}
onInput={(e) => setAddValue(group.ds, (e.target as HTMLInputElement).value)}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === 'Enter') commitAdd(group.ds, (e.target as HTMLInputElement).value);
if (e.key === 'Escape') setAdding(group.ds, false);
}}
/>
<button class="icon-btn" onClick={() => commitAdd(group.ds, groupInGroups?.addValue ?? '')}></button>
<button class="icon-btn" onClick={() => setAdding(group.ds, false)}></button>
</div>
)} )}
</div> </div>
)}
</div> {group.expanded && (
); <div>
}) {addingTo === group.id && (
)} <div class="signal-add-row">
</div> <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 => {
const isCustom = sig.type === 'custom' || !rawSignals.some(rs => rs.ds === sig.ds && rs.name === sig.name);
return (
<div
key={`${sig.ds}:${sig.name}`}
class={`signal-item${isCustom ? ' signal-custom' : ''}`}
title={`${sig.ds}:${sig.name}${sig.unit ? ` [${sig.unit}]` : ''}${sig.description ? `${sig.description}` : ''}`}
{...makeDraggable(sig)}
>
<span class="signal-name">{sig.name}</span>
{sig.unit && <span class="signal-unit">{sig.unit}</span>}
{sig.ds === 'synthetic' && (
<button
class="icon-btn signal-edit-btn"
title="Edit pipeline"
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
onClick={(e: MouseEvent) => { e.stopPropagation(); setEditSynthetic(sig.name); }}
></button>
)}
{isCustom && (
<button
class="icon-btn signal-remove-btn"
title="Remove custom signal"
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
onClick={(e: MouseEvent) => { e.stopPropagation(); removeCustom(sig.ds, sig.name); }}
></button>
)}
</div>
);
})}
</div>
)}
</div>
))
)}
</div>
</Fragment>
)}
</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
View File
@@ -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>
+146 -24
View File
@@ -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;">
<label>Data source</label> <div class="wizard-field" style="flex:1;">
<input class="prop-input" value={inputDs} <label>Data source</label>
placeholder="stub" <SearchableSelect
onInput={(e) => setInputDs((e.target as HTMLInputElement).value)} /> 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> </div>
<div class="wizard-field" style="flex:2;"> ))}
<label>Signal name</label> <button class="panel-btn" style="margin-top: 0.5rem;" onClick={addInput}>+ Add input</button>
<input class="prop-input" value={inputSig}
placeholder="sine_1hz"
onInput={(e) => setInputSig((e.target as HTMLInputElement).value)} />
</div>
</div>
<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}
+29
View File
@@ -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;
};
}
+133
View File
@@ -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;
}
+19
View File
@@ -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 010 bar, ~20 s period # Pressure: sawtooth 010 bar, ~20 s period
# Tests: gauge, barh, different EGU # Tests: gauge, barh, different EGU
record(calc, "UOPI:PRESSURE") { record(calc, "UOPI:PRESSURE") {