Improved perfs

This commit is contained in:
Martino Ferrari
2026-05-12 10:16:48 +02:00
parent 912ecdd9ed
commit 6ff8fb5c25
14 changed files with 1357 additions and 25 deletions
+1 -1
View File
@@ -58,7 +58,7 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() defer stop()
brk := broker.New(ctx, log) brk := broker.New(ctx, log, broker.WithMaxUpdateRate(cfg.Server.MaxUpdateRateHz))
// Stub data source: built-in simulated signals (sine, ramp, noise, setpoint). // Stub data source: built-in simulated signals (sine, ramp, noise, setpoint).
// Enabled by default; disable via [datasource.stub] enabled = false in config. // Enabled by default; disable via [datasource.stub] enabled = false in config.
+36
View File
@@ -42,6 +42,9 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
mux.HandleFunc("PUT "+prefix+"/interfaces/{id}", h.updateInterface) mux.HandleFunc("PUT "+prefix+"/interfaces/{id}", h.updateInterface)
mux.HandleFunc("DELETE "+prefix+"/interfaces/{id}", h.deleteInterface) mux.HandleFunc("DELETE "+prefix+"/interfaces/{id}", h.deleteInterface)
mux.HandleFunc("POST "+prefix+"/interfaces/{id}/clone", h.cloneInterface) mux.HandleFunc("POST "+prefix+"/interfaces/{id}/clone", h.cloneInterface)
// Signal group tree
mux.HandleFunc("GET "+prefix+"/groups", h.getGroups)
mux.HandleFunc("PUT "+prefix+"/groups", h.putGroups)
// Synthetic signal CRUD // Synthetic signal CRUD
mux.HandleFunc("GET "+prefix+"/synthetic", h.listSynthetic) mux.HandleFunc("GET "+prefix+"/synthetic", h.listSynthetic)
mux.HandleFunc("POST "+prefix+"/synthetic", h.createSynthetic) mux.HandleFunc("POST "+prefix+"/synthetic", h.createSynthetic)
@@ -184,6 +187,39 @@ func (h *Handler) channelFinder(w http.ResponseWriter, r *http.Request) {
jsonOK(w, names) jsonOK(w, names)
} }
// ── /groups ───────────────────────────────────────────────────────────────────
// getGroups returns the workspace group tree as a JSON array.
func (h *Handler) getGroups(w http.ResponseWriter, r *http.Request) {
data, err := h.store.ReadGroups()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(data)
}
// putGroups replaces the workspace group tree. The body must be a JSON array.
func (h *Handler) putGroups(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MB max
if err != nil {
http.Error(w, "read error", http.StatusBadRequest)
return
}
// Validate it is a JSON array (basic sanity check).
var raw []json.RawMessage
if err := json.Unmarshal(body, &raw); err != nil {
http.Error(w, "body must be a JSON array", http.StatusBadRequest)
return
}
if err := h.store.WriteGroups(body); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ── /interfaces ─────────────────────────────────────────────────────────────── // ── /interfaces ───────────────────────────────────────────────────────────────
func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) { func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
+67 -9
View File
@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"sync" "sync"
"time"
"github.com/uopi/uopi/internal/datasource" "github.com/uopi/uopi/internal/datasource"
) )
@@ -41,18 +42,38 @@ type Broker struct {
sources map[string]datasource.DataSource sources map[string]datasource.DataSource
subs map[SignalRef]*signalSub subs map[SignalRef]*signalSub
maxInterval time.Duration // 0 = unlimited
log *slog.Logger log *slog.Logger
} }
// Option is a functional option for Broker configuration.
type Option func(*Broker)
// WithMaxUpdateRate limits fan-out to at most hz updates per second per signal.
// When upstream delivers faster, intermediate values are coalesced: the most
// recent value in each interval is forwarded once the interval elapses.
// A value ≤ 0 disables rate limiting (the default).
func WithMaxUpdateRate(hz float64) Option {
return func(b *Broker) {
if hz > 0 {
b.maxInterval = time.Duration(float64(time.Second) / hz)
}
}
}
// New creates a Broker whose upstream subscriptions are bound to ctx. // New creates a Broker whose upstream subscriptions are bound to ctx.
// Cancel ctx (or the parent context passed to main) to shut everything down. // Cancel ctx (or the parent context passed to main) to shut everything down.
func New(ctx context.Context, log *slog.Logger) *Broker { func New(ctx context.Context, log *slog.Logger, opts ...Option) *Broker {
return &Broker{ b := &Broker{
ctx: ctx, ctx: ctx,
sources: make(map[string]datasource.DataSource), sources: make(map[string]datasource.DataSource),
subs: make(map[SignalRef]*signalSub), subs: make(map[SignalRef]*signalSub),
log: log, log: log,
} }
for _, opt := range opts {
opt(b)
}
return b
} }
// Register adds a DataSource to the broker. Must be called before Subscribe. // Register adds a DataSource to the broker. Must be called before Subscribe.
@@ -169,7 +190,36 @@ func (b *Broker) unsubscribe(ref SignalRef, ch chan<- Update) {
// fanOut reads values from rawCh and dispatches them to all registered clients. // fanOut reads values from rawCh and dispatches them to all registered clients.
// It exits when sub.done is closed or rawCh is closed. // It exits when sub.done is closed or rawCh is closed.
//
// When b.maxInterval > 0, updates that arrive faster than the interval are
// coalesced: only the most recent value in each interval window is forwarded,
// delivered at the end of the interval by a flush ticker. This ensures the
// latest value is always eventually seen even when the source fires rapidly.
func (b *Broker) fanOut(ref SignalRef, sub *signalSub, rawCh <-chan datasource.Value) { func (b *Broker) fanOut(ref SignalRef, sub *signalSub, rawCh <-chan datasource.Value) {
maxInterval := b.maxInterval
var lastSent time.Time
var pending *Update
// Only allocate a ticker when rate-limiting is configured.
var flushC <-chan time.Time
if maxInterval > 0 {
t := time.NewTicker(maxInterval)
defer t.Stop()
flushC = t.C
}
dispatch := func(u Update) {
sub.mu.RLock()
for ch := range sub.clients {
select {
case ch <- u:
default: // slow consumer: drop rather than block
}
}
sub.mu.RUnlock()
}
for { for {
select { select {
case v, ok := <-rawCh: case v, ok := <-rawCh:
@@ -177,15 +227,23 @@ func (b *Broker) fanOut(ref SignalRef, sub *signalSub, rawCh <-chan datasource.V
return return
} }
update := Update{Ref: ref, Value: v} update := Update{Ref: ref, Value: v}
sub.mu.RLock() // Rate-limit: if an interval is configured and the last delivery
for ch := range sub.clients { // was recent, hold this update as pending (coalesce).
select { if maxInterval > 0 && !lastSent.IsZero() && time.Since(lastSent) < maxInterval {
case ch <- update: pending = &update
default: continue
// slow consumer: drop rather than block
} }
lastSent = time.Now()
pending = nil
dispatch(update)
case <-flushC:
// Deliver the most recent coalesced value, if any.
if pending != nil {
lastSent = time.Now()
dispatch(*pending)
pending = nil
} }
sub.mu.RUnlock()
case <-sub.done: case <-sub.done:
return return
+269
View File
@@ -0,0 +1,269 @@
package broker_test
import (
"context"
"fmt"
"log/slog"
"runtime"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource/stub"
)
// newBrokerN builds a broker backed by a stub with n dynamically generated signals.
func newBrokerN(tb testing.TB, n int) (*broker.Broker, context.CancelFunc) {
tb.Helper()
ctx, cancel := context.WithCancel(context.Background())
log := slog.Default()
b := broker.New(ctx, log)
ds := stub.NewN(n)
if err := ds.Connect(ctx); err != nil {
cancel()
tb.Fatal(err)
}
b.Register(ds)
return b, cancel
}
// TestStress_ManySignalsManyClients subscribes 20 clients to 500 signals each,
// runs for 2 seconds, then verifies delivery counts and zero subscription leaks.
func TestStress_ManySignalsManyClients(t *testing.T) {
if testing.Short() {
t.Skip("skipping stress test in short mode")
}
const (
nSignals = 500
nClients = 20
duration = 2 * time.Second
)
goroutinesBefore := runtime.NumGoroutine()
brk, cancel := newBrokerN(t, nSignals)
defer cancel()
// Each client has its own channel and subscribes to all nSignals signals.
type clientState struct {
ch chan broker.Update
unsub []func()
}
clients := make([]clientState, nClients)
for i := range nClients {
ch := make(chan broker.Update, 2048)
unsubs := make([]func(), nSignals)
for j := range nSignals {
ref := broker.SignalRef{DS: "stub", Name: fmt.Sprintf("pv_%d", j)}
unsub, err := brk.Subscribe(ref, ch)
if err != nil {
t.Fatalf("client %d subscribe pv_%d: %v", i, j, err)
}
unsubs[j] = unsub
}
clients[i] = clientState{ch: ch, unsub: unsubs}
}
if n := brk.ActiveSubscriptions(); n != nSignals {
t.Errorf("expected %d active upstream subscriptions, got %d", nSignals, n)
}
// Drain updates concurrently for the test duration.
var totalReceived atomic.Int64
stop := make(chan struct{})
var wg sync.WaitGroup
for i := range nClients {
wg.Add(1)
ch := clients[i].ch
go func() {
defer wg.Done()
var n int64
for {
select {
case <-ch:
n++
case <-stop:
totalReceived.Add(n)
return
}
}
}()
}
time.Sleep(duration)
close(stop)
wg.Wait()
total := totalReceived.Load()
// At 10 Hz for duration seconds: nSignals * nClients * duration.Seconds() * 10
minExpected := int64(nSignals) * int64(nClients) * int64(duration.Seconds()) * 5 // conservative: 50%
t.Logf("received %d updates (%d clients × %d signals × %.0fs @ 10Hz, min=%d)",
total, nClients, nSignals, duration.Seconds(), minExpected)
if total < minExpected {
t.Errorf("too few updates: got %d, want >= %d", total, minExpected)
}
// Unsubscribe all clients.
for _, c := range clients {
for _, unsub := range c.unsub {
unsub()
}
}
// Give the broker time to tear down upstream subscriptions.
time.Sleep(200 * time.Millisecond)
if n := brk.ActiveSubscriptions(); n != 0 {
t.Errorf("subscription leak: %d active subscriptions remain after all clients unsubscribed", n)
}
cancel()
time.Sleep(300 * time.Millisecond)
goroutinesAfter := runtime.NumGoroutine()
leaked := goroutinesAfter - goroutinesBefore
t.Logf("goroutines: before=%d after=%d delta=%d", goroutinesBefore, goroutinesAfter, leaked)
if leaked > 20 {
t.Errorf("goroutine leak: started with %d, ended with %d (%d extra)", goroutinesBefore, goroutinesAfter, leaked)
}
}
// TestStress_RapidSubscribeUnsubscribe hammers subscribe/unsubscribe on a small
// signal set from many goroutines concurrently to expose races and deadlocks.
func TestStress_RapidSubscribeUnsubscribe(t *testing.T) {
if testing.Short() {
t.Skip("skipping stress test in short mode")
}
const (
nSignals = 20
nGoroutines = 50
duration = 2 * time.Second
)
brk, cancel := newBrokerN(t, nSignals)
defer cancel()
var wg sync.WaitGroup
stop := make(chan struct{})
var ops atomic.Int64
for range nGoroutines {
wg.Add(1)
go func() {
defer wg.Done()
ch := make(chan broker.Update, 64)
for {
select {
case <-stop:
return
default:
}
// Pick a signal and rapidly subscribe/unsubscribe.
idx := int(ops.Load()) % nSignals
ref := broker.SignalRef{DS: "stub", Name: fmt.Sprintf("pv_%d", idx)}
unsub, err := brk.Subscribe(ref, ch)
if err != nil {
t.Errorf("subscribe pv_%d: %v", idx, err)
return
}
ops.Add(1)
// Drain a bit before unsubscribing.
select {
case <-ch:
case <-time.After(50 * time.Millisecond):
}
unsub()
}
}()
}
time.Sleep(duration)
close(stop)
wg.Wait()
t.Logf("completed %d subscribe/unsubscribe cycles in %s", ops.Load(), duration)
time.Sleep(200 * time.Millisecond)
if n := brk.ActiveSubscriptions(); n != 0 {
t.Errorf("subscription leak after rapid churn: %d remain", n)
}
}
// TestStress_SharedSignalManyClients verifies that a single high-rate signal
// fans out correctly to many concurrent clients with no drops for fast consumers.
func TestStress_SharedSignalManyClients(t *testing.T) {
if testing.Short() {
t.Skip("skipping stress test in short mode")
}
const (
nClients = 50
duration = 2 * time.Second
)
brk, cancel := newBroker(t) // uses stub.New() which has counter_fast at 1 ms
defer cancel()
ref := broker.SignalRef{DS: "stub", Name: "counter_fast"}
channels := make([]chan broker.Update, nClients)
unsubs := make([]func(), nClients)
for i := range nClients {
ch := make(chan broker.Update, 4096)
channels[i] = ch
unsub, err := brk.Subscribe(ref, ch)
if err != nil {
t.Fatalf("subscribe client %d: %v", i, err)
}
unsubs[i] = unsub
}
defer func() {
for _, u := range unsubs {
u()
}
}()
if n := brk.ActiveSubscriptions(); n != 1 {
t.Errorf("expected exactly 1 upstream subscription for shared signal, got %d", n)
}
var totalReceived atomic.Int64
stop := make(chan struct{})
var wg sync.WaitGroup
for i := range nClients {
wg.Add(1)
ch := channels[i]
go func() {
defer wg.Done()
var n int64
for {
select {
case <-ch:
n++
case <-stop:
totalReceived.Add(n)
return
}
}
}()
}
time.Sleep(duration)
close(stop)
wg.Wait()
total := totalReceived.Load()
// counter_fast ticks at 1ms → ~1000 Hz → 2000 updates × 50 clients = 100000 expected minimum
minExpected := int64(nClients) * int64(duration.Seconds()) * 500 // 50% delivery ok due to buffering
t.Logf("shared signal: received %d updates across %d clients (min=%d)", total, nClients, minExpected)
if total < minExpected {
t.Errorf("too few updates: got %d, want >= %d", total, minExpected)
}
}
+62
View File
@@ -122,6 +122,68 @@ func TestUnknownSignal(t *testing.T) {
} }
} }
func TestMaxUpdateRate(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
const maxHz = 5.0 // 5 Hz → 200 ms interval
ds := stub.New() // counter_fast fires at 1 kHz
if err := ds.Connect(ctx); err != nil {
t.Fatal(err)
}
b := broker.New(ctx, slog.Default(), broker.WithMaxUpdateRate(maxHz))
b.Register(ds)
ref := broker.SignalRef{DS: "stub", Name: "counter_fast"}
ch := make(chan broker.Update, 512)
unsub, err := b.Subscribe(ref, ch)
if err != nil {
t.Fatal(err)
}
defer unsub()
// Collect updates for 2 seconds.
var count int
deadline := time.After(2 * time.Second)
loop:
for {
select {
case <-ch:
count++
case <-deadline:
break loop
}
}
// At 5 Hz for 2 s we expect ~10 updates (±3 for timer jitter + coalescing).
t.Logf("MaxUpdateRate: received %d updates at %.0f Hz limit over 2s (expect ~10)", count, maxHz)
if count < 5 || count > 20 {
t.Errorf("expected ~10 updates at 5 Hz, got %d", count)
}
}
func TestMaxUpdateRate_UnlimitedByDefault(t *testing.T) {
b, cancel := newBroker(t) // no WithMaxUpdateRate → unlimited
defer cancel()
ref := broker.SignalRef{DS: "stub", Name: "counter_fast"}
ch := make(chan broker.Update, 4096)
unsub, err := b.Subscribe(ref, ch)
if err != nil {
t.Fatal(err)
}
defer unsub()
// counter_fast fires at 1 kHz; over 500 ms we should see ≥ 400 updates.
time.Sleep(500 * time.Millisecond)
got := len(ch)
t.Logf("Unlimited: received %d updates in 500ms from 1kHz signal", got)
if got < 400 {
t.Errorf("expected >= 400 updates with no rate limit, got %d", got)
}
}
func TestMultipleSignals(t *testing.T) { func TestMultipleSignals(t *testing.T) {
b, cancel := newBroker(t) b, cancel := newBroker(t)
defer cancel() defer cancel()
+7
View File
@@ -3,6 +3,7 @@ package config
import ( import (
"fmt" "fmt"
"os" "os"
"strconv"
"strings" "strings"
"github.com/BurntSushi/toml" "github.com/BurntSushi/toml"
@@ -16,6 +17,7 @@ type Config struct {
type ServerConfig struct { type ServerConfig struct {
Listen string `toml:"listen"` Listen string `toml:"listen"`
StorageDir string `toml:"storage_dir"` StorageDir string `toml:"storage_dir"`
MaxUpdateRateHz float64 `toml:"max_update_rate_hz"` // 0 = unlimited
} }
type DatasourceConfig struct { type DatasourceConfig struct {
@@ -84,6 +86,11 @@ func applyEnv(cfg *Config) {
if v := env("UOPI_SERVER_STORAGE_DIR"); v != "" { if v := env("UOPI_SERVER_STORAGE_DIR"); v != "" {
cfg.Server.StorageDir = v cfg.Server.StorageDir = v
} }
if v := env("UOPI_SERVER_MAX_UPDATE_RATE_HZ"); v != "" {
if hz, err := strconv.ParseFloat(v, 64); err == nil {
cfg.Server.MaxUpdateRateHz = hz
}
}
if v := env("UOPI_EPICS_CA_ADDR_LIST"); v != "" { if v := env("UOPI_EPICS_CA_ADDR_LIST"); v != "" {
cfg.Datasource.EPICS.CAAddrList = v cfg.Datasource.EPICS.CAAddrList = v
} }
+46 -3
View File
@@ -5,6 +5,7 @@ package stub
import ( import (
"context" "context"
"fmt"
"math" "math"
"math/rand/v2" "math/rand/v2"
"sync" "sync"
@@ -13,10 +14,11 @@ import (
"github.com/uopi/uopi/internal/datasource" "github.com/uopi/uopi/internal/datasource"
) )
const updateInterval = 100 * time.Millisecond // 10 Hz const updateInterval = 100 * time.Millisecond // 10 Hz default
type signalDef struct { type signalDef struct {
meta datasource.Metadata meta datasource.Metadata
interval time.Duration // 0 → updateInterval
fn func(t time.Time) any fn func(t time.Time) any
} }
@@ -96,6 +98,41 @@ func New() *Stub {
fn: func(t time.Time) any { return 25.0 }, // default; overwritten by Write fn: func(t time.Time) any { return 25.0 }, // default; overwritten by Write
}) })
s.values["setpoint"] = 25.0 s.values["setpoint"] = 25.0
s.register(signalDef{
meta: datasource.Metadata{
Name: "counter_fast", Type: datasource.TypeFloat64,
Description: "Fast monotonic counter (1 ms tick) for benchmarks",
},
interval: time.Millisecond,
fn: func(t time.Time) any { return float64(t.UnixMilli()) },
})
return s
}
// NewN creates a Stub with n dynamically generated signals named "pv_0" … "pv_{n-1}".
// Each signal is a sine wave at a distinct frequency, emitted at 10 Hz.
// Use this for stress and scale testing.
func NewN(n int) *Stub {
s := &Stub{
signals: make(map[string]signalDef),
values: make(map[string]any),
}
for i := range n {
freq := 0.1 + float64(i)*0.01 // spread frequencies so signals differ
s.register(signalDef{
meta: datasource.Metadata{
Name: fmt.Sprintf("pv_%d", i),
Type: datasource.TypeFloat64,
Unit: "V",
DisplayLow: -1,
DisplayHigh: 1,
Description: fmt.Sprintf("Synthetic PV #%d (%.2f Hz sine)", i, freq),
},
fn: func(t time.Time) any {
return math.Sin(2 * math.Pi * freq * float64(t.UnixNano()) / 1e9)
},
})
}
return s return s
} }
@@ -127,16 +164,22 @@ func (s *Stub) GetMetadata(_ context.Context, signal string) (datasource.Metadat
return d.meta, nil return d.meta, nil
} }
// Subscribe starts a 10 Hz ticker that pushes generated values into ch. // Subscribe starts a ticker that pushes generated values into ch.
// The tick interval is the signal's configured interval (default 10 Hz).
func (s *Stub) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) { func (s *Stub) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
def, ok := s.signals[signal] def, ok := s.signals[signal]
if !ok { if !ok {
return nil, datasource.ErrNotFound return nil, datasource.ErrNotFound
} }
interval := def.interval
if interval == 0 {
interval = updateInterval
}
ctx, cancel := context.WithCancel(ctx) ctx, cancel := context.WithCancel(ctx)
go func() { go func() {
ticker := time.NewTicker(updateInterval) ticker := time.NewTicker(interval)
defer ticker.Stop() defer ticker.Stop()
for { for {
select { select {
+2 -2
View File
@@ -105,8 +105,8 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
c := &wsClient{ c := &wsClient{
conn: conn, conn: conn,
broker: h.broker, broker: h.broker,
outCh: make(chan []byte, 128), outCh: make(chan []byte, 512),
updateCh: make(chan broker.Update, 256), updateCh: make(chan broker.Update, 1024),
subs: make(map[broker.SignalRef]func()), subs: make(map[broker.SignalRef]func()),
log: h.log, log: h.log,
} }
+343
View File
@@ -0,0 +1,343 @@
package server
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/coder/websocket"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource/stub"
)
// newStressServer creates an httptest.Server backed by a stub with n signals.
// The server and broker contexts are tied to t.Context() and cleaned up automatically.
func newStressServer(t *testing.T, nSignals int) (*httptest.Server, *broker.Broker) {
t.Helper()
ctx := t.Context()
log := slog.New(slog.NewTextHandler(io.Discard, nil))
var ds *stub.Stub
if nSignals > 0 {
ds = stub.NewN(nSignals)
} else {
ds = stub.New()
}
if err := ds.Connect(ctx); err != nil {
t.Fatal(err)
}
brk := broker.New(ctx, log)
brk.Register(ds)
mux := http.NewServeMux()
mux.Handle("/ws", &wsHandler{broker: brk, log: log})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv, brk
}
// wsURL converts an http:// test server URL to ws://.
func wsURL(srv *httptest.Server) string {
return strings.ReplaceAll(srv.URL, "http://", "ws://") + "/ws"
}
// buildSubMsg encodes a subscribe message for n stub PVs named "pv_0"…"pv_{n-1}".
func buildSubMsg(n int) []byte {
sigs := make([]sigRef, n)
for i := range n {
sigs[i] = sigRef{DS: "stub", Name: fmt.Sprintf("pv_%d", i)}
}
data, _ := json.Marshal(inMsg{Type: "subscribe", Signals: sigs})
return data
}
// connectAndCount dials the WebSocket, sends subMsg, counts incoming messages until
// ctx is cancelled, adds the count to *total, and signals wg.Done.
func connectAndCount(ctx context.Context, t *testing.T, url string, subMsg []byte, total *atomic.Int64, wg *sync.WaitGroup) {
t.Helper()
defer wg.Done()
conn, _, err := websocket.Dial(ctx, url, nil)
if err != nil {
t.Errorf("dial: %v", err)
return
}
defer conn.CloseNow()
if err := conn.Write(ctx, websocket.MessageText, subMsg); err != nil {
t.Errorf("subscribe write: %v", err)
return
}
var n int64
for {
_, _, err := conn.Read(ctx)
if err != nil {
break
}
n++
}
total.Add(n)
}
// TestStress_100PVs_10Clients verifies the full stack (broker + WS handler) can
// deliver updates from 100 signals to 10 concurrent clients without loss or deadlock.
func TestStress_100PVs_10Clients(t *testing.T) {
if testing.Short() {
t.Skip("skipping stress test in short mode")
}
const (
nSignals = 100
nClients = 10
duration = 3 * time.Second
)
srv, brk := newStressServer(t, nSignals)
url := wsURL(srv)
subMsg := buildSubMsg(nSignals)
ctx, cancel := context.WithTimeout(t.Context(), duration+5*time.Second)
defer cancel()
var total atomic.Int64
var wg sync.WaitGroup
for range nClients {
wg.Add(1)
// Each client runs for exactly `duration`, then its context expires and
// conn.Read returns an error, causing the goroutine to exit cleanly.
clientCtx, clientCancel := context.WithTimeout(ctx, duration)
go func() {
defer clientCancel()
connectAndCount(clientCtx, t, url, subMsg, &total, &wg)
}()
}
wg.Wait()
got := total.Load()
// At 10 Hz, each PV fires ≥ duration*10 times; each client receives all of them.
// min = nClients × nSignals × duration_s × 10 × 0.5 (conservative — meta msgs too)
minExpected := int64(nClients) * int64(nSignals) * int64(duration.Seconds()) * 5
t.Logf("100PVs/10Clients: received %d updates (min expected %d)", got, minExpected)
if got < minExpected {
t.Errorf("too few updates: got %d, want >= %d", got, minExpected)
}
// Broker must release all upstream subscriptions after clients disconnect.
time.Sleep(300 * time.Millisecond)
if n := brk.ActiveSubscriptions(); n != 0 {
t.Errorf("subscription leak: %d remain after all clients disconnected", n)
}
}
// TestStress_500PVs_20Clients is the full-scale scenario: hundreds of signals,
// tens of clients. Verifies no crash, no deadlock, and clean teardown.
func TestStress_500PVs_20Clients(t *testing.T) {
if testing.Short() {
t.Skip("skipping stress test in short mode")
}
const (
nSignals = 500
nClients = 20
duration = 3 * time.Second
)
srv, brk := newStressServer(t, nSignals)
url := wsURL(srv)
subMsg := buildSubMsg(nSignals)
ctx, cancel := context.WithTimeout(t.Context(), duration+10*time.Second)
defer cancel()
var total atomic.Int64
var wg sync.WaitGroup
for range nClients {
wg.Add(1)
clientCtx, clientCancel := context.WithTimeout(ctx, duration)
go func() {
defer clientCancel()
connectAndCount(clientCtx, t, url, subMsg, &total, &wg)
}()
}
wg.Wait()
got := total.Load()
// Conservative: expect at least 20% of the theoretical maximum
minExpected := int64(nClients) * int64(nSignals) * int64(duration.Seconds()) * 2
t.Logf("500PVs/20Clients: received %d updates (min expected %d)", got, minExpected)
if got < minExpected {
t.Errorf("too few updates: got %d, want >= %d", got, minExpected)
}
time.Sleep(500 * time.Millisecond)
if n := brk.ActiveSubscriptions(); n != 0 {
t.Errorf("subscription leak: %d remain after all clients disconnected", n)
}
}
// TestStress_ClientChurn repeatedly connects and disconnects clients while a
// stable set of signals is being delivered to ensure subscribe/unsubscribe under
// load doesn't cause races, panics, or subscription leaks.
func TestStress_ClientChurn(t *testing.T) {
if testing.Short() {
t.Skip("skipping stress test in short mode")
}
const (
nSignals = 50
nParallelConns = 30
duration = 3 * time.Second
)
srv, brk := newStressServer(t, nSignals)
u := wsURL(srv)
subMsg := buildSubMsg(nSignals)
deadline := time.Now().Add(duration)
var wg sync.WaitGroup
var total atomic.Int64
for range nParallelConns {
wg.Add(1)
go func() {
defer wg.Done()
for time.Now().Before(deadline) {
// Each iteration: one short-lived connection (~500 ms).
func() {
connCtx, connCancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer connCancel()
conn, _, err := websocket.Dial(connCtx, u, nil)
if err != nil {
return // dial failed (server busy) — try next iteration
}
defer conn.CloseNow()
if err := conn.Write(connCtx, websocket.MessageText, subMsg); err != nil {
return
}
var n int64
for {
_, _, err := conn.Read(connCtx)
if err != nil {
break
}
n++
}
total.Add(n)
}()
}
}()
}
wg.Wait()
t.Logf("ClientChurn: %d total messages during %s of churn with %d concurrent connections",
total.Load(), duration, nParallelConns)
time.Sleep(500 * time.Millisecond)
if n := brk.ActiveSubscriptions(); n != 0 {
t.Errorf("subscription leak after churn: %d remain", n)
}
}
// TestStress_SlowConsumer verifies that a slow-reading client doesn't block
// fast clients or the broker fan-out (drop-on-full behaviour).
func TestStress_SlowConsumer(t *testing.T) {
if testing.Short() {
t.Skip("skipping stress test in short mode")
}
const (
nSignals = 100
duration = 3 * time.Second
)
srv, brk := newStressServer(t, nSignals)
url := wsURL(srv)
subMsg := buildSubMsg(nSignals)
ctx, cancel := context.WithTimeout(t.Context(), duration+5*time.Second)
defer cancel()
var fastTotal, slowTotal atomic.Int64
var wg sync.WaitGroup
// Fast client: reads as quickly as possible.
wg.Add(1)
fastCtx, fastCancel := context.WithTimeout(ctx, duration)
go func() {
defer fastCancel()
connectAndCount(fastCtx, t, url, subMsg, &fastTotal, &wg)
}()
// Slow client: sleeps 100 ms between each read.
wg.Add(1)
slowCtx, slowCancel := context.WithTimeout(ctx, duration)
go func() {
defer wg.Done()
defer slowCancel()
conn, _, err := websocket.Dial(slowCtx, url, nil)
if err != nil {
t.Errorf("slow client dial: %v", err)
return
}
defer conn.CloseNow()
if err := conn.Write(slowCtx, websocket.MessageText, subMsg); err != nil {
t.Errorf("slow client subscribe: %v", err)
return
}
var n int64
for {
time.Sleep(100 * time.Millisecond)
_, _, err := conn.Read(slowCtx)
if err != nil {
break
}
n++
}
slowTotal.Add(n)
}()
wg.Wait()
fast := fastTotal.Load()
slow := slowTotal.Load()
t.Logf("SlowConsumer: fast=%d updates, slow=%d updates in %s", fast, slow, duration)
// Fast client must receive substantially more than slow client.
if fast < slow*3 {
t.Errorf("fast client should receive many more updates than slow client (fast=%d slow=%d)", fast, slow)
}
// Fast client must have received a meaningful number of updates.
minFast := int64(nSignals) * int64(duration.Seconds()) * 5
if fast < minFast {
t.Errorf("fast client received too few updates: got %d, want >= %d", fast, minFast)
}
time.Sleep(300 * time.Millisecond)
if n := brk.ActiveSubscriptions(); n != 0 {
t.Errorf("subscription leak: %d remain", n)
}
}
+20 -3
View File
@@ -22,9 +22,11 @@ type InterfaceMeta struct {
Version int `json:"version"` Version int `json:"version"`
} }
// Store persists interface definitions as XML files under {storageDir}/interfaces/. // Store persists interface definitions as XML files under {storageDir}/interfaces/
// and workspace-level JSON blobs directly in {storageDir}.
type Store struct { type Store struct {
dir string rootDir string // {storageDir} — for workspace-level files (groups.json, etc.)
dir string // {storageDir}/interfaces
} }
// New opens (and creates, if needed) the storage directory. // New opens (and creates, if needed) the storage directory.
@@ -33,7 +35,22 @@ func New(storageDir string) (*Store, error) {
if err := os.MkdirAll(dir, 0o755); err != nil { if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create interfaces dir: %w", err) return nil, fmt.Errorf("create interfaces dir: %w", err)
} }
return &Store{dir: dir}, nil return &Store{rootDir: storageDir, dir: dir}, nil
}
// ReadGroups returns the raw JSON array of group tree nodes.
// Returns an empty JSON array if no groups have been saved yet.
func (s *Store) ReadGroups() ([]byte, error) {
data, err := os.ReadFile(filepath.Join(s.rootDir, "groups.json"))
if errors.Is(err, os.ErrNotExist) {
return []byte("[]"), nil
}
return data, err
}
// WriteGroups persists the group tree. data must be a valid JSON array.
func (s *Store) WriteGroups(data []byte) error {
return os.WriteFile(filepath.Join(s.rootDir, "groups.json"), data, 0o644)
} }
// validateID returns an error if id could be used for path traversal or is // validateID returns an error if id could be used for path traversal or is
+327
View File
@@ -0,0 +1,327 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import type { GroupNode, SignalRef } from './lib/types';
interface Props {
onDragStart?: (sig: SignalRef) => void;
}
// ── Pure tree helpers ──────────────────────────────────────────────────────────
function genId(): string {
return Math.random().toString(36).slice(2, 10);
}
/** Insert a child node at the end of the folder at `path` (empty = root). */
function insertAt(nodes: GroupNode[], path: number[], child: GroupNode): GroupNode[] {
if (path.length === 0) return [...nodes, child];
return nodes.map((n, i) => {
if (i !== path[0] || n.kind !== 'folder') return n;
return { ...n, children: insertAt(n.children, path.slice(1), child) };
});
}
/** Remove the node at `path`. */
function removeAt(nodes: GroupNode[], path: number[]): GroupNode[] {
if (path.length === 1) return nodes.filter((_, i) => i !== path[0]);
return nodes.map((n, i) => {
if (i !== path[0] || n.kind !== 'folder') return n;
return { ...n, children: removeAt(n.children, path.slice(1)) };
});
}
/** Update the folder label at `path`. */
function renameAt(nodes: GroupNode[], path: number[], label: string): GroupNode[] {
if (path.length === 1) {
return nodes.map((n, i) =>
i === path[0] && n.kind === 'folder' ? { ...n, label } : n
);
}
return nodes.map((n, i) => {
if (i !== path[0] || n.kind !== 'folder') return n;
return { ...n, children: renameAt(n.children, path.slice(1), label) };
});
}
// ── Parse signal input ─────────────────────────────────────────────────────────
/** Parse "ds:name" or bare "name" (defaults to epics). */
function parseSignal(input: string): { ds: string; name: string } | null {
const s = input.trim();
if (!s) return null;
const colon = s.indexOf(':');
if (colon > 0) return { ds: s.slice(0, colon), name: s.slice(colon + 1) };
return { ds: 'epics', name: s };
}
// ── Component ──────────────────────────────────────────────────────────────────
export default function GroupsTree({ onDragStart }: Props) {
const [nodes, setNodes] = useState<GroupNode[]>([]);
const [loading, setLoading] = useState(true);
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
// What the user is currently adding inline (null = nothing)
const [addingAt, setAddingAt] = useState<{ path: number[]; kind: 'signal' | 'folder' } | null>(null);
const [addInput, setAddInput] = useState('');
// Inline folder rename
const [renamingPath, setRenamingPath] = useState<number[] | null>(null);
const [renameInput, setRenameInput] = useState('');
// ── Load / save ─────────────────────────────────────────────────────────────
useEffect(() => {
fetch('/api/v1/groups')
.then(r => r.json())
.then((data: GroupNode[]) => { setNodes(data); setLoading(false); })
.catch(() => setLoading(false));
}, []);
function save(next: GroupNode[]) {
fetch('/api/v1/groups', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(next),
}).catch(e => console.error('groups save failed:', e));
}
function update(next: GroupNode[]) {
setNodes(next);
save(next);
}
// ── Drag ────────────────────────────────────────────────────────────────────
function makeDraggable(ds: string, name: string) {
return {
draggable: true as const,
onDragStart: (e: DragEvent) => {
const ref: SignalRef = { ds, name };
e.dataTransfer?.setData('application/json', JSON.stringify(ref));
onDragStart?.(ref);
},
};
}
// ── Inline add ──────────────────────────────────────────────────────────────
function startAdding(path: number[], kind: 'signal' | 'folder') {
setAddingAt({ path, kind });
setAddInput('');
}
function commitAdd() {
if (!addingAt) return;
const { path, kind } = addingAt;
const val = addInput.trim();
setAddingAt(null);
setAddInput('');
if (!val) return;
if (kind === 'folder') {
const node: GroupNode = { kind: 'folder', id: genId(), label: val, children: [] };
update(insertAt(nodes, path, node));
} else {
const sig = parseSignal(val);
if (!sig) return;
const node: GroupNode = { kind: 'signal', ds: sig.ds, name: sig.name };
update(insertAt(nodes, path, node));
}
}
function cancelAdd() {
setAddingAt(null);
setAddInput('');
}
// ── Rename ──────────────────────────────────────────────────────────────────
function startRename(path: number[], label: string) {
setRenamingPath(path);
setRenameInput(label);
}
function commitRename() {
if (!renamingPath || !renameInput.trim()) { cancelRename(); return; }
update(renameAt(nodes, renamingPath, renameInput.trim()));
setRenamingPath(null);
}
function cancelRename() {
setRenamingPath(null);
setRenameInput('');
}
// ── Collapse ────────────────────────────────────────────────────────────────
function toggleCollapse(id: string) {
setCollapsed(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}
// ── Render ──────────────────────────────────────────────────────────────────
function renderAddRow(path: number[], kind: 'signal' | 'folder') {
const isActive = addingAt &&
addingAt.kind === kind &&
JSON.stringify(addingAt.path) === JSON.stringify(path);
if (!isActive) return null;
return (
<div class="group-add-row">
<input
class="group-add-input"
autoFocus
placeholder={kind === 'signal' ? 'ds:name or PV name…' : 'Folder name…'}
value={addInput}
onInput={(e) => setAddInput((e.target as HTMLInputElement).value)}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === 'Enter') commitAdd();
if (e.key === 'Escape') cancelAdd();
}}
/>
<button class="icon-btn" onClick={commitAdd}></button>
<button class="icon-btn" onClick={cancelAdd}></button>
</div>
);
}
function renderNode(node: GroupNode, path: number[]) {
const pathKey = JSON.stringify(path);
if (node.kind === 'signal') {
return (
<div
key={pathKey}
class="group-signal-item"
title={`${node.ds}:${node.name}`}
{...makeDraggable(node.ds, node.name)}
>
<span class="group-signal-ds">{node.ds}</span>
<span class="group-signal-name">{node.name}</span>
<button
class="icon-btn group-node-remove"
title="Remove from group"
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
onClick={(e: MouseEvent) => { e.stopPropagation(); update(removeAt(nodes, path)); }}
></button>
</div>
);
}
// folder node
const isCollapsed = collapsed.has(node.id);
const isRenaming = renamingPath !== null && JSON.stringify(renamingPath) === pathKey;
return (
<div key={pathKey} class="group-folder">
<div class="group-folder-header">
<span
class="group-folder-arrow"
onClick={() => toggleCollapse(node.id)}
>
{isCollapsed ? '▸' : '▾'}
</span>
{isRenaming ? (
<input
class="group-rename-input"
autoFocus
value={renameInput}
onInput={(e) => setRenameInput((e.target as HTMLInputElement).value)}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === 'Enter') commitRename();
if (e.key === 'Escape') cancelRename();
}}
onBlur={commitRename}
/>
) : (
<span
class="group-folder-name"
onDblClick={() => startRename(path, node.label)}
title="Double-click to rename"
>
{node.label}
</span>
)}
<span class="group-folder-count">{node.children.length}</span>
<div class="group-folder-actions">
<button
class="icon-btn"
title="Add signal"
onClick={() => { if (!isCollapsed) toggleCollapse(node.id); startAdding(path, 'signal'); }}
>+📶</button>
<button
class="icon-btn"
title="Add subfolder"
onClick={() => { if (!isCollapsed) toggleCollapse(node.id); startAdding(path, 'folder'); }}
>+📁</button>
<button
class="icon-btn group-node-remove"
title="Delete folder"
onClick={() => update(removeAt(nodes, path))}
>🗑</button>
</div>
</div>
{!isCollapsed && (
<div class="group-folder-children">
{node.children.map((child, i) => renderNode(child, [...path, i]))}
{renderAddRow(path, 'signal')}
{renderAddRow(path, 'folder')}
</div>
)}
</div>
);
}
const isAddingRoot = addingAt !== null && addingAt.path.length === 0;
return (
<div class="groups-tree">
{/* Root-level toolbar */}
<div class="groups-toolbar">
<button
class="panel-btn"
title="New root group"
onClick={() => startAdding([], 'folder')}
>
+ Group
</button>
</div>
{/* Inline input for new root folder */}
{isAddingRoot && addingAt?.kind === 'folder' && (
<div class="group-add-row group-add-root">
<input
class="group-add-input"
autoFocus
placeholder="Group name…"
value={addInput}
onInput={(e) => setAddInput((e.target as HTMLInputElement).value)}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === 'Enter') commitAdd();
if (e.key === 'Escape') cancelAdd();
}}
/>
<button class="icon-btn" onClick={commitAdd}></button>
<button class="icon-btn" onClick={cancelAdd}></button>
</div>
)}
<div class="panel-list group-list">
{loading ? (
<p class="hint">Loading groups</p>
) : nodes.length === 0 && !isAddingRoot ? (
<p class="hint">No groups yet click "+ Group" to create one.</p>
) : (
nodes.map((node, i) => renderNode(node, [i]))
)}
</div>
</div>
);
}
+26 -1
View File
@@ -1,8 +1,11 @@
import { h } from 'preact'; import { h, Fragment } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks'; import { useState, useEffect, useRef } 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';
type TreeTab = 'sources' | 'groups';
interface SignalInfo { interface SignalInfo {
name: string; name: string;
@@ -41,6 +44,7 @@ interface Props {
} }
export default function SignalTree({ onDragStart, width }: Props) { export default function SignalTree({ onDragStart, width }: Props) {
const [treeTab, setTreeTab] = useState<TreeTab>('sources');
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [groups, setGroups] = useState<DsGroup[]>([]); const [groups, setGroups] = useState<DsGroup[]>([]);
const [customSignals, setCustomSignals] = useState<Array<{ ds: string; name: string }>>(loadCustom); const [customSignals, setCustomSignals] = useState<Array<{ ds: string; name: string }>>(loadCustom);
@@ -222,6 +226,26 @@ 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">
<button
class={`stab${treeTab === 'sources' ? ' stab-active' : ''}`}
onClick={() => setTreeTab('sources')}
>Sources</button>
<button
class={`stab${treeTab === 'groups' ? ' stab-active' : ''}`}
onClick={() => setTreeTab('groups')}
>Groups</button>
</div>
{/* Groups tab */}
{treeTab === 'groups' && (
<GroupsTree onDragStart={onDragStart} />
)}
{/* Sources tab contents */}
{treeTab === 'sources' && <>
{/* Search / filter bar */} {/* Search / filter bar */}
<div class="signal-tree-search"> <div class="signal-tree-search">
<div class="signal-search-row"> <div class="signal-search-row">
@@ -332,6 +356,7 @@ export default function SignalTree({ onDragStart, width }: Props) {
}) })
)} )}
</div> </div>
</>}
</div> </div>
)} )}
+6
View File
@@ -40,6 +40,12 @@ export interface HistoryPoint {
value: any; value: any;
} }
// A node in the user-defined signal group tree.
// Folders contain children (other folders or signals); signals are leaf nodes.
export type GroupNode =
| { kind: 'folder'; id: string; label: string; children: GroupNode[] }
| { kind: 'signal'; ds: string; name: string };
// A complete HMI interface definition // A complete HMI interface definition
export interface Interface { export interface Interface {
id: string; id: string;
+139
View File
@@ -1185,6 +1185,145 @@ body {
min-height: 0; min-height: 0;
} }
/* ── Signal tree tab bar ──────────────────────────────────────────────────── */
.signal-tree-tabs {
display: flex;
border-bottom: 1px solid #2d3748;
flex-shrink: 0;
}
.stab {
flex: 1;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: #64748b;
cursor: pointer;
font-size: 0.75rem;
font-weight: 600;
padding: 0.35rem 0;
text-transform: uppercase;
letter-spacing: 0.04em;
transition: color 0.15s, border-color 0.15s;
}
.stab:hover { color: #94a3b8; }
.stab-active { color: #e2e8f0; border-bottom-color: #4a9eff; }
/* ── Groups tree ──────────────────────────────────────────────────────────── */
.groups-tree {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.groups-toolbar {
display: flex;
gap: 0.25rem;
padding: 0.3rem 0.5rem;
border-bottom: 1px solid #2d3748;
flex-shrink: 0;
}
.group-list { overflow-y: auto; flex: 1; padding: 0.25rem 0; }
.group-add-row {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.2rem 0.5rem;
}
.group-add-root { padding: 0.25rem 0.5rem; }
.group-add-input {
flex: 1;
background: #0f1117;
border: 1px solid #4a9eff;
border-radius: 3px;
color: #e2e8f0;
font-size: 0.78rem;
padding: 2px 6px;
min-width: 0;
}
.group-folder { margin-bottom: 1px; }
.group-folder-header {
display: flex;
align-items: center;
gap: 0.2rem;
padding: 0.15rem 0.4rem;
cursor: default;
border-radius: 3px;
font-size: 0.8rem;
color: #94a3b8;
min-height: 1.7rem;
}
.group-folder-header:hover { background: #1e293b; color: #cbd5e1; }
.group-folder-header:hover .group-folder-actions { opacity: 1; }
.group-folder-arrow { font-size: 0.65rem; flex-shrink: 0; width: 0.9rem; text-align: center; cursor: pointer; }
.group-folder-name { flex: 1; font-weight: 600; cursor: pointer; user-select: none; }
.group-folder-count {
font-size: 0.65rem;
color: #475569;
background: #1e293b;
border-radius: 8px;
padding: 0 5px;
min-width: 1.1rem;
text-align: center;
}
.group-folder-actions {
display: flex;
gap: 1px;
opacity: 0;
transition: opacity 0.15s;
}
.group-folder-actions .icon-btn { font-size: 0.65rem; padding: 1px 3px; }
.group-rename-input {
flex: 1;
background: #0f1117;
border: 1px solid #4a9eff;
border-radius: 3px;
color: #e2e8f0;
font-size: 0.8rem;
font-weight: 600;
padding: 1px 4px;
min-width: 0;
}
.group-folder-children {
padding-left: 1rem;
border-left: 1px solid #1e293b;
margin-left: 0.7rem;
}
.group-signal-item {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.15rem 0.4rem;
font-size: 0.78rem;
color: #94a3b8;
border-radius: 3px;
cursor: grab;
min-height: 1.5rem;
}
.group-signal-item:hover { background: #1e293b; color: #cbd5e1; }
.group-signal-item:hover .group-node-remove { opacity: 1; }
.group-signal-ds {
color: #475569;
font-size: 0.7rem;
flex-shrink: 0;
}
.group-signal-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.group-node-remove { opacity: 0; transition: opacity 0.15s; margin-left: auto; flex-shrink: 0; }
.signal-tree-search { .signal-tree-search {
padding: 0.4rem 0.5rem; padding: 0.4rem 0.5rem;
border-bottom: 1px solid #2d3748; border-bottom: 1px solid #2d3748;