Major changes: logic add to panel, local variables, panel histor, users management...
This commit is contained in:
@@ -508,6 +508,11 @@ func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
|
||||
// Write puts a new value onto a CA channel.
|
||||
// If the signal is not currently subscribed (e.g. a button in oneshot mode),
|
||||
// a temporary CA channel is created, used for the put, then torn down.
|
||||
//
|
||||
// NOTE: per-session end-user identity (datasource.WithUser) is NOT honoured in
|
||||
// the CGo/libca build: libca uses a single process-wide CA context whose client
|
||||
// name is fixed at startup. Writes therefore use the server identity here. Use
|
||||
// the default pure-Go build for per-user write attribution.
|
||||
func (e *EPICS) Write(ctx context.Context, signal string, value any) error {
|
||||
e.attachCAContext()
|
||||
|
||||
|
||||
@@ -39,8 +39,29 @@ type EPICS struct {
|
||||
|
||||
mu sync.RWMutex
|
||||
metadata map[string]datasource.Metadata
|
||||
|
||||
// Per-user CA clients for writes. EPICS Channel Access carries the client
|
||||
// username at the circuit (TCP) level, so attributing a write to a specific
|
||||
// end-user requires a dedicated client whose ClientName is that user. These
|
||||
// are created lazily on first write by each user and evicted when idle.
|
||||
ctx context.Context // datasource lifetime; governs per-user clients
|
||||
baseCfg ca.Config // template config for per-user clients
|
||||
selfName string // the server's own CA client name
|
||||
userMu sync.Mutex
|
||||
userClients map[string]*userClient
|
||||
}
|
||||
|
||||
// userClient is a per-end-user CA client and its last-use time for idle eviction.
|
||||
type userClient struct {
|
||||
cli *ca.Client
|
||||
lastUsed time.Time
|
||||
}
|
||||
|
||||
// userClientIdleTTL is how long an idle per-user CA client is kept before being
|
||||
// closed. Writes are bursty, so a few minutes avoids reconnecting on every put
|
||||
// without holding circuits open indefinitely.
|
||||
const userClientIdleTTL = 10 * time.Minute
|
||||
|
||||
// New creates a new EPICS Channel Access data source.
|
||||
//
|
||||
// caAddrList is the EPICS_CA_ADDR_LIST value (space-separated addresses).
|
||||
@@ -79,7 +100,12 @@ func (e *EPICS) Connect(ctx context.Context) error {
|
||||
return fmt.Errorf("epics: %w", err)
|
||||
}
|
||||
e.client = cli
|
||||
slog.Info("epics: CA client started", "addrs", cfg.AddrList)
|
||||
e.ctx = ctx
|
||||
e.baseCfg = cfg
|
||||
e.selfName = cfg.ClientName
|
||||
e.userClients = make(map[string]*userClient)
|
||||
go e.evictIdleUserClients(ctx)
|
||||
slog.Info("epics: CA client started", "addrs", cfg.AddrList, "client_name", cfg.ClientName)
|
||||
|
||||
// Perform background sync from Channel Finder if configured.
|
||||
if e.cfURL != "" && e.autoSyncFilter != "" {
|
||||
@@ -344,13 +370,70 @@ func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
|
||||
|
||||
// Write puts a new value onto the named CA channel.
|
||||
// value must be one of: float64, float32, int64, int32, int, int16, string, bool.
|
||||
//
|
||||
// When the request context carries an end-user identity (see datasource.WithUser)
|
||||
// that differs from the server's own CA client name, the put is performed on a
|
||||
// dedicated CA client whose ClientName is that user, so the IOC's access-security
|
||||
// rules see the actual end-user rather than the server process.
|
||||
func (e *EPICS) Write(ctx context.Context, signal string, value any) error {
|
||||
if err := e.client.Put(ctx, signal, value); err != nil {
|
||||
cli := e.client
|
||||
if user, ok := datasource.UserFrom(ctx); ok && user != e.selfName {
|
||||
uc, err := e.clientForUser(user)
|
||||
if err != nil {
|
||||
return fmt.Errorf("epics: write %q as %q: %w", signal, user, err)
|
||||
}
|
||||
cli = uc
|
||||
}
|
||||
if err := cli.Put(ctx, signal, value); err != nil {
|
||||
return fmt.Errorf("epics: write %q: %w", signal, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clientForUser returns a CA client whose ClientName is user, creating and
|
||||
// caching one on first use. The client is bound to the datasource lifetime ctx,
|
||||
// not the per-request ctx, so it survives across writes until evicted when idle.
|
||||
func (e *EPICS) clientForUser(user string) (*ca.Client, error) {
|
||||
e.userMu.Lock()
|
||||
defer e.userMu.Unlock()
|
||||
if uc, ok := e.userClients[user]; ok {
|
||||
uc.lastUsed = time.Now()
|
||||
return uc.cli, nil
|
||||
}
|
||||
cfg := e.baseCfg
|
||||
cfg.ClientName = user
|
||||
cli, err := ca.NewClient(e.ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.userClients[user] = &userClient{cli: cli, lastUsed: time.Now()}
|
||||
slog.Info("epics: created per-user CA client", "user", user)
|
||||
return cli, nil
|
||||
}
|
||||
|
||||
// evictIdleUserClients closes per-user CA clients that have not been used within
|
||||
// userClientIdleTTL. It runs until ctx is cancelled.
|
||||
func (e *EPICS) evictIdleUserClients(ctx context.Context) {
|
||||
t := time.NewTicker(time.Minute)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case now := <-t.C:
|
||||
e.userMu.Lock()
|
||||
for user, uc := range e.userClients {
|
||||
if now.Sub(uc.lastUsed) > userClientIdleTTL {
|
||||
uc.cli.Close()
|
||||
delete(e.userClients, user)
|
||||
slog.Info("epics: evicted idle per-user CA client", "user", user)
|
||||
}
|
||||
}
|
||||
e.userMu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// History //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
@@ -10,6 +10,16 @@ type SignalDef struct {
|
||||
Inputs []InputRef `json:"inputs"` // alternative multi-input format
|
||||
Pipeline []NodeDef `json:"pipeline"` // ordered list of DSP nodes
|
||||
Meta MetaOverride `json:"meta"` // optional metadata overrides
|
||||
|
||||
// Visibility controls who sees this signal in the signal tree:
|
||||
// "global" — listed in every panel's edit mode
|
||||
// "user" — listed in every panel owned by Owner
|
||||
// "panel" — listed only when editing the bound Panel
|
||||
// An empty value is treated as "global" for backward compatibility with
|
||||
// definitions created before this field existed.
|
||||
Visibility string `json:"visibility,omitempty"`
|
||||
Owner string `json:"owner,omitempty"` // creator identity (stamped server-side)
|
||||
Panel string `json:"panel,omitempty"` // bound interface id for "panel" visibility
|
||||
}
|
||||
|
||||
// InputRef names one upstream signal used as input to the pipeline.
|
||||
|
||||
@@ -83,6 +83,9 @@ func buildNode(d NodeDef) (dsp.Node, error) {
|
||||
case "derivative":
|
||||
return &dsp.DerivativeNode{}, nil
|
||||
|
||||
case "integrate":
|
||||
return &dsp.IntegrateNode{}, nil
|
||||
|
||||
case "clamp":
|
||||
return &dsp.ClampNode{
|
||||
Min: floatParam(p, "min"),
|
||||
|
||||
@@ -85,6 +85,22 @@ func (s *Synthetic) ListSignals(_ context.Context) ([]datasource.Metadata, error
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FilteredMetadata returns metadata for every defined synthetic signal for
|
||||
// which keep returns true. It lets the API layer apply per-caller visibility
|
||||
// rules (which depend on SignalDef fields not present in datasource.Metadata).
|
||||
func (s *Synthetic) FilteredMetadata(keep func(SignalDef) bool) []datasource.Metadata {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
out := make([]datasource.Metadata, 0, len(s.signals))
|
||||
for _, st := range s.signals {
|
||||
if keep(st.def) {
|
||||
out = append(out, defToMetadata(st.def))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetMetadata returns metadata for a single named signal.
|
||||
func (s *Synthetic) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
|
||||
s.mu.RLock()
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package datasource
|
||||
|
||||
import "context"
|
||||
|
||||
// userKey is the unexported context key under which the end-user identity is
|
||||
// stored. Using a private type prevents collisions with other packages.
|
||||
type userKey struct{}
|
||||
|
||||
// WithUser returns a copy of ctx carrying the end-user identity associated with
|
||||
// the request (e.g. the authenticated web client). Data sources may use this to
|
||||
// attribute operations to the actual user rather than the server process — for
|
||||
// example EPICS Channel Access access-security rules match on the client
|
||||
// username. An empty user is treated as "no client identity".
|
||||
func WithUser(ctx context.Context, user string) context.Context {
|
||||
if user == "" {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, userKey{}, user)
|
||||
}
|
||||
|
||||
// UserFrom returns the end-user identity stored in ctx by WithUser. The boolean
|
||||
// is false when no (non-empty) identity is present, in which case callers
|
||||
// should fall back to the server's own identity.
|
||||
func UserFrom(ctx context.Context) (string, bool) {
|
||||
u, ok := ctx.Value(userKey{}).(string)
|
||||
return u, ok && u != ""
|
||||
}
|
||||
Reference in New Issue
Block a user