Improving all side of app
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
# TODO
|
||||||
|
|
||||||
|
- [ ] Implement git style versioning for: synthetic variable, panels, control logic
|
||||||
|
- [ ] Implement configuration manager:
|
||||||
|
- configuration is a set of signals (that can be organized in group and subgroup) that are used to configure a system or a sub system
|
||||||
|
- with configuration manager user should be able to:
|
||||||
|
- Define and manage configuration sets: delete (keep server side copy of deleted config), create (specifying default to parameters, mandatory, optional etc), edit (with versioning git style) and compare set
|
||||||
|
- user can fork an existing config instance to create a new one
|
||||||
|
- user can compare two instances: show unified diff or side by side diff
|
||||||
|
- etc...
|
||||||
|
- Create, delete (keep server cache), edit (git style versioning) and compare configuration instances: meaning set actual value to a configuration set
|
||||||
|
- user can fork an existing config instance to create a new one
|
||||||
|
- user can compare two instances: show unified diff or side by side diff
|
||||||
|
- etc...
|
||||||
|
- [ ] Implement admin pane: create / manage groups, set users permits, manage auditors etc
|
||||||
|
- [ ] Implement new datasources:
|
||||||
|
- [ ] modbus tcp
|
||||||
|
- [ ] scpi tcp
|
||||||
|
- [ ] Improve UX:
|
||||||
|
- [ ] Synthetic editor:
|
||||||
|
- color code the node link by type
|
||||||
|
- hover on a block in error should show the reason
|
||||||
|
- add proper array functionality
|
||||||
|
- add shortcut to add Signals (S) and nodes (N) with HUD
|
||||||
|
- [ ] Panels:
|
||||||
|
- in view mode the widgets should have no border/bg but blend with background
|
||||||
|
- add widgets such
|
||||||
|
- [ ] Implement proper distributed server side nodes to balance load and have redundancy (if a node is not available anymore all its clients migrate seamelessly to another)
|
||||||
+7
-1
@@ -186,9 +186,15 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
ctrlEngine := controllogic.NewEngine(ctx, brk, ctrlStore, recorder, log)
|
ctrlEngine := controllogic.NewEngine(ctx, brk, ctrlStore, recorder, log)
|
||||||
|
|
||||||
|
// Dialog hub: control-logic action.dialog nodes push notifications/inputs to
|
||||||
|
// connected clients (filtered by user/group) and route input responses back
|
||||||
|
// to server variables. Installed before Reload so running graphs can emit.
|
||||||
|
dialogs := server.NewDialogHub(brk, policy, recorder, log)
|
||||||
|
ctrlEngine.SetNotifier(dialogs)
|
||||||
ctrlEngine.Reload()
|
ctrlEngine.Reload()
|
||||||
|
|
||||||
srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, policy, aclStore, ctrlStore, ctrlEngine, recorder, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, log)
|
srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, policy, aclStore, ctrlStore, ctrlEngine, dialogs, recorder, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, 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)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/uopi/uopi/internal/audit"
|
"github.com/uopi/uopi/internal/audit"
|
||||||
@@ -34,11 +35,28 @@ type Engine struct {
|
|||||||
cancel context.CancelFunc // cancels the current generation
|
cancel context.CancelFunc // cancels the current generation
|
||||||
wg *sync.WaitGroup // tracks the current generation's goroutines
|
wg *sync.WaitGroup // tracks the current generation's goroutines
|
||||||
|
|
||||||
|
// notifier delivers action.dialog requests to connected clients. Stored in
|
||||||
|
// an atomic so flow goroutines can read it without taking e.mu (which Reload
|
||||||
|
// holds while it waits for those same goroutines to drain).
|
||||||
|
notifier atomic.Value // notifierBox
|
||||||
|
|
||||||
// Shared live signal cache for the current generation (key "ds\0name").
|
// Shared live signal cache for the current generation (key "ds\0name").
|
||||||
liveMu sync.RWMutex
|
liveMu sync.RWMutex
|
||||||
live map[string]float64
|
live map[string]float64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// notifierBox wraps a Notifier so atomic.Value always sees one concrete type.
|
||||||
|
type notifierBox struct{ n Notifier }
|
||||||
|
|
||||||
|
// dialogSeq generates unique action.dialog ids across all graphs.
|
||||||
|
var dialogSeq uint64
|
||||||
|
|
||||||
|
// SetNotifier installs the sink for action.dialog requests. Safe to call once
|
||||||
|
// at startup before or after Reload; it is read lock-free by running flows.
|
||||||
|
func (e *Engine) SetNotifier(n Notifier) {
|
||||||
|
e.notifier.Store(notifierBox{n: n})
|
||||||
|
}
|
||||||
|
|
||||||
// NewEngine creates an engine bound to root. Call Reload to start it. rec records
|
// NewEngine creates an engine bound to root. Call Reload to start it. rec records
|
||||||
// the writes performed by flows; pass audit.Nop() to disable auditing.
|
// the writes performed by flows; pass audit.Nop() to disable auditing.
|
||||||
func NewEngine(root context.Context, brk *broker.Broker, store *Store, rec audit.Recorder, log *slog.Logger) *Engine {
|
func NewEngine(root context.Context, brk *broker.Broker, store *Store, rec audit.Recorder, log *slog.Logger) *Engine {
|
||||||
@@ -252,6 +270,29 @@ func (e *Engine) write(cg *compiledGraph, target string, val float64) {
|
|||||||
e.audit.Record(ev)
|
e.audit.Record(ev)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// emitDialog delivers an action.dialog node's request to the installed Notifier
|
||||||
|
// (the WebSocket dialog hub). It is lock-free so it never deadlocks against a
|
||||||
|
// concurrent Reload that is waiting for this flow goroutine to finish.
|
||||||
|
func (e *Engine) emitDialog(n Node) {
|
||||||
|
box, _ := e.notifier.Load().(notifierBox)
|
||||||
|
if box.n == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
kind := strings.TrimSpace(n.param("kind"))
|
||||||
|
if kind != "error" && kind != "input" {
|
||||||
|
kind = "info"
|
||||||
|
}
|
||||||
|
box.n.Notify(Dialog{
|
||||||
|
ID: strconv.FormatUint(atomic.AddUint64(&dialogSeq, 1), 10),
|
||||||
|
Kind: kind,
|
||||||
|
Title: n.param("title"),
|
||||||
|
Message: n.param("message"),
|
||||||
|
Target: strings.TrimSpace(n.param("target")),
|
||||||
|
Users: splitCSV(n.param("users")),
|
||||||
|
Groups: splitCSV(n.param("groups")),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ── compiled graph ─────────────────────────────────────────────────────────────
|
// ── compiled graph ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type wireOut struct {
|
type wireOut struct {
|
||||||
@@ -658,6 +699,10 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
|
|||||||
cg.runLua(node.ID, ctx)
|
cg.runLua(node.ID, ctx)
|
||||||
cg.follow(node.ID, "out", ctx)
|
cg.follow(node.ID, "out", ctx)
|
||||||
|
|
||||||
|
case "action.dialog":
|
||||||
|
cg.engine.emitDialog(node)
|
||||||
|
cg.follow(node.ID, "out", ctx)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
cg.follow(node.ID, "out", ctx)
|
cg.follow(node.ID, "out", ctx)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ package controllogic
|
|||||||
// action.delay — waits `ms` before continuing.
|
// action.delay — waits `ms` before continuing.
|
||||||
// action.log — logs an expression value to the server log.
|
// action.log — logs an expression value to the server log.
|
||||||
// action.lua — runs a sandboxed Lua script with get/set/log host funcs.
|
// action.lua — runs a sandboxed Lua script with get/set/log host funcs.
|
||||||
|
// action.dialog — pushes an info/error/input dialog to connected clients
|
||||||
|
// filtered by user/group; input responses write `target`.
|
||||||
|
|
||||||
// Node is a single node in a control-logic graph. Params are stored as strings
|
// Node is a single node in a control-logic graph. Params are stored as strings
|
||||||
// (matching the panel logic model) and parsed per-kind by the engine.
|
// (matching the panel logic model) and parsed per-kind by the engine.
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package controllogic
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// Dialog is a user-facing notification or input request emitted by an
|
||||||
|
// action.dialog node. It is delivered to connected clients whose identity
|
||||||
|
// matches Users/Groups (both empty = everyone). For an "input" dialog the
|
||||||
|
// client's response is written back to Target (a "ds:name" reference, e.g.
|
||||||
|
// "srv:approved") so control logic can read it on a later activation.
|
||||||
|
type Dialog struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Kind string `json:"kind"` // "info" | "error" | "input"
|
||||||
|
Title string `json:"title"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Target string `json:"target,omitempty"`
|
||||||
|
Users []string `json:"users,omitempty"`
|
||||||
|
Groups []string `json:"groups,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notifier delivers control-logic dialogs to connected clients. The server
|
||||||
|
// implements it; the engine calls Notify when an action.dialog node runs.
|
||||||
|
// Notify must not block (the hub fans out without waiting on slow clients).
|
||||||
|
type Notifier interface {
|
||||||
|
Notify(Dialog)
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitCSV parses a comma-separated user/group filter into trimmed,
|
||||||
|
// non-empty tokens. An empty string yields a nil slice (no filter).
|
||||||
|
func splitCSV(s string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, p := range strings.Split(s, ",") {
|
||||||
|
if t := strings.TrimSpace(p); t != "" {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ type SignalDef struct {
|
|||||||
Signal string `json:"signal"` // upstream signal name (or "" for constant)
|
Signal string `json:"signal"` // upstream signal name (or "" for constant)
|
||||||
Inputs []InputRef `json:"inputs"` // alternative multi-input format
|
Inputs []InputRef `json:"inputs"` // alternative multi-input format
|
||||||
Pipeline []NodeDef `json:"pipeline"` // ordered list of DSP nodes
|
Pipeline []NodeDef `json:"pipeline"` // ordered list of DSP nodes
|
||||||
|
Graph *Graph `json:"graph,omitempty"` // DAG form (preferred when present)
|
||||||
Meta MetaOverride `json:"meta"` // optional metadata overrides
|
Meta MetaOverride `json:"meta"` // optional metadata overrides
|
||||||
|
|
||||||
// Visibility controls who sees this signal in the signal tree:
|
// Visibility controls who sees this signal in the signal tree:
|
||||||
@@ -34,6 +35,37 @@ type NodeDef struct {
|
|||||||
Params map[string]any `json:"params,omitempty"`
|
Params map[string]any `json:"params,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Graph is the DAG form of a synthetic signal: a set of nodes (sources, ops and
|
||||||
|
// one output) wired together by explicit per-node ordered input lists. It
|
||||||
|
// supersedes the linear Inputs+Pipeline form: when SignalDef.Graph is set it is
|
||||||
|
// authoritative; otherwise the legacy linear fields are converted into an
|
||||||
|
// equivalent graph at load time (see toGraph).
|
||||||
|
type Graph struct {
|
||||||
|
Nodes []GraphNode `json:"nodes"`
|
||||||
|
Output string `json:"output"` // id of the output node
|
||||||
|
}
|
||||||
|
|
||||||
|
// GraphNode is one node in a Graph.
|
||||||
|
//
|
||||||
|
// kind=="source": carries DS+Signal; has no Inputs (a graph root).
|
||||||
|
// kind=="op": carries Op + Params; Inputs lists upstream node IDs in the
|
||||||
|
// order the op receives them (input 0, 1, … e.g. a−b, a÷b).
|
||||||
|
// kind=="output": Inputs has a single upstream node whose value is the result.
|
||||||
|
//
|
||||||
|
// X/Y are the editor layout coordinates, persisted so a reloaded graph keeps its
|
||||||
|
// shape; they have no effect on evaluation.
|
||||||
|
type GraphNode struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Op string `json:"op,omitempty"`
|
||||||
|
Params map[string]any `json:"params,omitempty"`
|
||||||
|
DS string `json:"ds,omitempty"`
|
||||||
|
Signal string `json:"signal,omitempty"`
|
||||||
|
Inputs []string `json:"inputs,omitempty"`
|
||||||
|
X float64 `json:"x,omitempty"`
|
||||||
|
Y float64 `json:"y,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// MetaOverride allows the synthetic signal to override display metadata.
|
// MetaOverride allows the synthetic signal to override display metadata.
|
||||||
type MetaOverride struct {
|
type MetaOverride struct {
|
||||||
Unit string `json:"unit,omitempty"`
|
Unit string `json:"unit,omitempty"`
|
||||||
|
|||||||
@@ -38,21 +38,32 @@ func stringParam(params map[string]any, key string) string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildPipeline converts a []NodeDef (from JSON) into a []dsp.Node ready for
|
// stringSliceParam extracts a []string from params; JSON arrays decode to []any,
|
||||||
// execution. JSON numbers are float64, so all numeric params are handled as
|
// so each element is coerced via its string value. Returns nil if missing.
|
||||||
// float64 regardless of the final type needed.
|
func stringSliceParam(params map[string]any, key string) []string {
|
||||||
func BuildPipeline(defs []NodeDef) ([]dsp.Node, error) {
|
if params == nil {
|
||||||
nodes := make([]dsp.Node, 0, len(defs))
|
return nil
|
||||||
for i, d := range defs {
|
|
||||||
n, err := buildNode(d)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("pipeline node %d (%q): %w", i, d.Type, err)
|
|
||||||
}
|
|
||||||
nodes = append(nodes, n)
|
|
||||||
}
|
}
|
||||||
return nodes, nil
|
v, ok := params[key]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
arr, ok := v.([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(arr))
|
||||||
|
for _, e := range arr {
|
||||||
|
if s, ok := e.(string); ok && s != "" {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildNode converts a single NodeDef (from JSON) into a dsp.Node ready for
|
||||||
|
// execution. JSON numbers are float64, so all numeric params are handled as
|
||||||
|
// float64 regardless of the final type needed.
|
||||||
func buildNode(d NodeDef) (dsp.Node, error) {
|
func buildNode(d NodeDef) (dsp.Node, error) {
|
||||||
p := d.Params
|
p := d.Params
|
||||||
switch d.Type {
|
switch d.Type {
|
||||||
@@ -100,7 +111,7 @@ func buildNode(d NodeDef) (dsp.Node, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
|
|
||||||
case "expr":
|
case "expr":
|
||||||
return &dsp.ExprNode{Expr: stringParam(p, "expr")}, nil
|
return &dsp.ExprNode{Expr: stringParam(p, "expr"), Vars: stringSliceParam(p, "vars")}, nil
|
||||||
|
|
||||||
case "lowpass":
|
case "lowpass":
|
||||||
order := int(floatParam(p, "order"))
|
order := int(floatParam(p, "order"))
|
||||||
@@ -113,7 +124,7 @@ func buildNode(d NodeDef) (dsp.Node, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
|
|
||||||
case "lua":
|
case "lua":
|
||||||
return &dsp.LuaNode{Script: stringParam(p, "script")}, nil
|
return &dsp.LuaNode{Script: stringParam(p, "script"), Vars: stringSliceParam(p, "vars")}, nil
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown node type %q", d.Type)
|
return nil, fmt.Errorf("unknown node type %q", d.Type)
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package synthetic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/uopi/uopi/internal/broker"
|
||||||
|
"github.com/uopi/uopi/internal/dsp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runtimeGraph is the executable form of a synthetic signal's DAG. Nodes are
|
||||||
|
// held in topological order so a single forward pass computes every value with
|
||||||
|
// each node's inputs already resolved. Op-node state maps persist across
|
||||||
|
// evaluations (for stateful nodes like moving_average / lua).
|
||||||
|
type runtimeGraph struct {
|
||||||
|
order []*rtNode // topological order (sources first, output last)
|
||||||
|
sources []rtSource // source nodes, in topological order
|
||||||
|
outputID string // id of the output node
|
||||||
|
}
|
||||||
|
|
||||||
|
type rtNode struct {
|
||||||
|
id string
|
||||||
|
kind string // source | op | output
|
||||||
|
op dsp.Node // set for kind==op
|
||||||
|
state map[string]any // persistent per-node state (op only)
|
||||||
|
inputs []string // upstream node ids, in input order
|
||||||
|
}
|
||||||
|
|
||||||
|
type rtSource struct {
|
||||||
|
id string
|
||||||
|
ref broker.SignalRef
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceRefs returns the broker references for every source node, in a stable
|
||||||
|
// order matching rg.sources.
|
||||||
|
func (rg *runtimeGraph) sourceRefs() []broker.SignalRef {
|
||||||
|
refs := make([]broker.SignalRef, len(rg.sources))
|
||||||
|
for i, s := range rg.sources {
|
||||||
|
refs[i] = s.ref
|
||||||
|
}
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
|
||||||
|
// eval computes the output value given the latest value for each source node
|
||||||
|
// (keyed by source node id). Nodes are visited in topological order so every
|
||||||
|
// input is already present in vals by the time a node is processed.
|
||||||
|
func (rg *runtimeGraph) eval(sourceVals map[string]float64) (float64, error) {
|
||||||
|
vals := make(map[string]float64, len(rg.order))
|
||||||
|
for id, v := range sourceVals {
|
||||||
|
vals[id] = v
|
||||||
|
}
|
||||||
|
for _, n := range rg.order {
|
||||||
|
switch n.kind {
|
||||||
|
case "op":
|
||||||
|
in := make([]float64, len(n.inputs))
|
||||||
|
for i, id := range n.inputs {
|
||||||
|
in[i] = vals[id]
|
||||||
|
}
|
||||||
|
r, err := n.op.Process(in, n.state)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("node %s (%s): %w", n.id, n.op.Type(), err)
|
||||||
|
}
|
||||||
|
vals[n.id] = r
|
||||||
|
case "output":
|
||||||
|
if len(n.inputs) > 0 {
|
||||||
|
vals[n.id] = vals[n.inputs[0]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return vals[rg.outputID], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// compileGraph converts a SignalDef into an executable runtimeGraph. When the
|
||||||
|
// def carries an explicit Graph it is used directly; otherwise the legacy
|
||||||
|
// Inputs+Pipeline form is converted to an equivalent linear graph (see toGraph).
|
||||||
|
func compileGraph(def SignalDef) (*runtimeGraph, error) {
|
||||||
|
g := toGraph(def)
|
||||||
|
if g == nil || len(g.Nodes) == 0 {
|
||||||
|
return &runtimeGraph{}, nil
|
||||||
|
}
|
||||||
|
order, err := topoOrder(g)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rg := &runtimeGraph{outputID: g.Output}
|
||||||
|
for _, gn := range order {
|
||||||
|
switch gn.Kind {
|
||||||
|
case "source":
|
||||||
|
rg.sources = append(rg.sources, rtSource{id: gn.ID, ref: broker.SignalRef{DS: gn.DS, Name: gn.Signal}})
|
||||||
|
case "op":
|
||||||
|
node, err := buildNode(NodeDef{Type: gn.Op, Params: gn.Params})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("node %q: %w", gn.ID, err)
|
||||||
|
}
|
||||||
|
rg.order = append(rg.order, &rtNode{id: gn.ID, kind: "op", op: node, state: map[string]any{}, inputs: gn.Inputs})
|
||||||
|
case "output":
|
||||||
|
rg.outputID = gn.ID
|
||||||
|
rg.order = append(rg.order, &rtNode{id: gn.ID, kind: "output", inputs: gn.Inputs})
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("node %q: unknown kind %q", gn.ID, gn.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// topoOrder returns the graph's nodes in a topological (dependency-first) order,
|
||||||
|
// treating each node's Inputs as its predecessors. It errors on dangling input
|
||||||
|
// references or cycles.
|
||||||
|
func topoOrder(g *Graph) ([]GraphNode, error) {
|
||||||
|
byID := make(map[string]GraphNode, len(g.Nodes))
|
||||||
|
for _, n := range g.Nodes {
|
||||||
|
byID[n.ID] = n
|
||||||
|
}
|
||||||
|
indeg := make(map[string]int, len(g.Nodes))
|
||||||
|
succ := make(map[string][]string, len(g.Nodes))
|
||||||
|
for _, n := range g.Nodes {
|
||||||
|
if _, ok := indeg[n.ID]; !ok {
|
||||||
|
indeg[n.ID] = 0
|
||||||
|
}
|
||||||
|
for _, in := range n.Inputs {
|
||||||
|
if _, ok := byID[in]; !ok {
|
||||||
|
return nil, fmt.Errorf("node %q references unknown input %q", n.ID, in)
|
||||||
|
}
|
||||||
|
indeg[n.ID]++
|
||||||
|
succ[in] = append(succ[in], n.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Seed the queue with roots, preserving the node slice order for determinism.
|
||||||
|
queue := make([]string, 0, len(g.Nodes))
|
||||||
|
for _, n := range g.Nodes {
|
||||||
|
if indeg[n.ID] == 0 {
|
||||||
|
queue = append(queue, n.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
order := make([]GraphNode, 0, len(g.Nodes))
|
||||||
|
for len(queue) > 0 {
|
||||||
|
id := queue[0]
|
||||||
|
queue = queue[1:]
|
||||||
|
order = append(order, byID[id])
|
||||||
|
for _, s := range succ[id] {
|
||||||
|
indeg[s]--
|
||||||
|
if indeg[s] == 0 {
|
||||||
|
queue = append(queue, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(order) != len(g.Nodes) {
|
||||||
|
return nil, errors.New("graph contains a cycle")
|
||||||
|
}
|
||||||
|
return order, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// toGraph returns the DAG for a SignalDef. If def.Graph is set it is returned
|
||||||
|
// as-is. Otherwise the legacy linear form is converted: each input signal
|
||||||
|
// becomes a source node, the pipeline becomes a chain of op nodes (the first op
|
||||||
|
// receiving every source, each later op the previous op's output), terminated by
|
||||||
|
// an output node. With no pipeline the output takes the first source directly,
|
||||||
|
// matching the old runPipeline behaviour.
|
||||||
|
func toGraph(def SignalDef) *Graph {
|
||||||
|
if def.Graph != nil && len(def.Graph.Nodes) > 0 {
|
||||||
|
return def.Graph
|
||||||
|
}
|
||||||
|
|
||||||
|
inputs := def.Inputs
|
||||||
|
if len(inputs) == 0 && def.DS != "" && def.Signal != "" {
|
||||||
|
inputs = []InputRef{{DS: def.DS, Signal: def.Signal}}
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes := make([]GraphNode, 0, len(inputs)+len(def.Pipeline)+1)
|
||||||
|
srcIDs := make([]string, 0, len(inputs))
|
||||||
|
for i, inp := range inputs {
|
||||||
|
id := fmt.Sprintf("s%d", i)
|
||||||
|
nodes = append(nodes, GraphNode{ID: id, Kind: "source", DS: inp.DS, Signal: inp.Signal})
|
||||||
|
srcIDs = append(srcIDs, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
opIDs := make([]string, 0, len(def.Pipeline))
|
||||||
|
for i, nd := range def.Pipeline {
|
||||||
|
id := fmt.Sprintf("p%d", i)
|
||||||
|
var ins []string
|
||||||
|
if i == 0 {
|
||||||
|
ins = srcIDs
|
||||||
|
} else {
|
||||||
|
ins = []string{opIDs[i-1]}
|
||||||
|
}
|
||||||
|
nodes = append(nodes, GraphNode{ID: id, Kind: "op", Op: nd.Type, Params: nd.Params, Inputs: ins})
|
||||||
|
opIDs = append(opIDs, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
var outInputs []string
|
||||||
|
if len(opIDs) > 0 {
|
||||||
|
outInputs = []string{opIDs[len(opIDs)-1]}
|
||||||
|
} else if len(srcIDs) > 0 {
|
||||||
|
outInputs = []string{srcIDs[0]}
|
||||||
|
}
|
||||||
|
nodes = append(nodes, GraphNode{ID: "out", Kind: "output", Inputs: outInputs})
|
||||||
|
|
||||||
|
return &Graph{Nodes: nodes, Output: "out"}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package synthetic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// evalDef compiles a SignalDef and evaluates it against per-source values keyed
|
||||||
|
// by source node id.
|
||||||
|
func evalDef(t *testing.T, def SignalDef, srcVals map[string]float64) float64 {
|
||||||
|
t.Helper()
|
||||||
|
rg, err := compileGraph(def)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("compileGraph: %v", err)
|
||||||
|
}
|
||||||
|
out, err := rg.eval(srcVals)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("eval: %v", err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGraphMultiInputDAG verifies that an intermediate op can take two
|
||||||
|
// independently-wired sources — the capability the old linear pipeline lacked.
|
||||||
|
func TestGraphMultiInputDAG(t *testing.T) {
|
||||||
|
def := SignalDef{
|
||||||
|
Name: "diff",
|
||||||
|
Graph: &Graph{
|
||||||
|
Output: "out",
|
||||||
|
Nodes: []GraphNode{
|
||||||
|
{ID: "a", Kind: "source", DS: "x", Signal: "left"},
|
||||||
|
{ID: "b", Kind: "source", DS: "x", Signal: "right"},
|
||||||
|
{ID: "sub", Kind: "op", Op: "subtract", Inputs: []string{"a", "b"}},
|
||||||
|
{ID: "out", Kind: "output", Inputs: []string{"sub"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got := evalDef(t, def, map[string]float64{"a": 10, "b": 3})
|
||||||
|
if got != 7 {
|
||||||
|
t.Errorf("subtract DAG: want 7, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGraphExprNamedInputs verifies expr nodes bind arbitrary named inputs in
|
||||||
|
// wired order.
|
||||||
|
func TestGraphExprNamedInputs(t *testing.T) {
|
||||||
|
def := SignalDef{
|
||||||
|
Name: "formula",
|
||||||
|
Graph: &Graph{
|
||||||
|
Output: "out",
|
||||||
|
Nodes: []GraphNode{
|
||||||
|
{ID: "a", Kind: "source", DS: "x", Signal: "p"},
|
||||||
|
{ID: "b", Kind: "source", DS: "x", Signal: "q"},
|
||||||
|
{ID: "e", Kind: "op", Op: "expr", Inputs: []string{"a", "b"},
|
||||||
|
Params: map[string]any{"expr": "price * qty", "vars": []any{"price", "qty"}}},
|
||||||
|
{ID: "out", Kind: "output", Inputs: []string{"e"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got := evalDef(t, def, map[string]float64{"a": 4, "b": 2.5})
|
||||||
|
if math.Abs(got-10) > 1e-9 {
|
||||||
|
t.Errorf("expr named inputs: want 10, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGraphFanInToExpr exercises a non-trivial DAG: two ops feeding one expr.
|
||||||
|
func TestGraphFanInToExpr(t *testing.T) {
|
||||||
|
def := SignalDef{
|
||||||
|
Name: "combo",
|
||||||
|
Graph: &Graph{
|
||||||
|
Output: "out",
|
||||||
|
Nodes: []GraphNode{
|
||||||
|
{ID: "a", Kind: "source", DS: "x", Signal: "p"},
|
||||||
|
{ID: "b", Kind: "source", DS: "x", Signal: "q"},
|
||||||
|
{ID: "g", Kind: "op", Op: "gain", Inputs: []string{"a"}, Params: map[string]any{"gain": 2.0}},
|
||||||
|
{ID: "o", Kind: "op", Op: "offset", Inputs: []string{"b"}, Params: map[string]any{"offset": 1.0}},
|
||||||
|
{ID: "e", Kind: "op", Op: "expr", Inputs: []string{"g", "o"}, Params: map[string]any{"expr": "a + b"}},
|
||||||
|
{ID: "out", Kind: "output", Inputs: []string{"e"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
// g = 5*2 = 10 ; o = 4+1 = 5 ; a+b = 15
|
||||||
|
got := evalDef(t, def, map[string]float64{"a": 5, "b": 4})
|
||||||
|
if math.Abs(got-15) > 1e-9 {
|
||||||
|
t.Errorf("fan-in DAG: want 15, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGraphLegacyConversion verifies the linear Inputs+Pipeline form still
|
||||||
|
// evaluates correctly via the graph runtime.
|
||||||
|
func TestGraphLegacyConversion(t *testing.T) {
|
||||||
|
def := SignalDef{
|
||||||
|
Name: "legacy",
|
||||||
|
DS: "x",
|
||||||
|
Signal: "p",
|
||||||
|
Pipeline: []NodeDef{{Type: "gain", Params: map[string]any{"gain": 3.0}}},
|
||||||
|
}
|
||||||
|
rg, err := compileGraph(def)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("compileGraph: %v", err)
|
||||||
|
}
|
||||||
|
if len(rg.sources) != 1 {
|
||||||
|
t.Fatalf("want 1 source, got %d", len(rg.sources))
|
||||||
|
}
|
||||||
|
got, err := rg.eval(map[string]float64{rg.sources[0].id: 4})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("eval: %v", err)
|
||||||
|
}
|
||||||
|
if got != 12 {
|
||||||
|
t.Errorf("legacy gain: want 12, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGraphCycleRejected ensures a cyclic graph is refused at compile time.
|
||||||
|
func TestGraphCycleRejected(t *testing.T) {
|
||||||
|
def := SignalDef{
|
||||||
|
Name: "cyclic",
|
||||||
|
Graph: &Graph{
|
||||||
|
Output: "out",
|
||||||
|
Nodes: []GraphNode{
|
||||||
|
{ID: "a", Kind: "op", Op: "gain", Inputs: []string{"b"}, Params: map[string]any{"gain": 1.0}},
|
||||||
|
{ID: "b", Kind: "op", Op: "gain", Inputs: []string{"a"}, Params: map[string]any{"gain": 1.0}},
|
||||||
|
{ID: "out", Kind: "output", Inputs: []string{"a"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if _, err := compileGraph(def); err == nil {
|
||||||
|
t.Error("expected cycle to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,16 +13,14 @@ import (
|
|||||||
|
|
||||||
"github.com/uopi/uopi/internal/broker"
|
"github.com/uopi/uopi/internal/broker"
|
||||||
"github.com/uopi/uopi/internal/datasource"
|
"github.com/uopi/uopi/internal/datasource"
|
||||||
"github.com/uopi/uopi/internal/dsp"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const definitionsFile = "synthetic.json"
|
const definitionsFile = "synthetic.json"
|
||||||
|
|
||||||
// signalState holds everything needed to run one synthetic signal.
|
// signalState holds everything needed to run one synthetic signal.
|
||||||
type signalState struct {
|
type signalState struct {
|
||||||
def SignalDef
|
def SignalDef
|
||||||
nodes []dsp.Node
|
rg *runtimeGraph // compiled DAG; op-node state persists across evaluations
|
||||||
states []map[string]any // one map per node, persistent across calls
|
|
||||||
|
|
||||||
// cancel stops the goroutine driving this signal.
|
// cancel stops the goroutine driving this signal.
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
@@ -123,19 +121,25 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
|
|||||||
return nil, datasource.ErrNotFound
|
return nil, datasource.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect the upstream references for this signal.
|
// Collect the source node references for this signal's DAG.
|
||||||
refs := upstreamRefs(st.def)
|
refs := st.rg.sourceRefs()
|
||||||
if len(refs) == 0 {
|
if len(refs) == 0 {
|
||||||
return nil, fmt.Errorf("synthetic: signal %q has no upstream inputs", signal)
|
return nil, fmt.Errorf("synthetic: signal %q has no upstream inputs", signal)
|
||||||
}
|
}
|
||||||
|
// Source node ids, index-aligned with refs, so updates map to graph inputs.
|
||||||
|
srcIDs := make([]string, len(st.rg.sources))
|
||||||
|
for i, s := range st.rg.sources {
|
||||||
|
srcIDs[i] = s.id
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(ctx)
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Latest value per upstream input index.
|
// Latest value and timestamp per source node id.
|
||||||
latest := make([]float64, len(refs))
|
latest := make(map[string]float64, len(refs))
|
||||||
|
latestTs := make([]time.Time, len(refs))
|
||||||
ready := make([]bool, len(refs))
|
ready := make([]bool, len(refs))
|
||||||
|
|
||||||
// Subscribe to every upstream ref via the broker.
|
// Subscribe to every upstream ref via the broker.
|
||||||
@@ -184,7 +188,8 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
|
|||||||
return
|
return
|
||||||
|
|
||||||
case upd := <-updateCh:
|
case upd := <-updateCh:
|
||||||
latest[upd.idx] = upd.val
|
latest[srcIDs[upd.idx]] = upd.val
|
||||||
|
latestTs[upd.idx] = upd.ts
|
||||||
ready[upd.idx] = true
|
ready[upd.idx] = true
|
||||||
|
|
||||||
// Only compute once we have at least one value for every input.
|
// Only compute once we have at least one value for every input.
|
||||||
@@ -199,7 +204,19 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run the pipeline.
|
// The output is computed from the latest value of every input, so
|
||||||
|
// its timestamp is the most recent contributing sample time. Using
|
||||||
|
// the triggering update's timestamp instead would drag the output
|
||||||
|
// back in time whenever a slow/stale input fired, producing
|
||||||
|
// non-monotonic or duplicated timestamps on plots.
|
||||||
|
outTs := latestTs[0]
|
||||||
|
for _, ts := range latestTs[1:] {
|
||||||
|
if ts.After(outTs) {
|
||||||
|
outTs = ts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate the DAG.
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
cur, stillExists := s.signals[signal]
|
cur, stillExists := s.signals[signal]
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
@@ -207,14 +224,14 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := runPipeline(cur.nodes, cur.states, latest)
|
result, err := cur.rg.eval(latest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Warn("synthetic: pipeline error", "signal", signal, "err", err)
|
s.log.Warn("synthetic: pipeline error", "signal", signal, "err", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
v := datasource.Value{
|
v := datasource.Value{
|
||||||
Timestamp: upd.ts,
|
Timestamp: outTs,
|
||||||
Data: result,
|
Data: result,
|
||||||
Quality: datasource.QualityGood,
|
Quality: datasource.QualityGood,
|
||||||
}
|
}
|
||||||
@@ -246,9 +263,9 @@ func (s *Synthetic) AddSignal(def SignalDef) error {
|
|||||||
return errors.New("signal name must not be empty")
|
return errors.New("signal name must not be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
nodes, err := BuildPipeline(def.Pipeline)
|
rg, err := compileGraph(def)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("build pipeline: %w", err)
|
return fmt.Errorf("compile graph: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@@ -257,16 +274,7 @@ func (s *Synthetic) AddSignal(def SignalDef) error {
|
|||||||
return fmt.Errorf("signal %q already exists", def.Name)
|
return fmt.Errorf("signal %q already exists", def.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
states := make([]map[string]any, len(nodes))
|
st := &signalState{def: def, rg: rg}
|
||||||
for i := range states {
|
|
||||||
states[i] = make(map[string]any)
|
|
||||||
}
|
|
||||||
|
|
||||||
st := &signalState{
|
|
||||||
def: def,
|
|
||||||
nodes: nodes,
|
|
||||||
states: states,
|
|
||||||
}
|
|
||||||
s.signals[def.Name] = st
|
s.signals[def.Name] = st
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
@@ -320,14 +328,9 @@ func (s *Synthetic) UpdateSignal(def SignalDef) error {
|
|||||||
return errors.New("signal name must not be empty")
|
return errors.New("signal name must not be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
nodes, err := BuildPipeline(def.Pipeline)
|
rg, err := compileGraph(def)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("build pipeline: %w", err)
|
return fmt.Errorf("compile graph: %w", err)
|
||||||
}
|
|
||||||
|
|
||||||
states := make([]map[string]any, len(nodes))
|
|
||||||
for i := range states {
|
|
||||||
states[i] = make(map[string]any)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@@ -339,7 +342,7 @@ func (s *Synthetic) UpdateSignal(def SignalDef) error {
|
|||||||
if old.cancel != nil {
|
if old.cancel != nil {
|
||||||
old.cancel()
|
old.cancel()
|
||||||
}
|
}
|
||||||
s.signals[def.Name] = &signalState{def: def, nodes: nodes, states: states}
|
s.signals[def.Name] = &signalState{def: def, rg: rg}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
if err := s.saveDefs(); err != nil {
|
if err := s.saveDefs(); err != nil {
|
||||||
@@ -402,73 +405,22 @@ func (s *Synthetic) saveDefs() error {
|
|||||||
return os.WriteFile(s.defsFilePath(), data, 0o644)
|
return os.WriteFile(s.defsFilePath(), data, 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
// startSignal builds the pipeline for def and registers the signalState.
|
// startSignal compiles the DAG for def and registers the signalState.
|
||||||
// The actual goroutines are started lazily by Subscribe.
|
// The actual goroutines are started lazily by Subscribe.
|
||||||
func (s *Synthetic) startSignal(def SignalDef) error {
|
func (s *Synthetic) startSignal(def SignalDef) error {
|
||||||
nodes, err := BuildPipeline(def.Pipeline)
|
rg, err := compileGraph(def)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("build pipeline for %q: %w", def.Name, err)
|
return fmt.Errorf("compile graph for %q: %w", def.Name, err)
|
||||||
}
|
|
||||||
|
|
||||||
states := make([]map[string]any, len(nodes))
|
|
||||||
for i := range states {
|
|
||||||
states[i] = make(map[string]any)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.signals[def.Name] = &signalState{
|
s.signals[def.Name] = &signalState{def: def, rg: rg}
|
||||||
def: def,
|
|
||||||
nodes: nodes,
|
|
||||||
states: states,
|
|
||||||
}
|
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
s.log.Info("synthetic: signal registered", "name", def.Name)
|
s.log.Info("synthetic: signal registered", "name", def.Name)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// runPipeline executes all nodes in sequence. The output of node N becomes
|
|
||||||
// input[0] of node N+1. For the first node, inputs is the full upstream slice.
|
|
||||||
func runPipeline(nodes []dsp.Node, states []map[string]any, inputs []float64) (float64, error) {
|
|
||||||
if len(nodes) == 0 {
|
|
||||||
if len(inputs) == 0 {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
return inputs[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// First node receives all upstream inputs.
|
|
||||||
cur := inputs
|
|
||||||
var result float64
|
|
||||||
var err error
|
|
||||||
|
|
||||||
for i, node := range nodes {
|
|
||||||
result, err = node.Process(cur, states[i])
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("node %d (%s): %w", i, node.Type(), err)
|
|
||||||
}
|
|
||||||
// Subsequent nodes receive only the single output of the previous node.
|
|
||||||
cur = []float64{result}
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// upstreamRefs returns the broker.SignalRef list for a SignalDef.
|
|
||||||
// If Inputs is set, those take precedence; otherwise DS+Signal is used.
|
|
||||||
func upstreamRefs(def SignalDef) []broker.SignalRef {
|
|
||||||
if len(def.Inputs) > 0 {
|
|
||||||
refs := make([]broker.SignalRef, len(def.Inputs))
|
|
||||||
for i, inp := range def.Inputs {
|
|
||||||
refs[i] = broker.SignalRef{DS: inp.DS, Name: inp.Signal}
|
|
||||||
}
|
|
||||||
return refs
|
|
||||||
}
|
|
||||||
if def.DS != "" && def.Signal != "" {
|
|
||||||
return []broker.SignalRef{{DS: def.DS, Name: def.Signal}}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// defToMetadata converts a SignalDef into a datasource.Metadata.
|
// defToMetadata converts a SignalDef into a datasource.Metadata.
|
||||||
func defToMetadata(def SignalDef) datasource.Metadata {
|
func defToMetadata(def SignalDef) datasource.Metadata {
|
||||||
return datasource.Metadata{
|
return datasource.Metadata{
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package synthetic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/uopi/uopi/internal/broker"
|
||||||
|
"github.com/uopi/uopi/internal/datasource"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seqSource is a test DataSource that emits a fixed sequence of values, each
|
||||||
|
// carrying its own timestamp, so tests can control upstream sample times.
|
||||||
|
type seqSource struct {
|
||||||
|
name string
|
||||||
|
seq []datasource.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *seqSource) Name() string { return s.name }
|
||||||
|
func (s *seqSource) Connect(context.Context) error { return nil }
|
||||||
|
func (s *seqSource) ListSignals(context.Context) ([]datasource.Metadata, error) { return nil, nil }
|
||||||
|
func (s *seqSource) GetMetadata(context.Context, string) (datasource.Metadata, error) {
|
||||||
|
return datasource.Metadata{Name: "x", Type: datasource.TypeFloat64}, nil
|
||||||
|
}
|
||||||
|
func (s *seqSource) Write(context.Context, string, any) error { return datasource.ErrNotWritable }
|
||||||
|
func (s *seqSource) History(context.Context, string, time.Time, time.Time, int) ([]datasource.Value, error) {
|
||||||
|
return nil, datasource.ErrHistoryUnavailable
|
||||||
|
}
|
||||||
|
func (s *seqSource) Subscribe(ctx context.Context, _ string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
|
||||||
|
go func() {
|
||||||
|
for _, v := range s.seq {
|
||||||
|
select {
|
||||||
|
case ch <- v:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(8 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return func() {}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSubscribePreservesUpstreamTimestamp verifies a single-source synthetic
|
||||||
|
// emits each computed value with the upstream sample's timestamp.
|
||||||
|
func TestSubscribePreservesUpstreamTimestamp(t *testing.T) {
|
||||||
|
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
base := time.Date(2026, 6, 19, 10, 0, 0, 0, time.UTC)
|
||||||
|
src := &seqSource{name: "src", seq: []datasource.Value{
|
||||||
|
{Timestamp: base.Add(1 * time.Second), Data: 1.0, Quality: datasource.QualityGood},
|
||||||
|
{Timestamp: base.Add(2 * time.Second), Data: 2.0, Quality: datasource.QualityGood},
|
||||||
|
{Timestamp: base.Add(3 * time.Second), Data: 3.0, Quality: datasource.QualityGood},
|
||||||
|
}}
|
||||||
|
|
||||||
|
brk := broker.New(ctx, log)
|
||||||
|
brk.Register(src)
|
||||||
|
syn := New(t.TempDir(), brk, log)
|
||||||
|
if err := syn.Connect(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := syn.AddSignal(SignalDef{
|
||||||
|
Name: "g", DS: "src", Signal: "x",
|
||||||
|
Pipeline: []NodeDef{{Type: "gain", Params: map[string]any{"gain": 10.0}}},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := make(chan datasource.Value, 8)
|
||||||
|
if _, err := syn.Subscribe(ctx, "g", ch); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []time.Time{base.Add(1 * time.Second), base.Add(2 * time.Second), base.Add(3 * time.Second)}
|
||||||
|
for i, w := range want {
|
||||||
|
select {
|
||||||
|
case v := <-ch:
|
||||||
|
if !v.Timestamp.Equal(w) {
|
||||||
|
t.Errorf("emit #%d timestamp: want %s, got %s", i, w, v.Timestamp)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatalf("timeout waiting for emit #%d", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSubscribeMultiSourceUsesLatestTimestamp verifies that a synthetic combining
|
||||||
|
// two independent sources stamps each output with the MOST RECENT contributing
|
||||||
|
// sample time — not the timestamp of whichever source happened to trigger the
|
||||||
|
// computation. A slow source carrying a stale timestamp must not drag the output
|
||||||
|
// backwards in time (which previously produced wrong/non-monotonic plot points).
|
||||||
|
func TestSubscribeMultiSourceUsesLatestTimestamp(t *testing.T) {
|
||||||
|
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
now := time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC)
|
||||||
|
// Fast source A with current timestamps.
|
||||||
|
a := &seqSource{name: "A", seq: []datasource.Value{
|
||||||
|
{Timestamp: now.Add(10 * time.Second), Data: 1.0, Quality: datasource.QualityGood},
|
||||||
|
{Timestamp: now.Add(11 * time.Second), Data: 2.0, Quality: datasource.QualityGood},
|
||||||
|
{Timestamp: now.Add(12 * time.Second), Data: 3.0, Quality: datasource.QualityGood},
|
||||||
|
{Timestamp: now.Add(13 * time.Second), Data: 4.0, Quality: datasource.QualityGood},
|
||||||
|
}}
|
||||||
|
// Slow source B: a single sample with a much older timestamp.
|
||||||
|
b := &seqSource{name: "B", seq: []datasource.Value{
|
||||||
|
{Timestamp: now.Add(1 * time.Second), Data: 100.0, Quality: datasource.QualityGood},
|
||||||
|
}}
|
||||||
|
|
||||||
|
brk := broker.New(ctx, log)
|
||||||
|
brk.Register(a)
|
||||||
|
brk.Register(b)
|
||||||
|
syn := New(t.TempDir(), brk, log)
|
||||||
|
if err := syn.Connect(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := syn.AddSignal(SignalDef{
|
||||||
|
Name: "diff",
|
||||||
|
Graph: &Graph{Output: "out", Nodes: []GraphNode{
|
||||||
|
{ID: "sa", Kind: "source", DS: "A", Signal: "x"},
|
||||||
|
{ID: "sb", Kind: "source", DS: "B", Signal: "x"},
|
||||||
|
{ID: "sub", Kind: "op", Op: "subtract", Inputs: []string{"sa", "sb"}},
|
||||||
|
{ID: "out", Kind: "output", Inputs: []string{"sub"}},
|
||||||
|
}},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := make(chan datasource.Value, 16)
|
||||||
|
if _, err := syn.Subscribe(ctx, "diff", ch); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var last time.Time
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
select {
|
||||||
|
case v := <-ch:
|
||||||
|
// The stale source-B timestamp (t=1s) must never be used: every output
|
||||||
|
// is stamped with the newest input time, so emits stay monotonic.
|
||||||
|
if v.Timestamp.Equal(now.Add(1 * time.Second)) {
|
||||||
|
t.Errorf("emit #%d used the stale source-B timestamp %s", i, v.Timestamp)
|
||||||
|
}
|
||||||
|
if !last.IsZero() && v.Timestamp.Before(last) {
|
||||||
|
t.Errorf("emit #%d went backwards: %s before previous %s", i, v.Timestamp, last)
|
||||||
|
}
|
||||||
|
last = v.Timestamp
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatalf("timeout waiting for emit #%d", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-10
@@ -277,16 +277,28 @@ func (n *ThresholdNode) Process(inputs []float64, _ map[string]any) (float64, er
|
|||||||
|
|
||||||
// ── ExprNode ──────────────────────────────────────────────────────────────────
|
// ── ExprNode ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// ExprNode evaluates a simple arithmetic expression with variables a, b, c, d
|
// ExprNode evaluates a simple arithmetic expression. Inputs are bound to named
|
||||||
// bound to inputs[0..3]. It uses a hand-written recursive descent parser.
|
// variables: Vars[i] -> inputs[i]. When Vars is empty it defaults to a, b, c, d
|
||||||
|
// (bound to inputs[0..3]) for backward compatibility. It uses a hand-written
|
||||||
|
// recursive descent parser.
|
||||||
type ExprNode struct {
|
type ExprNode struct {
|
||||||
Expr string
|
Expr string
|
||||||
|
Vars []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultVarNames returns the variable names for an expr/lua node: the explicit
|
||||||
|
// list when set, otherwise the legacy a,b,c,d.
|
||||||
|
func defaultVarNames(vars []string) []string {
|
||||||
|
if len(vars) > 0 {
|
||||||
|
return vars
|
||||||
|
}
|
||||||
|
return []string{"a", "b", "c", "d"}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *ExprNode) Type() string { return "expr" }
|
func (n *ExprNode) Type() string { return "expr" }
|
||||||
func (n *ExprNode) Process(inputs []float64, _ map[string]any) (float64, error) {
|
func (n *ExprNode) Process(inputs []float64, _ map[string]any) (float64, error) {
|
||||||
vars := map[string]float64{}
|
vars := map[string]float64{}
|
||||||
names := []string{"a", "b", "c", "d"}
|
names := defaultVarNames(n.Vars)
|
||||||
for i, name := range names {
|
for i, name := range names {
|
||||||
if i < len(inputs) {
|
if i < len(inputs) {
|
||||||
vars[name] = inputs[i]
|
vars[name] = inputs[i]
|
||||||
@@ -507,13 +519,10 @@ func (p *exprParser) parseCall() (float64, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not a function call — must be a single-letter variable.
|
// Not a function call — must be a declared input variable.
|
||||||
if len(name) != 1 {
|
|
||||||
return 0, fmt.Errorf("unknown identifier %q (use a–d for variables, or a known function name)", name)
|
|
||||||
}
|
|
||||||
val, ok := p.vars[name]
|
val, ok := p.vars[name]
|
||||||
if !ok {
|
if !ok {
|
||||||
return 0, fmt.Errorf("unknown variable %q (allowed: a, b, c, d)", name)
|
return 0, fmt.Errorf("unknown variable %q (declare it as a named input)", name)
|
||||||
}
|
}
|
||||||
return val, nil
|
return val, nil
|
||||||
}
|
}
|
||||||
@@ -628,10 +637,12 @@ func (n *LowPassNode) Process(inputs []float64, state map[string]any) (float64,
|
|||||||
// ── LuaNode ───────────────────────────────────────────────────────────────────
|
// ── LuaNode ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// LuaNode runs a Lua script in a sandboxed gopher-lua VM.
|
// LuaNode runs a Lua script in a sandboxed gopher-lua VM.
|
||||||
// Inputs are bound to globals a, b, c, d. The script's return value is the output.
|
// Inputs are bound to globals named by Vars (Vars[i] -> inputs[i]); when Vars is
|
||||||
|
// empty it defaults to a, b, c, d. The script's return value is the output.
|
||||||
// The os, io, package, and debug libraries are disabled.
|
// The os, io, package, and debug libraries are disabled.
|
||||||
type LuaNode struct {
|
type LuaNode struct {
|
||||||
Script string
|
Script string
|
||||||
|
Vars []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *LuaNode) Type() string { return "lua" }
|
func (n *LuaNode) Type() string { return "lua" }
|
||||||
@@ -682,7 +693,7 @@ func (n *LuaNode) Process(inputs []float64, state map[string]any) (result float6
|
|||||||
L.SetTop(0)
|
L.SetTop(0)
|
||||||
|
|
||||||
// Bind inputs.
|
// Bind inputs.
|
||||||
names := []string{"a", "b", "c", "d"}
|
names := defaultVarNames(n.Vars)
|
||||||
for i, name := range names {
|
for i, name := range names {
|
||||||
if i < len(inputs) {
|
if i < len(inputs) {
|
||||||
L.SetGlobal(name, lua.LNumber(inputs[i]))
|
L.SetGlobal(name, lua.LNumber(inputs[i]))
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/uopi/uopi/internal/access"
|
||||||
|
"github.com/uopi/uopi/internal/audit"
|
||||||
|
"github.com/uopi/uopi/internal/broker"
|
||||||
|
"github.com/uopi/uopi/internal/controllogic"
|
||||||
|
"github.com/uopi/uopi/internal/datasource"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DialogHub fans control-logic dialog requests out to connected WebSocket
|
||||||
|
// clients whose identity matches the dialog's user/group filter, and routes
|
||||||
|
// input responses back to the dialog's target server variable.
|
||||||
|
//
|
||||||
|
// It implements controllogic.Notifier; the engine calls Notify when an
|
||||||
|
// action.dialog node runs. Input dialogs are remembered as pending so a later
|
||||||
|
// dialogResponse can be correlated by id and validated against its recipient
|
||||||
|
// filter — this is why panels can write the response target even though direct
|
||||||
|
// srv writes are otherwise gated to control-logic editors.
|
||||||
|
type DialogHub struct {
|
||||||
|
broker *broker.Broker
|
||||||
|
policy *access.Policy
|
||||||
|
audit audit.Recorder
|
||||||
|
log *slog.Logger
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
clients map[*wsClient]struct{}
|
||||||
|
pending map[string]controllogic.Dialog // input dialogs awaiting a response
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDialogHub builds an empty hub. rec is never nil after construction.
|
||||||
|
func NewDialogHub(brk *broker.Broker, policy *access.Policy, rec audit.Recorder, log *slog.Logger) *DialogHub {
|
||||||
|
if rec == nil {
|
||||||
|
rec = audit.Nop()
|
||||||
|
}
|
||||||
|
return &DialogHub{
|
||||||
|
broker: brk,
|
||||||
|
policy: policy,
|
||||||
|
audit: rec,
|
||||||
|
log: log,
|
||||||
|
clients: map[*wsClient]struct{}{},
|
||||||
|
pending: map[string]controllogic.Dialog{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DialogHub) add(c *wsClient) {
|
||||||
|
h.mu.Lock()
|
||||||
|
h.clients[c] = struct{}{}
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DialogHub) remove(c *wsClient) {
|
||||||
|
h.mu.Lock()
|
||||||
|
delete(h.clients, c)
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialogOut is the client-bound JSON form of a dialog request.
|
||||||
|
type dialogOut struct {
|
||||||
|
Type string `json:"type"` // always "dialog"
|
||||||
|
ID string `json:"id"`
|
||||||
|
Kind string `json:"kind"` // "info" | "error" | "input"
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify implements controllogic.Notifier: serialise the dialog and push it to
|
||||||
|
// every connected client matching its user/group filter.
|
||||||
|
func (h *DialogHub) Notify(d controllogic.Dialog) {
|
||||||
|
b, err := json.Marshal(dialogOut{
|
||||||
|
Type: "dialog",
|
||||||
|
ID: d.ID,
|
||||||
|
Kind: d.Kind,
|
||||||
|
Title: d.Title,
|
||||||
|
Message: d.Message,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
|
if d.Kind == "input" && strings.TrimSpace(d.Target) != "" {
|
||||||
|
h.pending[d.ID] = d
|
||||||
|
}
|
||||||
|
var targets []*wsClient
|
||||||
|
for c := range h.clients {
|
||||||
|
if h.matches(c.user, d) {
|
||||||
|
targets = append(targets, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
for _, c := range targets {
|
||||||
|
select {
|
||||||
|
case c.outCh <- b:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// matches reports whether user is a recipient of d. An empty user+group filter
|
||||||
|
// targets everyone; otherwise the user must be named or in a named group.
|
||||||
|
func (h *DialogHub) matches(user string, d controllogic.Dialog) bool {
|
||||||
|
if len(d.Users) == 0 && len(d.Groups) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if h.policy != nil {
|
||||||
|
user = h.policy.ResolveUser(user)
|
||||||
|
}
|
||||||
|
for _, u := range d.Users {
|
||||||
|
if u == user {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(d.Groups) > 0 && h.policy != nil {
|
||||||
|
groups := map[string]bool{}
|
||||||
|
for _, g := range h.policy.GroupsOf(user) {
|
||||||
|
groups[g] = true
|
||||||
|
}
|
||||||
|
for _, g := range d.Groups {
|
||||||
|
if groups[g] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// cancel drops a pending input dialog without writing (user dismissed it).
|
||||||
|
func (h *DialogHub) cancel(id string) {
|
||||||
|
h.mu.Lock()
|
||||||
|
delete(h.pending, id)
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// respond writes an input dialog's response to its target server variable. The
|
||||||
|
// dialog must be pending and the responding client must have been a recipient;
|
||||||
|
// this gate replaces the usual srv write-permission check for sanctioned
|
||||||
|
// control-logic responses.
|
||||||
|
func (h *DialogHub) respond(ctx context.Context, c *wsClient, id string, value float64) {
|
||||||
|
h.mu.Lock()
|
||||||
|
d, ok := h.pending[id]
|
||||||
|
if ok {
|
||||||
|
delete(h.pending, id)
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
if !ok || !h.matches(c.user, d) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ds, name, ok := parseDialogTarget(d.Target)
|
||||||
|
if !ok || ds == "local" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
src, ok := h.broker.Source(ds)
|
||||||
|
if !ok {
|
||||||
|
h.log.Warn("dialog response: unknown data source", "ds", ds, "target", d.Target)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ev := audit.Event{
|
||||||
|
Actor: c.user,
|
||||||
|
ActorType: audit.ActorUser,
|
||||||
|
Action: "signal.write",
|
||||||
|
DS: ds,
|
||||||
|
Signal: name,
|
||||||
|
Value: strconv.FormatFloat(value, 'g', -1, 64),
|
||||||
|
Detail: "control logic dialog response",
|
||||||
|
IP: c.ip,
|
||||||
|
Outcome: audit.OutcomeOK,
|
||||||
|
}
|
||||||
|
wctx := datasource.WithUser(ctx, c.user)
|
||||||
|
if err := src.Write(wctx, name, value); err != nil {
|
||||||
|
h.log.Warn("dialog response: write failed", "ds", ds, "signal", name, "err", err)
|
||||||
|
ev.Outcome = audit.OutcomeError
|
||||||
|
ev.Error = err.Error()
|
||||||
|
}
|
||||||
|
h.audit.Record(ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDialogTarget splits a "ds:name" dialog target on the first ':'. A bare
|
||||||
|
// name (no ':') defaults to the persistent server-variable source "srv".
|
||||||
|
func parseDialogTarget(t string) (ds, name string, ok bool) {
|
||||||
|
t = strings.TrimSpace(t)
|
||||||
|
if t == "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
if i := strings.IndexByte(t, ':'); i >= 0 {
|
||||||
|
return t[:i], t[i+1:], true
|
||||||
|
}
|
||||||
|
return "srv", t, true
|
||||||
|
}
|
||||||
@@ -28,7 +28,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, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, rec audit.Recorder, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server {
|
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, dialogs *DialogHub, rec audit.Recorder, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server {
|
||||||
if rec == nil {
|
if rec == nil {
|
||||||
rec = audit.Nop()
|
rec = audit.Nop()
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
|
|||||||
})
|
})
|
||||||
|
|
||||||
// WebSocket endpoint
|
// WebSocket endpoint
|
||||||
mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy, audit: rec})
|
mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy, audit: rec, dialogs: dialogs})
|
||||||
|
|
||||||
// Prometheus-format metrics
|
// Prometheus-format metrics
|
||||||
mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions))
|
mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions))
|
||||||
|
|||||||
+36
-2
@@ -34,6 +34,9 @@ type inMsg struct {
|
|||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
Value json.RawMessage `json:"value,omitempty"`
|
Value json.RawMessage `json:"value,omitempty"`
|
||||||
|
|
||||||
|
// dialogResponse — id of the control-logic dialog being answered.
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
|
||||||
// history
|
// history
|
||||||
Start time.Time `json:"start"`
|
Start time.Time `json:"start"`
|
||||||
End time.Time `json:"end"`
|
End time.Time `json:"end"`
|
||||||
@@ -101,6 +104,8 @@ type wsHandler struct {
|
|||||||
policy *access.Policy
|
policy *access.Policy
|
||||||
// audit records signal writes (never nil; audit.Nop when disabled).
|
// audit records signal writes (never nil; audit.Nop when disabled).
|
||||||
audit audit.Recorder
|
audit audit.Recorder
|
||||||
|
// dialogs fans control-logic dialogs to clients and routes responses.
|
||||||
|
dialogs *DialogHub
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -143,12 +148,19 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
ip: clientIP,
|
ip: clientIP,
|
||||||
policy: h.policy,
|
policy: h.policy,
|
||||||
audit: rec,
|
audit: rec,
|
||||||
|
dialogs: h.dialogs,
|
||||||
outCh: make(chan []byte, 512),
|
outCh: make(chan []byte, 512),
|
||||||
updateCh: make(chan broker.Update, 1024),
|
updateCh: make(chan broker.Update, 1024),
|
||||||
subs: make(map[broker.SignalRef]func()),
|
subs: make(map[broker.SignalRef]func()),
|
||||||
log: h.log,
|
log: h.log,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register for control-logic dialog pushes for this client's identity.
|
||||||
|
if h.dialogs != nil {
|
||||||
|
h.dialogs.add(c)
|
||||||
|
defer h.dialogs.remove(c)
|
||||||
|
}
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(3)
|
wg.Add(3)
|
||||||
go func() { defer wg.Done(); c.readLoop(ctx, cancel) }()
|
go func() { defer wg.Done(); c.readLoop(ctx, cancel) }()
|
||||||
@@ -174,8 +186,9 @@ type wsClient struct {
|
|||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
user string // end-user identity from the trusted proxy header ("" if none)
|
user string // end-user identity from the trusted proxy header ("" if none)
|
||||||
ip string // client address, for audit attribution
|
ip string // client address, for audit attribution
|
||||||
policy *access.Policy // global access-level enforcement
|
policy *access.Policy // global access-level enforcement
|
||||||
audit audit.Recorder // signal-write audit recorder (never nil)
|
audit audit.Recorder // signal-write audit recorder (never nil)
|
||||||
|
dialogs *DialogHub // control-logic dialog fan-out (nil if disabled)
|
||||||
|
|
||||||
outCh chan []byte // serialised outgoing messages
|
outCh chan []byte // serialised outgoing messages
|
||||||
updateCh chan broker.Update // raw updates from the broker
|
updateCh chan broker.Update // raw updates from the broker
|
||||||
@@ -272,6 +285,8 @@ func (c *wsClient) handleMessage(ctx context.Context, data []byte) {
|
|||||||
c.handleWrite(ctx, msg)
|
c.handleWrite(ctx, msg)
|
||||||
case "history":
|
case "history":
|
||||||
c.handleHistory(ctx, msg)
|
c.handleHistory(ctx, msg)
|
||||||
|
case "dialogResponse":
|
||||||
|
c.handleDialogResponse(ctx, msg)
|
||||||
default:
|
default:
|
||||||
c.sendError(ctx, "UNKNOWN_TYPE", "unknown message type: "+msg.Type)
|
c.sendError(ctx, "UNKNOWN_TYPE", "unknown message type: "+msg.Type)
|
||||||
}
|
}
|
||||||
@@ -389,6 +404,25 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
|
|||||||
c.audit.Record(ev)
|
c.audit.Record(ev)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleDialogResponse routes the answer to a control-logic input dialog. An
|
||||||
|
// empty/null value means the user dismissed the dialog (drop it without
|
||||||
|
// writing); otherwise the numeric value is written to the dialog's target.
|
||||||
|
func (c *wsClient) handleDialogResponse(ctx context.Context, msg inMsg) {
|
||||||
|
if c.dialogs == nil || msg.ID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(msg.Value) == 0 || string(msg.Value) == "null" {
|
||||||
|
c.dialogs.cancel(msg.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var num float64
|
||||||
|
if err := json.Unmarshal(msg.Value, &num); err != nil {
|
||||||
|
c.dialogs.cancel(msg.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.dialogs.respond(ctx, c, msg.ID, num)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *wsClient) handleHistory(ctx context.Context, msg inMsg) {
|
func (c *wsClient) handleHistory(ctx context.Context, msg inMsg) {
|
||||||
metrics.IncHistoryReqs()
|
metrics.IncHistoryReqs()
|
||||||
ds, ok := c.broker.Source(msg.DS)
|
ds, ok := c.broker.Source(msg.DS)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { wsClient } from './lib/ws';
|
|||||||
import ViewMode from './ViewMode';
|
import ViewMode from './ViewMode';
|
||||||
import EditMode from './EditMode';
|
import EditMode from './EditMode';
|
||||||
import FullscreenMode from './FullscreenMode';
|
import FullscreenMode from './FullscreenMode';
|
||||||
|
import ControlDialogs from './ControlDialogs';
|
||||||
import { applyZoom, getStoredZoom } from './ZoomControl';
|
import { applyZoom, getStoredZoom } from './ZoomControl';
|
||||||
import { AuthContext, DEFAULT_ME, fetchMe, canRead } from './lib/auth';
|
import { AuthContext, DEFAULT_ME, fetchMe, canRead } from './lib/auth';
|
||||||
import type { Interface, Me } from './lib/types';
|
import type { Interface, Me } from './lib/types';
|
||||||
@@ -69,6 +70,7 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={me}>
|
<AuthContext.Provider value={me}>
|
||||||
<div class="app-root">
|
<div class="app-root">
|
||||||
|
<ControlDialogs />
|
||||||
{wsStatus === 'disconnected' && (
|
{wsStatus === 'disconnected' && (
|
||||||
<div class="connection-banner">WebSocket disconnected — reconnecting…</div>
|
<div class="connection-banner">WebSocket disconnected — reconnecting…</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { h, Fragment } from 'preact';
|
||||||
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
|
import { wsClient } from './lib/ws';
|
||||||
|
import { controlDialogs, dismissControlDialog, type ControlDialog } from './lib/controldialogs';
|
||||||
|
|
||||||
|
// Renders dialogs pushed by server-side control-logic action.dialog nodes over
|
||||||
|
// the WebSocket. Mounted once globally (App) since these are addressed to the
|
||||||
|
// connected user, independent of which panel is open. info/error dialogs are
|
||||||
|
// acknowledgements; input dialogs send a numeric response back to the server.
|
||||||
|
export default function ControlDialogs() {
|
||||||
|
const [reqs, setReqs] = useState<ControlDialog[]>([]);
|
||||||
|
useEffect(() => controlDialogs.subscribe(setReqs), []);
|
||||||
|
if (reqs.length === 0) return null;
|
||||||
|
return <Fragment>{reqs.map(r => <ControlDialogModal key={r.id} req={r} />)}</Fragment>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ControlDialogModal({ req }: { req: ControlDialog; key?: string }) {
|
||||||
|
const isInput = req.kind === 'input';
|
||||||
|
const [val, setVal] = useState('');
|
||||||
|
|
||||||
|
function ok() {
|
||||||
|
if (isInput) {
|
||||||
|
const n = parseFloat(val);
|
||||||
|
wsClient.sendDialogResponse(req.id, Number.isFinite(n) ? n : null);
|
||||||
|
}
|
||||||
|
dismissControlDialog(req.id);
|
||||||
|
}
|
||||||
|
function cancel() {
|
||||||
|
if (isInput) wsClient.sendDialogResponse(req.id, null);
|
||||||
|
dismissControlDialog(req.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); ok(); }
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); cancel(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="logic-dialog-backdrop" onKeyDown={onKey}>
|
||||||
|
<div class={`logic-dialog logic-dialog-${req.kind}`} role="dialog">
|
||||||
|
<div class="logic-dialog-header">
|
||||||
|
{req.title || (req.kind === 'error' ? 'Error' : isInput ? 'Input required' : 'Info')}
|
||||||
|
</div>
|
||||||
|
{req.message && <div class="logic-dialog-message">{req.message}</div>}
|
||||||
|
{isInput && (
|
||||||
|
<div class="logic-dialog-field">
|
||||||
|
<input class="prop-input logic-dialog-input" type="number"
|
||||||
|
autoFocus value={val}
|
||||||
|
onInput={(e) => setVal((e.target as HTMLInputElement).value)}
|
||||||
|
onKeyDown={onKey} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div class="logic-dialog-actions">
|
||||||
|
{isInput && <button class="panel-btn" onClick={cancel}>Cancel</button>}
|
||||||
|
<button class="panel-btn panel-btn-primary" onClick={ok}>OK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { h, Fragment } from 'preact';
|
import { h, Fragment } from 'preact';
|
||||||
import { useState, useEffect, useRef } from 'preact/hooks';
|
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||||
import SearchableSelect from './SearchableSelect';
|
import SignalPicker, { SignalOption } from './SignalPicker';
|
||||||
import LuaEditor from './LuaEditor';
|
import LuaEditor from './LuaEditor';
|
||||||
import { checkExpr } from './lib/expr';
|
import { checkExpr } from './lib/expr';
|
||||||
|
|
||||||
@@ -18,7 +18,8 @@ type CLNodeKind =
|
|||||||
| 'action.write'
|
| 'action.write'
|
||||||
| 'action.delay'
|
| 'action.delay'
|
||||||
| 'action.log'
|
| 'action.log'
|
||||||
| 'action.lua';
|
| 'action.lua'
|
||||||
|
| 'action.dialog';
|
||||||
|
|
||||||
interface CLNode {
|
interface CLNode {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -55,6 +56,11 @@ const NODE_W = 11.5 * REM;
|
|||||||
const PORT_TOP = 2 * REM;
|
const PORT_TOP = 2 * REM;
|
||||||
const PORT_GAP = 1.375 * REM;
|
const PORT_GAP = 1.375 * REM;
|
||||||
const PORT_R = 0.375 * REM;
|
const PORT_R = 0.375 * REM;
|
||||||
|
// Ports sit inside the node's padding box, offset from the node border-box origin
|
||||||
|
// by the node borders (3px colored top accent, 1px sides). Anchors add the same
|
||||||
|
// offsets so wires meet the port centers, not their top edges.
|
||||||
|
const BORDER = 1;
|
||||||
|
const BORDER_TOP = 3;
|
||||||
|
|
||||||
interface PaletteEntry { kind: CLNodeKind; label: string; params: Record<string, string>; }
|
interface PaletteEntry { kind: CLNodeKind; label: string; params: Record<string, string>; }
|
||||||
|
|
||||||
@@ -71,6 +77,7 @@ const PALETTE: PaletteEntry[] = [
|
|||||||
{ kind: 'action.delay', label: 'Delay', params: { ms: '500' } },
|
{ kind: 'action.delay', label: 'Delay', params: { ms: '500' } },
|
||||||
{ kind: 'action.log', label: 'Log', params: { expr: '', label: '' } },
|
{ kind: 'action.log', label: 'Log', params: { expr: '', label: '' } },
|
||||||
{ kind: 'action.lua', label: 'Lua script', params: { script: '-- get("ds:name") reads, set("ds:name", v) writes,\n-- log("msg") logs to the server.\n' } },
|
{ kind: 'action.lua', label: 'Lua script', params: { script: '-- get("ds:name") reads, set("ds:name", v) writes,\n-- log("msg") logs to the server.\n' } },
|
||||||
|
{ kind: 'action.dialog', label: 'Dialog', params: { kind: 'info', title: '', message: '', users: '', groups: '', target: '' } },
|
||||||
];
|
];
|
||||||
|
|
||||||
const KIND_LABEL: Record<CLNodeKind, string> = {
|
const KIND_LABEL: Record<CLNodeKind, string> = {
|
||||||
@@ -86,6 +93,7 @@ const KIND_LABEL: Record<CLNodeKind, string> = {
|
|||||||
'action.delay': 'Delay',
|
'action.delay': 'Delay',
|
||||||
'action.log': 'Log',
|
'action.log': 'Log',
|
||||||
'action.lua': 'Lua script',
|
'action.lua': 'Lua script',
|
||||||
|
'action.dialog': 'Dialog',
|
||||||
};
|
};
|
||||||
|
|
||||||
const THRESHOLD_OPS = ['>', '<', '>=', '<=', '==', '!='] as const;
|
const THRESHOLD_OPS = ['>', '<', '>=', '<=', '==', '!='] as const;
|
||||||
@@ -117,11 +125,11 @@ function nodeHeight(kind: CLNodeKind): number { return PORT_TOP + outputs(kind).
|
|||||||
|
|
||||||
function genId(): string { return 'n_' + Math.random().toString(36).slice(2, 9); }
|
function genId(): string { return 'n_' + Math.random().toString(36).slice(2, 9); }
|
||||||
|
|
||||||
function inAnchor(n: CLNode) { return { x: n.x, y: n.y + PORT_TOP }; }
|
function inAnchor(n: CLNode) { return { x: n.x + BORDER, y: n.y + BORDER_TOP + PORT_TOP }; }
|
||||||
function outAnchor(n: CLNode, port: string) {
|
function outAnchor(n: CLNode, port: string) {
|
||||||
const ports = outputs(n.kind);
|
const ports = outputs(n.kind);
|
||||||
const i = Math.max(0, ports.findIndex(p => p.id === port));
|
const i = Math.max(0, ports.findIndex(p => p.id === port));
|
||||||
return { x: n.x + NODE_W, y: n.y + PORT_TOP + i * PORT_GAP };
|
return { x: n.x + NODE_W - BORDER, y: n.y + BORDER_TOP + PORT_TOP + i * PORT_GAP };
|
||||||
}
|
}
|
||||||
function wirePathStr(x1: number, y1: number, x2: number, y2: number): string {
|
function wirePathStr(x1: number, y1: number, x2: number, y2: number): string {
|
||||||
const dx = Math.max(40, Math.abs(x2 - x1) / 2);
|
const dx = Math.max(40, Math.abs(x2 - x1) / 2);
|
||||||
@@ -384,6 +392,15 @@ function FlowEditor({ graph, onChange }: {
|
|||||||
return dsSignals[ds] ?? [];
|
return dsSignals[ds] ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flatten every known ds:name into a single list for the fuzzy SignalPicker,
|
||||||
|
// and a hook to lazily load all data sources' signals when a picker opens.
|
||||||
|
function allSignalOptions(): SignalOption[] {
|
||||||
|
const out: SignalOption[] = [];
|
||||||
|
for (const ds of dsOptions) for (const name of signalOptions(ds)) out.push({ ds, name });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function openAllSignals() { dsOptions.forEach(ds => loadSignals(ds)); }
|
||||||
|
|
||||||
const selected = nodes.find(n => n.id === selectedNode) ?? null;
|
const selected = nodes.find(n => n.id === selectedNode) ?? null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -603,19 +620,13 @@ function FlowEditor({ graph, onChange }: {
|
|||||||
<div class="wizard-section-title">{KIND_LABEL[selected.kind]}</div>
|
<div class="wizard-section-title">{KIND_LABEL[selected.kind]}</div>
|
||||||
|
|
||||||
{(selected.kind === 'trigger.threshold' || selected.kind === 'trigger.change' || selected.kind === 'trigger.alarm') && (() => {
|
{(selected.kind === 'trigger.threshold' || selected.kind === 'trigger.change' || selected.kind === 'trigger.alarm') && (() => {
|
||||||
const { ds, name } = splitRef(selected.params.signal ?? '');
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<div class="wizard-field">
|
|
||||||
<label>Data source</label>
|
|
||||||
<SearchableSelect value={ds} options={dataSources}
|
|
||||||
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { signal: `${newDs}:` }); }} />
|
|
||||||
</div>
|
|
||||||
<div class="wizard-field">
|
<div class="wizard-field">
|
||||||
<label>Signal</label>
|
<label>Signal</label>
|
||||||
<SearchableSelect value={name} options={signalOptions(ds)}
|
<SignalPicker value={selected.params.signal ?? ''} options={allSignalOptions()}
|
||||||
onSelect={(sig) => patchParams(selected.id, { signal: `${ds}:${sig}` })}
|
onOpen={openAllSignals}
|
||||||
placeholder={ds ? 'Search signals…' : 'Select data source first'} />
|
onChange={(ref) => patchParams(selected.id, { signal: ref })} />
|
||||||
</div>
|
</div>
|
||||||
{selected.kind === 'trigger.threshold' && (
|
{selected.kind === 'trigger.threshold' && (
|
||||||
<div class="wizard-field wizard-field-row">
|
<div class="wizard-field wizard-field-row">
|
||||||
@@ -691,7 +702,7 @@ function FlowEditor({ graph, onChange }: {
|
|||||||
<ExprField label="Condition" value={selected.params.cond ?? ''}
|
<ExprField label="Condition" value={selected.params.cond ?? ''}
|
||||||
onChange={(v) => patchParams(selected.id, { cond: v })}
|
onChange={(v) => patchParams(selected.id, { cond: v })}
|
||||||
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
|
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
|
||||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
|
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
|
||||||
hint="True → 'then' port, false → 'else'. e.g. {stub:level} > 5 && temp < 100. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
|
hint="True → 'then' port, false → 'else'. e.g. {stub:level} > 5 && temp < 100. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -715,50 +726,30 @@ function FlowEditor({ graph, onChange }: {
|
|||||||
<ExprField label="While condition" value={selected.params.cond ?? ''}
|
<ExprField label="While condition" value={selected.params.cond ?? ''}
|
||||||
onChange={(v) => patchParams(selected.id, { cond: v })}
|
onChange={(v) => patchParams(selected.id, { cond: v })}
|
||||||
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
|
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
|
||||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
|
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
|
||||||
hint="Loops the 'body' port while true (capped). e.g. {stub:level} < 90" />
|
hint="Loops the 'body' port while true (capped). e.g. {stub:level} < 90" />
|
||||||
)}
|
)}
|
||||||
<p class="hint">Runs the <b>body</b> port repeatedly, then continues on <b>done</b>.</p>
|
<p class="hint">Runs the <b>body</b> port repeatedly, then continues on <b>done</b>.</p>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selected.kind === 'action.write' && (() => {
|
{selected.kind === 'action.write' && (
|
||||||
const { ds, name } = splitRef(selected.params.target ?? '');
|
<Fragment>
|
||||||
return (
|
<div class="wizard-field">
|
||||||
<Fragment>
|
<label>Target signal / variable</label>
|
||||||
<div class="wizard-field">
|
<SignalPicker value={selected.params.target ?? ''} options={allSignalOptions()}
|
||||||
<label>Target data source</label>
|
onOpen={openAllSignals} allowFreeform
|
||||||
<SearchableSelect value={ds} options={dsOptions}
|
onChange={(ref) => patchParams(selected.id, { target: ref })} />
|
||||||
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { target: `${newDs}:` }); }} />
|
<p class="hint">Pick a signal, or type <code>local:name</code> for a graph-local var
|
||||||
</div>
|
or <code>srv:name</code> for a server variable that panels can read.</p>
|
||||||
<div class="wizard-field">
|
</div>
|
||||||
<label>Target {ds === 'local' ? 'variable' : ds === 'srv' ? 'server variable' : 'signal'}</label>
|
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
|
||||||
{(ds === 'local' || ds === 'srv') ? (
|
onChange={(v) => patchParams(selected.id, { expr: v })}
|
||||||
<Fragment>
|
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
|
||||||
<input class="prop-input" list={`clvars-${selected.id}`} value={name}
|
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
|
||||||
placeholder={ds === 'srv' ? 'server variable name (read by panels)' : 'local variable name'}
|
hint="Write to a data source signal, a bare local var, or srv:name for a server variable panels can read. e.g. ({stub:a} - {stub:b}) / {sys:dt}." />
|
||||||
onInput={(e) => patchParams(selected.id, { target: `${ds}:${(e.target as HTMLInputElement).value}` })} />
|
</Fragment>
|
||||||
<datalist id={`clvars-${selected.id}`}>
|
)}
|
||||||
{signalOptions(ds).map(o => <option key={o} value={o} />)}
|
|
||||||
</datalist>
|
|
||||||
<p class="hint">{ds === 'srv'
|
|
||||||
? 'Server variables are visible to interface panels (read-only unless the user has control-logic access).'
|
|
||||||
: 'Local variables live only inside this graph and reset on reload.'}</p>
|
|
||||||
</Fragment>
|
|
||||||
) : (
|
|
||||||
<SearchableSelect value={name} options={signalOptions(ds)}
|
|
||||||
onSelect={(sig) => patchParams(selected.id, { target: `${ds}:${sig}` })}
|
|
||||||
placeholder={ds ? 'Search signals…' : 'Select data source first'} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
|
|
||||||
onChange={(v) => patchParams(selected.id, { expr: v })}
|
|
||||||
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
|
|
||||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
|
|
||||||
hint="Write to a data source signal, a bare local var, or srv:name for a server variable panels can read. e.g. ({stub:a} - {stub:b}) / {sys:dt}." />
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{selected.kind === 'action.delay' && (
|
{selected.kind === 'action.delay' && (
|
||||||
<div class="wizard-field">
|
<div class="wizard-field">
|
||||||
@@ -779,7 +770,7 @@ function FlowEditor({ graph, onChange }: {
|
|||||||
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
|
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
|
||||||
onChange={(v) => patchParams(selected.id, { expr: v })}
|
onChange={(v) => patchParams(selected.id, { expr: v })}
|
||||||
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
|
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
|
||||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
|
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
|
||||||
hint="Logs this value to the server log — handy for debugging a flow." />
|
hint="Logs this value to the server log — handy for debugging a flow." />
|
||||||
</Fragment>
|
</Fragment>
|
||||||
)}
|
)}
|
||||||
@@ -797,6 +788,54 @@ function FlowEditor({ graph, onChange }: {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selected.kind === 'action.dialog' && (
|
||||||
|
<Fragment>
|
||||||
|
<div class="wizard-field">
|
||||||
|
<label>Type</label>
|
||||||
|
<select class="prop-input" value={selected.params.kind ?? 'info'}
|
||||||
|
onChange={(e) => patchParams(selected.id, { kind: (e.target as HTMLSelectElement).value })}>
|
||||||
|
<option value="info">Info</option>
|
||||||
|
<option value="error">Error</option>
|
||||||
|
<option value="input">Input (ask for a value)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="wizard-field">
|
||||||
|
<label>Title</label>
|
||||||
|
<input class="prop-input" value={selected.params.title ?? ''}
|
||||||
|
placeholder="dialog title"
|
||||||
|
onInput={(e) => patchParams(selected.id, { title: (e.target as HTMLInputElement).value })} />
|
||||||
|
</div>
|
||||||
|
<div class="wizard-field">
|
||||||
|
<label>Message</label>
|
||||||
|
<textarea class="prop-input" rows={3} value={selected.params.message ?? ''}
|
||||||
|
placeholder="shown to the user"
|
||||||
|
onInput={(e) => patchParams(selected.id, { message: (e.target as HTMLTextAreaElement).value })} />
|
||||||
|
</div>
|
||||||
|
{selected.params.kind === 'input' && (
|
||||||
|
<div class="wizard-field">
|
||||||
|
<label>Response variable</label>
|
||||||
|
<input class="prop-input" value={selected.params.target ?? ''}
|
||||||
|
placeholder="srv:approved"
|
||||||
|
onInput={(e) => patchParams(selected.id, { target: (e.target as HTMLInputElement).value })} />
|
||||||
|
<p class="hint">The user's number is written here (e.g. <code>srv:approved</code>); read it on a later activation.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div class="wizard-field">
|
||||||
|
<label>Users (optional)</label>
|
||||||
|
<input class="prop-input" value={selected.params.users ?? ''}
|
||||||
|
placeholder="alice, bob"
|
||||||
|
onInput={(e) => patchParams(selected.id, { users: (e.target as HTMLInputElement).value })} />
|
||||||
|
</div>
|
||||||
|
<div class="wizard-field">
|
||||||
|
<label>Groups (optional)</label>
|
||||||
|
<input class="prop-input" value={selected.params.groups ?? ''}
|
||||||
|
placeholder="operators"
|
||||||
|
onInput={(e) => patchParams(selected.id, { groups: (e.target as HTMLInputElement).value })} />
|
||||||
|
<p class="hint">Comma-separated. Leave both empty to show the dialog to every connected user.</p>
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
|
|
||||||
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(selected.id)}>Delete node</button>
|
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(selected.id)}>Delete node</button>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
)}
|
)}
|
||||||
@@ -818,17 +857,15 @@ function FlowEditor({ graph, onChange }: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ExprField({ label, value, onChange, onInsert, dsOptions, signalOptions, loadSignals, hint }: {
|
function ExprField({ label, value, onChange, onInsert, allSignals, onOpenSignals, hint }: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (v: string) => void;
|
onChange: (v: string) => void;
|
||||||
onInsert: (ds: string, sig: string) => void;
|
onInsert: (ds: string, sig: string) => void;
|
||||||
dsOptions: string[];
|
allSignals: SignalOption[];
|
||||||
signalOptions: (ds: string) => string[];
|
onOpenSignals: () => void;
|
||||||
loadSignals: (ds: string) => void;
|
|
||||||
hint: string;
|
hint: string;
|
||||||
}) {
|
}) {
|
||||||
const [insDs, setInsDs] = useState('');
|
|
||||||
const err = checkExpr(value);
|
const err = checkExpr(value);
|
||||||
return (
|
return (
|
||||||
<div class="wizard-field">
|
<div class="wizard-field">
|
||||||
@@ -838,11 +875,9 @@ function ExprField({ label, value, onChange, onInsert, dsOptions, signalOptions,
|
|||||||
onInput={(e) => onChange((e.target as HTMLInputElement).value)} />
|
onInput={(e) => onChange((e.target as HTMLInputElement).value)} />
|
||||||
{err && <p class="wizard-error">{err}</p>}
|
{err && <p class="wizard-error">{err}</p>}
|
||||||
<div class="flow-expr-insert">
|
<div class="flow-expr-insert">
|
||||||
<SearchableSelect value={insDs} options={dsOptions}
|
<SignalPicker value="" options={allSignals} onOpen={onOpenSignals}
|
||||||
onSelect={(ds) => { setInsDs(ds); loadSignals(ds); }} placeholder="ds…" />
|
placeholder="+ insert signal"
|
||||||
<SearchableSelect value="" options={signalOptions(insDs)}
|
onChange={(ref) => { const s = splitRef(ref); onInsert(s.ds, s.name); }} />
|
||||||
onSelect={(sig) => onInsert(insDs, sig)}
|
|
||||||
placeholder={insDs ? '+ insert signal' : 'pick ds first'} />
|
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">{hint}</p>
|
<p class="hint">{hint}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -865,6 +900,7 @@ function nodeSummary(n: CLNode): string {
|
|||||||
case 'action.delay': return `wait ${n.params.ms || '0'} ms`;
|
case 'action.delay': return `wait ${n.params.ms || '0'} ms`;
|
||||||
case 'action.log': return `log ${n.params.label ? n.params.label + ': ' : ''}${n.params.expr || ''}`;
|
case 'action.log': return `log ${n.params.label ? n.params.label + ': ' : ''}${n.params.expr || ''}`;
|
||||||
case 'action.lua': return 'Lua script';
|
case 'action.lua': return 'Lua script';
|
||||||
|
case 'action.dialog': return `${n.params.kind || 'info'} dialog${n.params.title ? ': ' + n.params.title : ''}`;
|
||||||
default: return '';
|
default: return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-61
@@ -1,6 +1,6 @@
|
|||||||
import { h, Fragment } from 'preact';
|
import { h, Fragment } from 'preact';
|
||||||
import { useState, useEffect, useRef } from 'preact/hooks';
|
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||||
import SearchableSelect from './SearchableSelect';
|
import SignalPicker, { SignalOption } from './SignalPicker';
|
||||||
import { checkExpr } from './lib/expr';
|
import { checkExpr } from './lib/expr';
|
||||||
import type { LogicGraph, LogicNode, LogicNodeKind, LogicWire, StateVar, Widget } from './lib/types';
|
import type { LogicGraph, LogicNode, LogicNodeKind, LogicWire, StateVar, Widget } from './lib/types';
|
||||||
|
|
||||||
@@ -37,6 +37,11 @@ const NODE_W = 11.5 * REM; // node width
|
|||||||
const PORT_TOP = 2 * REM; // y of the first port row, relative to node top
|
const PORT_TOP = 2 * REM; // y of the first port row, relative to node top
|
||||||
const PORT_GAP = 1.375 * REM; // vertical spacing between stacked output ports
|
const PORT_GAP = 1.375 * REM; // vertical spacing between stacked output ports
|
||||||
const PORT_R = 0.375 * REM; // port radius (half the 0.75rem dot)
|
const PORT_R = 0.375 * REM; // port radius (half the 0.75rem dot)
|
||||||
|
// Ports sit inside the node's padding box, offset from the node border-box origin
|
||||||
|
// by the node borders (3px colored top accent, 1px sides). Anchors add the same
|
||||||
|
// offsets so wires meet the port centers, not their top edges.
|
||||||
|
const BORDER = 1;
|
||||||
|
const BORDER_TOP = 3;
|
||||||
|
|
||||||
interface PaletteEntry {
|
interface PaletteEntry {
|
||||||
kind: LogicNodeKind;
|
kind: LogicNodeKind;
|
||||||
@@ -114,11 +119,11 @@ function nodeHeight(kind: LogicNodeKind): number {
|
|||||||
function genId(): string { return 'n_' + Math.random().toString(36).slice(2, 9); }
|
function genId(): string { return 'n_' + Math.random().toString(36).slice(2, 9); }
|
||||||
|
|
||||||
// Port anchors in canvas coordinates.
|
// Port anchors in canvas coordinates.
|
||||||
function inAnchor(n: LogicNode) { return { x: n.x, y: n.y + PORT_TOP }; }
|
function inAnchor(n: LogicNode) { return { x: n.x + BORDER, y: n.y + BORDER_TOP + PORT_TOP }; }
|
||||||
function outAnchor(n: LogicNode, port: string) {
|
function outAnchor(n: LogicNode, port: string) {
|
||||||
const ports = outputs(n.kind);
|
const ports = outputs(n.kind);
|
||||||
const i = Math.max(0, ports.findIndex(p => p.id === port));
|
const i = Math.max(0, ports.findIndex(p => p.id === port));
|
||||||
return { x: n.x + NODE_W, y: n.y + PORT_TOP + i * PORT_GAP };
|
return { x: n.x + NODE_W - BORDER, y: n.y + BORDER_TOP + PORT_TOP + i * PORT_GAP };
|
||||||
}
|
}
|
||||||
|
|
||||||
function wirePathStr(x1: number, y1: number, x2: number, y2: number): string {
|
function wirePathStr(x1: number, y1: number, x2: number, y2: number): string {
|
||||||
@@ -180,6 +185,15 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
|||||||
return dsSignals[ds] ?? [];
|
return dsSignals[ds] ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flatten every known ds:name into a single list for the fuzzy SignalPicker,
|
||||||
|
// and a hook to lazily load all data sources' signals when a picker opens.
|
||||||
|
function allSignalOptions(): SignalOption[] {
|
||||||
|
const out: SignalOption[] = [];
|
||||||
|
for (const ds of dsOptions) for (const name of signalOptions(ds)) out.push({ ds, name });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function openAllSignals() { dsOptions.forEach(ds => loadSignals(ds)); }
|
||||||
|
|
||||||
const selected = nodes.find(n => n.id === selectedNode) ?? null;
|
const selected = nodes.find(n => n.id === selectedNode) ?? null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -520,19 +534,13 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{(selected.kind === 'trigger.threshold' || selected.kind === 'trigger.change') && (() => {
|
{(selected.kind === 'trigger.threshold' || selected.kind === 'trigger.change') && (() => {
|
||||||
const { ds, name } = splitRef(selected.params.signal ?? '');
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<div class="wizard-field">
|
|
||||||
<label>Data source</label>
|
|
||||||
<SearchableSelect value={ds} options={dsOptions}
|
|
||||||
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { signal: `${newDs}:` }); }} />
|
|
||||||
</div>
|
|
||||||
<div class="wizard-field">
|
<div class="wizard-field">
|
||||||
<label>Signal</label>
|
<label>Signal</label>
|
||||||
<SearchableSelect value={name} options={signalOptions(ds)}
|
<SignalPicker value={selected.params.signal ?? ''} options={allSignalOptions()}
|
||||||
onSelect={(sig) => patchParams(selected.id, { signal: `${ds}:${sig}` })}
|
onOpen={openAllSignals}
|
||||||
placeholder={ds ? 'Search signals…' : 'Select data source first'} />
|
onChange={(ref) => patchParams(selected.id, { signal: ref })} />
|
||||||
</div>
|
</div>
|
||||||
{selected.kind === 'trigger.threshold' && (
|
{selected.kind === 'trigger.threshold' && (
|
||||||
<div class="wizard-field wizard-field-row">
|
<div class="wizard-field wizard-field-row">
|
||||||
@@ -573,7 +581,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
|||||||
<ExprField label="Condition" value={selected.params.cond ?? ''}
|
<ExprField label="Condition" value={selected.params.cond ?? ''}
|
||||||
onChange={(v) => patchParams(selected.id, { cond: v })}
|
onChange={(v) => patchParams(selected.id, { cond: v })}
|
||||||
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
|
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
|
||||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
|
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
|
||||||
hint="True → 'then' port, false → 'else' port. e.g. {stub:level} > 5 && temp < 100. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
|
hint="True → 'then' port, false → 'else' port. e.g. {stub:level} > 5 && temp < 100. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -597,36 +605,29 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
|||||||
<ExprField label="While condition" value={selected.params.cond ?? ''}
|
<ExprField label="While condition" value={selected.params.cond ?? ''}
|
||||||
onChange={(v) => patchParams(selected.id, { cond: v })}
|
onChange={(v) => patchParams(selected.id, { cond: v })}
|
||||||
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
|
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
|
||||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
|
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
|
||||||
hint="Loops the 'body' port while true (capped). e.g. {stub:level} < 90" />
|
hint="Loops the 'body' port while true (capped). e.g. {stub:level} < 90" />
|
||||||
)}
|
)}
|
||||||
<p class="hint">Runs the <b>body</b> port repeatedly, then continues on <b>done</b>.</p>
|
<p class="hint">Runs the <b>body</b> port repeatedly, then continues on <b>done</b>.</p>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selected.kind === 'action.write' && (() => {
|
{selected.kind === 'action.write' && (
|
||||||
const { ds, name } = splitRef(selected.params.target ?? '');
|
<Fragment>
|
||||||
return (
|
<div class="wizard-field">
|
||||||
<Fragment>
|
<label>Target signal / variable</label>
|
||||||
<div class="wizard-field">
|
<SignalPicker value={selected.params.target ?? ''} options={allSignalOptions()}
|
||||||
<label>Target data source</label>
|
onOpen={openAllSignals} allowFreeform
|
||||||
<SearchableSelect value={ds} options={dsOptions}
|
onChange={(ref) => patchParams(selected.id, { target: ref })} />
|
||||||
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { target: `${newDs}:` }); }} />
|
<p class="hint">Pick a signal, or type <code>local:name</code> to write a panel-local variable.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="wizard-field">
|
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
|
||||||
<label>Target signal</label>
|
onChange={(v) => patchParams(selected.id, { expr: v })}
|
||||||
<SearchableSelect value={name} options={signalOptions(ds)}
|
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
|
||||||
onSelect={(sig) => patchParams(selected.id, { target: `${ds}:${sig}` })}
|
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
|
||||||
placeholder={ds ? 'Search signals…' : 'Select data source first'} />
|
hint="e.g. ({stub:a} - {stub:b}) / {sys:dt} for a rate — or a constant like 42. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
|
||||||
</div>
|
</Fragment>
|
||||||
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
|
)}
|
||||||
onChange={(v) => patchParams(selected.id, { expr: v })}
|
|
||||||
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
|
|
||||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
|
|
||||||
hint="e.g. ({stub:a} - {stub:b}) / {sys:dt} for a rate — or a constant like 42. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{selected.kind === 'action.delay' && (
|
{selected.kind === 'action.delay' && (
|
||||||
<div class="wizard-field">
|
<div class="wizard-field">
|
||||||
@@ -647,7 +648,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
|||||||
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
|
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
|
||||||
onChange={(v) => patchParams(selected.id, { expr: v })}
|
onChange={(v) => patchParams(selected.id, { expr: v })}
|
||||||
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
|
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
|
||||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
|
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
|
||||||
hint="Appends this value (with a timestamp) to the array. e.g. {stub:level}, or {sys:time} for the clock." />
|
hint="Appends this value (with a timestamp) to the array. e.g. {stub:level}, or {sys:time} for the clock." />
|
||||||
</Fragment>
|
</Fragment>
|
||||||
)}
|
)}
|
||||||
@@ -683,7 +684,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
|||||||
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
|
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
|
||||||
onChange={(v) => patchParams(selected.id, { expr: v })}
|
onChange={(v) => patchParams(selected.id, { expr: v })}
|
||||||
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
|
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
|
||||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
|
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
|
||||||
hint="Logs this value to the browser console — handy for debugging a flow." />
|
hint="Logs this value to the browser console — handy for debugging a flow." />
|
||||||
</Fragment>
|
</Fragment>
|
||||||
)}
|
)}
|
||||||
@@ -747,7 +748,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
|||||||
<DialogFieldsEditor allowInput={false}
|
<DialogFieldsEditor allowInput={false}
|
||||||
fields={parseDialogFields(selected)}
|
fields={parseDialogFields(selected)}
|
||||||
onChange={(f) => patchParams(selected.id, { fields: JSON.stringify(f) })}
|
onChange={(f) => patchParams(selected.id, { fields: JSON.stringify(f) })}
|
||||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} />
|
allSignals={allSignalOptions()} onOpenSignals={openAllSignals} />
|
||||||
</Fragment>
|
</Fragment>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -768,7 +769,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
|||||||
<DialogFieldsEditor allowInput={true}
|
<DialogFieldsEditor allowInput={true}
|
||||||
fields={parseDialogFields(selected)}
|
fields={parseDialogFields(selected)}
|
||||||
onChange={(f) => patchParams(selected.id, { fields: JSON.stringify(f) })}
|
onChange={(f) => patchParams(selected.id, { fields: JSON.stringify(f) })}
|
||||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} />
|
allSignals={allSignalOptions()} onOpenSignals={openAllSignals} />
|
||||||
<p class="hint">Each <b>input</b> prompts the operator and writes the entered number to its
|
<p class="hint">Each <b>input</b> prompts the operator and writes the entered number to its
|
||||||
target on OK; each <b>display</b> shows a live value. Cancel aborts the flow.</p>
|
target on OK; each <b>display</b> shows a live value. Cancel aborts the flow.</p>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
@@ -796,17 +797,15 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Expression input with a signal-insert helper and live validation.
|
// Expression input with a signal-insert helper and live validation.
|
||||||
function ExprField({ label, value, onChange, onInsert, dsOptions, signalOptions, loadSignals, hint }: {
|
function ExprField({ label, value, onChange, onInsert, allSignals, onOpenSignals, hint }: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (v: string) => void;
|
onChange: (v: string) => void;
|
||||||
onInsert: (ds: string, sig: string) => void;
|
onInsert: (ds: string, sig: string) => void;
|
||||||
dsOptions: string[];
|
allSignals: SignalOption[];
|
||||||
signalOptions: (ds: string) => string[];
|
onOpenSignals: () => void;
|
||||||
loadSignals: (ds: string) => void;
|
|
||||||
hint: string;
|
hint: string;
|
||||||
}) {
|
}) {
|
||||||
const [insDs, setInsDs] = useState('');
|
|
||||||
const err = checkExpr(value);
|
const err = checkExpr(value);
|
||||||
return (
|
return (
|
||||||
<div class="wizard-field">
|
<div class="wizard-field">
|
||||||
@@ -816,11 +815,9 @@ function ExprField({ label, value, onChange, onInsert, dsOptions, signalOptions,
|
|||||||
onInput={(e) => onChange((e.target as HTMLInputElement).value)} />
|
onInput={(e) => onChange((e.target as HTMLInputElement).value)} />
|
||||||
{err && <p class="wizard-error">{err}</p>}
|
{err && <p class="wizard-error">{err}</p>}
|
||||||
<div class="flow-expr-insert">
|
<div class="flow-expr-insert">
|
||||||
<SearchableSelect value={insDs} options={dsOptions}
|
<SignalPicker value="" options={allSignals} onOpen={onOpenSignals}
|
||||||
onSelect={(ds) => { setInsDs(ds); loadSignals(ds); }} placeholder="ds…" />
|
placeholder="+ insert signal"
|
||||||
<SearchableSelect value="" options={signalOptions(insDs)}
|
onChange={(ref) => { const s = splitRef(ref); onInsert(s.ds, s.name); }} />
|
||||||
onSelect={(sig) => onInsert(insDs, sig)}
|
|
||||||
placeholder={insDs ? '+ insert signal' : 'pick ds first'} />
|
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">{hint}</p>
|
<p class="hint">{hint}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -934,13 +931,12 @@ function parseDialogFields(n: LogicNode): DialogFieldSpec[] {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogFieldsEditor({ allowInput, fields, onChange, dsOptions, signalOptions, loadSignals }: {
|
function DialogFieldsEditor({ allowInput, fields, onChange, allSignals, onOpenSignals }: {
|
||||||
allowInput: boolean;
|
allowInput: boolean;
|
||||||
fields: DialogFieldSpec[];
|
fields: DialogFieldSpec[];
|
||||||
onChange: (fields: DialogFieldSpec[]) => void;
|
onChange: (fields: DialogFieldSpec[]) => void;
|
||||||
dsOptions: string[];
|
allSignals: SignalOption[];
|
||||||
signalOptions: (ds: string) => string[];
|
onOpenSignals: () => void;
|
||||||
loadSignals: (ds: string) => void;
|
|
||||||
}) {
|
}) {
|
||||||
function patch(i: number, patch: Partial<DialogFieldSpec>) {
|
function patch(i: number, patch: Partial<DialogFieldSpec>) {
|
||||||
onChange(fields.map((f, j) => (j === i ? { ...f, ...patch } : f)));
|
onChange(fields.map((f, j) => (j === i ? { ...f, ...patch } : f)));
|
||||||
@@ -953,7 +949,6 @@ function DialogFieldsEditor({ allowInput, fields, onChange, dsOptions, signalOpt
|
|||||||
<label>{allowInput ? 'Fields' : 'Displayed values'}</label>
|
<label>{allowInput ? 'Fields' : 'Displayed values'}</label>
|
||||||
{fields.length === 0 && <p class="hint">None yet.</p>}
|
{fields.length === 0 && <p class="hint">None yet.</p>}
|
||||||
{fields.map((f, i) => {
|
{fields.map((f, i) => {
|
||||||
const { ds, name } = splitRef(f.target ?? '');
|
|
||||||
return (
|
return (
|
||||||
<div key={i} class="flow-field-edit">
|
<div key={i} class="flow-field-edit">
|
||||||
<div class="flow-field-head">
|
<div class="flow-field-head">
|
||||||
@@ -972,11 +967,9 @@ function DialogFieldsEditor({ allowInput, fields, onChange, dsOptions, signalOpt
|
|||||||
{f.type === 'input' ? (
|
{f.type === 'input' ? (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<div class="flow-row-edit">
|
<div class="flow-row-edit">
|
||||||
<SearchableSelect value={ds} options={dsOptions}
|
<SignalPicker value={f.target ?? ''} options={allSignals}
|
||||||
onSelect={(newDs) => { loadSignals(newDs); patch(i, { target: `${newDs}:` }); }} placeholder="ds…" />
|
onOpen={onOpenSignals} allowFreeform placeholder="target signal…"
|
||||||
<SearchableSelect value={name} options={signalOptions(ds)}
|
onChange={(ref) => patch(i, { target: ref })} />
|
||||||
onSelect={(sig) => patch(i, { target: `${ds}:${sig}` })}
|
|
||||||
placeholder={ds ? 'signal…' : 'pick ds'} />
|
|
||||||
</div>
|
</div>
|
||||||
<input class={`prop-input${checkExpr(f.default ?? '') ? ' prop-input-error' : ''}`}
|
<input class={`prop-input${checkExpr(f.default ?? '') ? ' prop-input-error' : ''}`}
|
||||||
value={f.default ?? ''} placeholder="default value (expression, optional)"
|
value={f.default ?? ''} placeholder="default value (expression, optional)"
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useState, useMemo } from 'preact/hooks';
|
||||||
|
|
||||||
|
// A single fuzzy-finder input for picking a signal as one "ds:name" reference,
|
||||||
|
// replacing the old stacked "Data source" + "Signal" select pair. The caller
|
||||||
|
// supplies a flattened option list (every ds:name it knows about) and, via
|
||||||
|
// onOpen, a hook to lazily load signals for all data sources when the dropdown
|
||||||
|
// is first opened. When allowFreeform is set, typing a value that matches no
|
||||||
|
// option (e.g. a new srv:/local: variable) can still be committed.
|
||||||
|
|
||||||
|
export interface SignalOption { ds: string; name: string; }
|
||||||
|
|
||||||
|
export default function SignalPicker({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
onOpen,
|
||||||
|
placeholder = 'Search signals…',
|
||||||
|
allowFreeform = false,
|
||||||
|
}: {
|
||||||
|
value: string; // current "ds:name" (or "")
|
||||||
|
options: SignalOption[];
|
||||||
|
onChange: (ref: string) => void;
|
||||||
|
onOpen?: () => void;
|
||||||
|
placeholder?: string;
|
||||||
|
allowFreeform?: boolean;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const tokens = filter.toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
|
const scored = options
|
||||||
|
.map(o => ({ o, label: `${o.ds}:${o.name}` }))
|
||||||
|
.filter(({ label }) => {
|
||||||
|
const l = label.toLowerCase();
|
||||||
|
return tokens.every(t => l.includes(t));
|
||||||
|
});
|
||||||
|
// Prefer matches where the signal name (not the ds prefix) leads.
|
||||||
|
const f = filter.toLowerCase();
|
||||||
|
scored.sort((a, b) => {
|
||||||
|
const an = a.o.name.toLowerCase().startsWith(f) ? 0 : 1;
|
||||||
|
const bn = b.o.name.toLowerCase().startsWith(f) ? 0 : 1;
|
||||||
|
if (an !== bn) return an - bn;
|
||||||
|
return a.label.localeCompare(b.label);
|
||||||
|
});
|
||||||
|
return scored.slice(0, 200);
|
||||||
|
}, [options, filter]);
|
||||||
|
|
||||||
|
function openDropdown() {
|
||||||
|
setOpen(true);
|
||||||
|
setFilter('');
|
||||||
|
onOpen?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function commit(ref: string) {
|
||||||
|
onChange(ref);
|
||||||
|
setOpen(false);
|
||||||
|
setFilter('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// A freeform entry is offered when the typed text looks like a usable
|
||||||
|
// reference and is not already an exact option.
|
||||||
|
const freeform = allowFreeform && filter.trim() !== ''
|
||||||
|
&& !filtered.some(({ label }) => label === filter.trim());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="search-select">
|
||||||
|
<div class="search-select-trigger" onClick={() => (open ? setOpen(false) : openDropdown())}>
|
||||||
|
{value || <span class="search-select-placeholder">{placeholder}</span>}
|
||||||
|
<span class="search-select-arrow">{open ? '▴' : '▾'}</span>
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<div class="search-select-dropdown">
|
||||||
|
<input
|
||||||
|
class="prop-input"
|
||||||
|
autoFocus
|
||||||
|
placeholder="Type to search… (ds:name)"
|
||||||
|
value={filter}
|
||||||
|
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (filtered.length > 0) commit(`${filtered[0].o.ds}:${filtered[0].o.name}`);
|
||||||
|
else if (freeform) commit(filter.trim());
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); setOpen(false); }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div class="search-select-list">
|
||||||
|
{freeform && (
|
||||||
|
<div class="search-select-item" onClick={() => commit(filter.trim())}>
|
||||||
|
Use “{filter.trim()}”
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filtered.length === 0 && !freeform && <div class="search-select-item hint">No matches</div>}
|
||||||
|
{filtered.map(({ o, label }) => (
|
||||||
|
<div
|
||||||
|
key={label}
|
||||||
|
class={`search-select-item${label === value ? ' search-select-item-selected' : ''}`}
|
||||||
|
onClick={() => commit(`${o.ds}:${o.name}`)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{open && <div class="search-select-backdrop" onClick={() => setOpen(false)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+13
-18
@@ -4,6 +4,7 @@ import type { SignalRef, StateVar } from './lib/types';
|
|||||||
import SyntheticGraphEditor from './SyntheticGraphEditor';
|
import SyntheticGraphEditor from './SyntheticGraphEditor';
|
||||||
import GroupsTree from './GroupsTree';
|
import GroupsTree from './GroupsTree';
|
||||||
import ChannelFinderModal from './ChannelFinderModal';
|
import ChannelFinderModal from './ChannelFinderModal';
|
||||||
|
import SignalPicker from './SignalPicker';
|
||||||
|
|
||||||
type TreeTab = 'sources' | 'groups';
|
type TreeTab = 'sources' | 'groups';
|
||||||
type GroupBy = 'datasource' | 'area' | 'system' | 'ioc';
|
type GroupBy = 'datasource' | 'area' | 'system' | 'ioc';
|
||||||
@@ -523,24 +524,18 @@ export default function SignalTree({ onDragStart, width, panelId, statevars, onS
|
|||||||
</div>
|
</div>
|
||||||
<div class="wizard-body">
|
<div class="wizard-body">
|
||||||
<div class="wizard-field">
|
<div class="wizard-field">
|
||||||
<label>Data Source</label>
|
<label>Signal</label>
|
||||||
<select class="prop-select" value={manualDs} onChange={e => setManualDs((e.target as HTMLSelectElement).value)}>
|
<SignalPicker
|
||||||
{Array.from(new Set(allSignals.map(s => s.ds))).map(ds => (
|
value={manualName ? `${manualDs}:${manualName}` : ''}
|
||||||
<option key={ds} value={ds}>{ds}</option>
|
options={allSignals}
|
||||||
))}
|
allowFreeform
|
||||||
{!allSignals.some(s => s.ds === 'epics') && <option value="epics">epics</option>}
|
placeholder="Search or type ds:NAME…"
|
||||||
</select>
|
onChange={(ref) => {
|
||||||
</div>
|
const i = ref.indexOf(':');
|
||||||
<div class="wizard-field">
|
if (i < 0) { setManualDs(ref); setManualName(''); }
|
||||||
<label>Signal / PV Name</label>
|
else { setManualDs(ref.slice(0, i)); setManualName(ref.slice(i + 1)); }
|
||||||
<input
|
}} />
|
||||||
class="prop-input"
|
<p class="hint">Pick an existing signal, or type <code>epics:MY:PV:NAME</code> to add a new one.</p>
|
||||||
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>
|
</div>
|
||||||
<div class="wizard-footer">
|
<div class="wizard-footer">
|
||||||
|
|||||||
+319
-119
@@ -1,8 +1,8 @@
|
|||||||
import { h, Fragment } from 'preact';
|
import { h, Fragment } from 'preact';
|
||||||
import { useState, useEffect, useRef } from 'preact/hooks';
|
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||||
import SearchableSelect from './SearchableSelect';
|
import SignalPicker, { SignalOption } from './SignalPicker';
|
||||||
import LuaEditor from './LuaEditor';
|
import LuaEditor from './LuaEditor';
|
||||||
import type { InputRef, PipelineNode, SignalDef } from './lib/types';
|
import type { SignalDef, SynthGraph, SynthGraphNode } from './lib/types';
|
||||||
|
|
||||||
interface DataSource { name: string; }
|
interface DataSource { name: string; }
|
||||||
interface SignalInfo { name: string; }
|
interface SignalInfo { name: string; }
|
||||||
@@ -19,41 +19,51 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Node-type catalogue ──────────────────────────────────────────────────────
|
// ── Node-type catalogue ──────────────────────────────────────────────────────
|
||||||
// Mirrors the backend dsp node set (internal/dsp/nodes.go). The combine nodes
|
// Mirrors the backend dsp node set (internal/dsp/nodes.go). `arity` describes how
|
||||||
// (add/subtract/…) and expr take several inputs, which only the FIRST node of a
|
// many input ports an op exposes:
|
||||||
// pipeline receives — see the compile() note below.
|
// fixed n — exactly n inputs (gain=1, subtract/divide=2, …)
|
||||||
|
// min n — at least n inputs; one spare port appears past the wired count so the
|
||||||
|
// user can keep adding (add/multiply)
|
||||||
|
// named — the user names each input (expr/lua); ports follow params.vars
|
||||||
|
type InArity = { kind: 'fixed'; n: number } | { kind: 'min'; n: number } | { kind: 'named' };
|
||||||
interface NodeParam { label: string; key: string; type: 'number' | 'text' | 'lua'; default: string; }
|
interface NodeParam { label: string; key: string; type: 'number' | 'text' | 'lua'; default: string; }
|
||||||
interface OpDef { type: string; label: string; params: NodeParam[]; }
|
interface OpDef { type: string; label: string; arity: InArity; params: NodeParam[]; }
|
||||||
|
|
||||||
const OPS: OpDef[] = [
|
const OPS: OpDef[] = [
|
||||||
{ type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] },
|
{ type: 'gain', label: 'Gain', arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] },
|
||||||
{ type: 'offset', label: 'Offset', params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] },
|
{ type: 'offset', label: 'Offset', arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] },
|
||||||
{ type: 'add', label: 'Add (Σ inputs)', params: [] },
|
{ type: 'add', label: 'Add (Σ inputs)', arity: { kind: 'min', n: 2 }, params: [] },
|
||||||
{ type: 'subtract', label: 'Subtract (a−b)', params: [] },
|
{ type: 'subtract', label: 'Subtract (a−b)', arity: { kind: 'fixed', n: 2 }, params: [] },
|
||||||
{ type: 'multiply', label: 'Multiply (Π)', params: [] },
|
{ type: 'multiply', label: 'Multiply (Π)', arity: { kind: 'min', n: 2 }, params: [] },
|
||||||
{ type: 'divide', label: 'Divide (a÷b)', params: [] },
|
{ type: 'divide', label: 'Divide (a÷b)', arity: { kind: 'fixed', n: 2 }, params: [] },
|
||||||
{ type: 'moving_average', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
|
{ type: 'moving_average', label: 'Moving Average', arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
|
||||||
{ type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
|
{ type: 'rms', label: 'RMS', arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
|
||||||
{ type: 'lowpass', label: 'Low-pass', params: [
|
{ type: 'lowpass', label: 'Low-pass', arity: { kind: 'fixed', n: 1 }, params: [
|
||||||
{ label: 'Cutoff freq (Hz)', key: 'freq', type: 'number', default: '1' },
|
{ label: 'Cutoff freq (Hz)', key: 'freq', type: 'number', default: '1' },
|
||||||
{ label: 'Order (1–8)', key: 'order', type: 'number', default: '1' },
|
{ label: 'Order (1–8)', key: 'order', type: 'number', default: '1' },
|
||||||
]},
|
]},
|
||||||
{ type: 'derivative', label: 'Derivative', params: [] },
|
{ type: 'derivative', label: 'Derivative', arity: { kind: 'fixed', n: 1 }, params: [] },
|
||||||
{ type: 'integrate', label: 'Integrate', params: [] },
|
{ type: 'integrate', label: 'Integrate', arity: { kind: 'fixed', n: 1 }, 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', arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
|
||||||
{ type: 'threshold', label: 'Threshold', params: [
|
{ type: 'threshold', label: 'Threshold', arity: { kind: 'fixed', n: 1 }, params: [
|
||||||
{ label: 'Threshold', key: 'threshold', type: 'number', default: '0' },
|
{ label: 'Threshold', key: 'threshold', type: 'number', default: '0' },
|
||||||
{ label: 'High output', key: 'high', type: 'number', default: '1' },
|
{ label: 'High output', key: 'high', type: 'number', default: '1' },
|
||||||
{ label: 'Low output', key: 'low', type: 'number', default: '0' },
|
{ label: 'Low output', key: 'low', type: 'number', default: '0' },
|
||||||
]},
|
]},
|
||||||
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a,b,c,d = inputs)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
|
{ type: 'expr', label: 'Formula', arity: { kind: 'named' }, params: [{ label: 'Expression', key: 'expr', type: 'text', default: 'a + b' }] },
|
||||||
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a,b,c,d = inputs)', key: 'script', type: 'lua', default: 'return a' }] },
|
{ type: 'lua', label: 'Lua Script', arity: { kind: 'named' }, params: [{ label: 'Script', key: 'script', type: 'lua', default: 'return a + b' }] },
|
||||||
];
|
];
|
||||||
const OP_BY_TYPE = new Map(OPS.map(o => [o.type, o]));
|
const OP_BY_TYPE = new Map(OPS.map(o => [o.type, o]));
|
||||||
function opLabel(type: string): string { return OP_BY_TYPE.get(type)?.label ?? type; }
|
function opLabel(type: string): string { return OP_BY_TYPE.get(type)?.label ?? type; }
|
||||||
function opParamDefs(type: string): NodeParam[] { return OP_BY_TYPE.get(type)?.params ?? []; }
|
function opParamDefs(type: string): NodeParam[] { return OP_BY_TYPE.get(type)?.params ?? []; }
|
||||||
|
function opArity(type?: string): InArity { return OP_BY_TYPE.get(type ?? '')?.arity ?? { kind: 'fixed', n: 1 }; }
|
||||||
|
|
||||||
// ── Graph model (UI only — compiled to SignalDef on save) ───────────────────
|
// Default input names a,b,c,… for named (expr/lua) nodes.
|
||||||
|
function letters(n: number): string[] {
|
||||||
|
return Array.from({ length: Math.max(1, n) }, (_, i) => String.fromCharCode(97 + i));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Graph model (UI only — compiled to SynthGraph on save) ──────────────────
|
||||||
type NodeKind = 'source' | 'op' | 'output';
|
type NodeKind = 'source' | 'op' | 'output';
|
||||||
interface GNode {
|
interface GNode {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -63,9 +73,10 @@ interface GNode {
|
|||||||
ds?: string; // source
|
ds?: string; // source
|
||||||
signal?: string; // source
|
signal?: string; // source
|
||||||
op?: string; // op type
|
op?: string; // op type
|
||||||
params?: Record<string, any>; // op
|
params?: Record<string, any>; // op (named ops carry params.vars: string[])
|
||||||
}
|
}
|
||||||
interface GWire { from: string; to: string; }
|
// A wire terminates on a specific input port (index) of the target node.
|
||||||
|
interface GWire { from: string; to: string; toPort: number; }
|
||||||
interface Graph { nodes: GNode[]; wires: GWire[]; }
|
interface Graph { nodes: GNode[]; wires: GWire[]; }
|
||||||
|
|
||||||
// ── Geometry (rem-based, like the logic editor) ─────────────────────────────
|
// ── Geometry (rem-based, like the logic editor) ─────────────────────────────
|
||||||
@@ -74,23 +85,75 @@ const REM = (() => {
|
|||||||
return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
||||||
})();
|
})();
|
||||||
const NODE_W = 10 * REM;
|
const NODE_W = 10 * REM;
|
||||||
const PORT_TOP = 1.4 * REM;
|
const PORT_TOP = 1.7 * REM; // y of the first input/output port row
|
||||||
|
const PORT_GAP = 1.25 * REM; // vertical spacing between stacked input ports
|
||||||
const PORT_R = 0.375 * REM;
|
const PORT_R = 0.375 * REM;
|
||||||
|
// Ports are absolutely positioned inside the node's padding box, which is offset
|
||||||
|
// from the node's border-box origin (node.x/node.y) by the node borders: a 3px
|
||||||
|
// colored accent on top and 1px on the sides. Wire anchors must add the same
|
||||||
|
// offsets or they land at the top edge of the port circle instead of its center.
|
||||||
|
const BORDER = 1;
|
||||||
|
const BORDER_TOP = 3;
|
||||||
|
|
||||||
function genId(): string { return 'g_' + Math.random().toString(36).slice(2, 9); }
|
function genId(): string { return 'g_' + Math.random().toString(36).slice(2, 9); }
|
||||||
function hasInput(k: NodeKind): boolean { return k !== 'source'; }
|
|
||||||
function hasOutput(k: NodeKind): boolean { return k !== 'output'; }
|
function hasOutput(k: NodeKind): boolean { return k !== 'output'; }
|
||||||
function inAnchor(n: GNode) { return { x: n.x, y: n.y + PORT_TOP }; }
|
|
||||||
function outAnchor(n: GNode) { return { x: n.x + NODE_W, y: n.y + PORT_TOP }; }
|
// Number of input ports a node currently shows.
|
||||||
|
function inPortCount(n: GNode, wires: GWire[]): number {
|
||||||
|
if (n.kind === 'source') return 0;
|
||||||
|
if (n.kind === 'output') return 1;
|
||||||
|
const ar = opArity(n.op);
|
||||||
|
if (ar.kind === 'fixed') return ar.n;
|
||||||
|
if (ar.kind === 'named') return Math.max(1, (n.params?.vars as string[] | undefined)?.length ?? 0);
|
||||||
|
// min: expose one spare port beyond the highest wired index so the user can add more.
|
||||||
|
const wired = wires.filter(w => w.to === n.id).length;
|
||||||
|
return Math.max(ar.n, wired + 1);
|
||||||
|
}
|
||||||
|
function inAnchor(n: GNode, port: number) {
|
||||||
|
return { x: n.x + BORDER, y: n.y + BORDER_TOP + PORT_TOP + port * PORT_GAP };
|
||||||
|
}
|
||||||
|
function outAnchor(n: GNode) { return { x: n.x + NODE_W - BORDER, y: n.y + BORDER_TOP + PORT_TOP }; }
|
||||||
|
function nodeMinHeight(n: GNode, wires: GWire[]): number {
|
||||||
|
const nIn = Math.max(1, inPortCount(n, wires));
|
||||||
|
return BORDER_TOP + PORT_TOP + nIn * PORT_GAP;
|
||||||
|
}
|
||||||
function wirePathStr(x1: number, y1: number, x2: number, y2: number): string {
|
function wirePathStr(x1: number, y1: number, x2: number, y2: number): string {
|
||||||
const dx = Math.max(40, Math.abs(x2 - x1) / 2);
|
const dx = Math.max(40, Math.abs(x2 - x1) / 2);
|
||||||
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
|
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build an initial graph by laying out an existing SignalDef: sources stacked on
|
// Label shown beside an input port (named ops show their var name).
|
||||||
// the left, the linear pipeline as a row of op nodes, output on the right.
|
function portLabel(n: GNode, port: number): string {
|
||||||
|
if (opArity(n.op).kind === 'named') {
|
||||||
|
const vars = (n.params?.vars as string[] | undefined) ?? [];
|
||||||
|
return vars[port] ?? '';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build an initial graph from an existing SignalDef. Prefer the stored DAG; fall
|
||||||
|
// back to laying out the legacy linear Inputs+Pipeline form.
|
||||||
function buildInitial(def: SignalDef): Graph {
|
function buildInitial(def: SignalDef): Graph {
|
||||||
const inputs: InputRef[] = def.inputs && def.inputs.length > 0
|
if (def.graph && def.graph.nodes.length > 0) {
|
||||||
|
const nodes: GNode[] = def.graph.nodes.map((n, i) => ({
|
||||||
|
id: n.id,
|
||||||
|
kind: n.kind,
|
||||||
|
x: n.x ?? (2 + (i % 3) * 12) * REM,
|
||||||
|
y: n.y ?? (2 + Math.floor(i / 3) * 5) * REM,
|
||||||
|
ds: n.ds,
|
||||||
|
signal: n.signal,
|
||||||
|
op: n.op,
|
||||||
|
params: n.params ? { ...n.params } : undefined,
|
||||||
|
}));
|
||||||
|
const wires: GWire[] = [];
|
||||||
|
for (const n of def.graph.nodes) {
|
||||||
|
(n.inputs ?? []).forEach((from, port) => wires.push({ from, to: n.id, toPort: port }));
|
||||||
|
}
|
||||||
|
return { nodes, wires };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy linear layout: sources stacked left, pipeline a row of op nodes, output right.
|
||||||
|
const inputs = def.inputs && def.inputs.length > 0
|
||||||
? def.inputs
|
? def.inputs
|
||||||
: (def.ds && def.signal ? [{ ds: def.ds, signal: def.signal }] : []);
|
: (def.ds && def.signal ? [{ ds: def.ds, signal: def.signal }] : []);
|
||||||
const pipeline = def.pipeline ?? [];
|
const pipeline = def.pipeline ?? [];
|
||||||
@@ -104,57 +167,108 @@ function buildInitial(def: SignalDef): Graph {
|
|||||||
});
|
});
|
||||||
const opIds = pipeline.map((nd, i) => {
|
const opIds = pipeline.map((nd, i) => {
|
||||||
const id = genId();
|
const id = genId();
|
||||||
nodes.push({ id, kind: 'op', x: (15 + i * 12) * REM, y: 3 * REM, op: nd.type, params: { ...nd.params } });
|
const params: Record<string, any> = { ...nd.params };
|
||||||
|
// The head op received every source as a,b,c,…; later ops a single input.
|
||||||
|
if (opArity(nd.type).kind === 'named') params.vars = letters(i === 0 ? srcIds.length : 1);
|
||||||
|
nodes.push({ id, kind: 'op', x: (15 + i * 12) * REM, y: 3 * REM, op: nd.type, params });
|
||||||
return id;
|
return id;
|
||||||
});
|
});
|
||||||
const outId = genId();
|
const outId = genId();
|
||||||
nodes.push({ id: outId, kind: 'output', x: (15 + pipeline.length * 12) * REM, y: 3 * REM });
|
nodes.push({ id: outId, kind: 'output', x: (15 + pipeline.length * 12) * REM, y: 3 * REM });
|
||||||
|
|
||||||
const headId = opIds[0] ?? outId;
|
const headId = opIds[0] ?? outId;
|
||||||
srcIds.forEach(s => wires.push({ from: s, to: headId }));
|
srcIds.forEach((s, i) => wires.push({ from: s, to: headId, toPort: i }));
|
||||||
for (let i = 0; i < opIds.length - 1; i++) wires.push({ from: opIds[i], to: opIds[i + 1] });
|
for (let i = 0; i < opIds.length - 1; i++) wires.push({ from: opIds[i], to: opIds[i + 1], toPort: 0 });
|
||||||
if (opIds.length > 0) wires.push({ from: opIds[opIds.length - 1], to: outId });
|
if (opIds.length > 0) wires.push({ from: opIds[opIds.length - 1], to: outId, toPort: 0 });
|
||||||
|
|
||||||
return { nodes, wires };
|
return { nodes, wires };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compile the visual graph back into the backend's linear form. The pipeline is
|
// Compile the visual graph into the backend DAG form. Each op/output node's
|
||||||
// a single chain ending at the output; the head node receives every source
|
// inputs are its wires ordered by target port index.
|
||||||
// signal (inputs[0]=a, inputs[1]=b, …), every later node the previous output.
|
function compile(g: Graph): SynthGraph {
|
||||||
function compile(g: Graph): { inputs?: InputRef[]; pipeline?: PipelineNode[]; error?: string } {
|
|
||||||
const output = g.nodes.find(n => n.kind === 'output');
|
const output = g.nodes.find(n => n.kind === 'output');
|
||||||
if (!output) return { error: 'missing output node' };
|
const nodes: SynthGraphNode[] = g.nodes.map(n => {
|
||||||
const byId = new Map(g.nodes.map(n => [n.id, n]));
|
const inputs = g.wires
|
||||||
const upstream = (id: string) => g.wires.filter(w => w.to === id).map(w => byId.get(w.from)).filter(Boolean) as GNode[];
|
.filter(w => w.to === n.id)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.toPort - b.toPort)
|
||||||
|
.map(w => w.from);
|
||||||
|
if (n.kind === 'source') return { id: n.id, kind: 'source', ds: n.ds, signal: n.signal, x: n.x, y: n.y };
|
||||||
|
if (n.kind === 'output') return { id: n.id, kind: 'output', inputs, x: n.x, y: n.y };
|
||||||
|
return { id: n.id, kind: 'op', op: n.op, params: n.params, inputs, x: n.x, y: n.y };
|
||||||
|
});
|
||||||
|
return { nodes, output: output?.id ?? '' };
|
||||||
|
}
|
||||||
|
|
||||||
const chain: GNode[] = [];
|
// Detect a cycle in the graph (Kahn count over input edges).
|
||||||
const seen = new Set<string>();
|
function hasCycle(g: Graph): boolean {
|
||||||
let cur: GNode = output;
|
const indeg = new Map<string, number>();
|
||||||
for (;;) {
|
const succ = new Map<string, string[]>();
|
||||||
if (seen.has(cur.id)) return { error: 'the graph contains a cycle' };
|
for (const n of g.nodes) indeg.set(n.id, 0);
|
||||||
seen.add(cur.id);
|
for (const w of g.wires) {
|
||||||
const ups = upstream(cur.id);
|
if (!indeg.has(w.to) || !indeg.has(w.from)) continue;
|
||||||
const opUps = ups.filter(n => n.kind === 'op');
|
indeg.set(w.to, (indeg.get(w.to) ?? 0) + 1);
|
||||||
const srcUps = ups.filter(n => n.kind === 'source');
|
succ.set(w.from, [...(succ.get(w.from) ?? []), w.to]);
|
||||||
if (opUps.length > 1) return { error: 'a node may have only one upstream node — the pipeline must be a single chain' };
|
}
|
||||||
if (opUps.length === 1) {
|
const queue = g.nodes.filter(n => (indeg.get(n.id) ?? 0) === 0).map(n => n.id);
|
||||||
if (srcUps.length > 0) return { error: 'only the first processing node may take input signals directly' };
|
let visited = 0;
|
||||||
chain.unshift(opUps[0]);
|
while (queue.length) {
|
||||||
cur = opUps[0];
|
const id = queue.shift()!;
|
||||||
|
visited++;
|
||||||
|
for (const s of succ.get(id) ?? []) {
|
||||||
|
indeg.set(s, (indeg.get(s) ?? 0) - 1);
|
||||||
|
if ((indeg.get(s) ?? 0) === 0) queue.push(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return visited !== g.nodes.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the graph; return a per-node error map plus the first message found.
|
||||||
|
function validate(g: Graph): { errors: Map<string, string>; first?: string } {
|
||||||
|
const errors = new Map<string, string>();
|
||||||
|
const set = (id: string, msg: string) => { if (!errors.has(id)) errors.set(id, msg); };
|
||||||
|
|
||||||
|
if (!g.nodes.some(n => n.kind === 'output')) {
|
||||||
|
return { errors, first: 'missing output node' };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const n of g.nodes) {
|
||||||
|
const ports = new Set(g.wires.filter(w => w.to === n.id).map(w => w.toPort));
|
||||||
|
if (n.kind === 'source') {
|
||||||
|
if (!n.ds || !n.signal) set(n.id, 'pick a data source and signal');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Reached the head of the chain: its source upstreams become the inputs.
|
if (n.kind === 'output') {
|
||||||
if (srcUps.length === 0) {
|
if (!ports.has(0)) set(n.id, 'connect a value to the output');
|
||||||
return { error: cur.kind === 'output' ? 'connect at least one signal to the output' : 'the first node has no input signals' };
|
continue;
|
||||||
|
}
|
||||||
|
const ar = opArity(n.op);
|
||||||
|
if (ar.kind === 'fixed' || ar.kind === 'named') {
|
||||||
|
const cnt = ar.kind === 'fixed' ? ar.n : ((n.params?.vars as string[] | undefined)?.length ?? 0);
|
||||||
|
if (cnt === 0) { set(n.id, 'add at least one named input'); continue; }
|
||||||
|
for (let i = 0; i < cnt; i++) {
|
||||||
|
if (!ports.has(i)) { set(n.id, 'all inputs must be connected'); break; }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// min: needs ≥ ar.n contiguous inputs starting at port 0.
|
||||||
|
const cnt = ports.size;
|
||||||
|
if (cnt < ar.n) { set(n.id, `needs at least ${ar.n} inputs`); continue; }
|
||||||
|
for (let i = 0; i < cnt; i++) {
|
||||||
|
if (!ports.has(i)) { set(n.id, 'inputs have a gap — reconnect them'); break; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const inputs = srcUps
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => a.y - b.y)
|
|
||||||
.map(n => ({ ds: n.ds || '', signal: n.signal || '' }));
|
|
||||||
if (inputs.some(i => !i.ds || !i.signal)) return { error: 'every input signal needs a data source and a signal' };
|
|
||||||
const pipeline = chain.map(n => ({ type: n.op!, params: n.params ?? {} }));
|
|
||||||
return { inputs, pipeline };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let first: string | undefined;
|
||||||
|
if (hasCycle(g)) first = 'the graph contains a cycle';
|
||||||
|
if (!first) {
|
||||||
|
for (const n of g.nodes) {
|
||||||
|
const m = errors.get(n.id);
|
||||||
|
if (m) { first = `${n.kind === 'op' ? opLabel(n.op ?? '') : n.kind}: ${m}`; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { errors, first };
|
||||||
}
|
}
|
||||||
|
|
||||||
function nodeSummary(n: GNode): string {
|
function nodeSummary(n: GNode): string {
|
||||||
@@ -228,6 +342,15 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flatten every known ds:name into a single list for the fuzzy SignalPicker,
|
||||||
|
// and a hook to lazily load all data sources' signals when a picker opens.
|
||||||
|
function allSignalOptions(): SignalOption[] {
|
||||||
|
const out: SignalOption[] = [];
|
||||||
|
for (const ds of dataSources) for (const name of (dsSignals[ds] ?? [])) out.push({ ds, name });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function openAllSignals() { dataSources.forEach(ds => loadSignals(ds)); }
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (create) {
|
if (create) {
|
||||||
const blank: SignalDef = { name: '', inputs: [], pipeline: [], meta: {} };
|
const blank: SignalDef = { name: '', inputs: [], pipeline: [], meta: {} };
|
||||||
@@ -288,6 +411,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
function addOp(op: OpDef, x?: number, y?: number) {
|
function addOp(op: OpDef, x?: number, y?: number) {
|
||||||
const params: Record<string, any> = {};
|
const params: Record<string, any> = {};
|
||||||
for (const p of op.params) params[p.key] = p.type === 'number' ? parseFloat(p.default) : p.default;
|
for (const p of op.params) params[p.key] = p.type === 'number' ? parseFloat(p.default) : p.default;
|
||||||
|
if (op.arity.kind === 'named') params.vars = ['a', 'b'];
|
||||||
const node: GNode = { id: genId(), kind: 'op', op: op.type, params, x: x ?? (14 + (graph.nodes.length % 4) * 3) * REM, y: y ?? (3 + (graph.nodes.length % 4) * 2) * REM };
|
const node: GNode = { id: genId(), kind: 'op', op: op.type, params, x: x ?? (14 + (graph.nodes.length % 4) * 3) * REM, y: y ?? (3 + (graph.nodes.length % 4) * 2) * REM };
|
||||||
commit({ nodes: [...graph.nodes, node], wires: graph.wires });
|
commit({ nodes: [...graph.nodes, node], wires: graph.wires });
|
||||||
setSelected(node.id); setSelectedWire(null);
|
setSelected(node.id); setSelectedWire(null);
|
||||||
@@ -306,6 +430,42 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
wires: graph.wires,
|
wires: graph.wires,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Named-input (expr/lua) variable list editing.
|
||||||
|
function addNamedVar(id: string) {
|
||||||
|
commit({
|
||||||
|
nodes: graph.nodes.map(n => {
|
||||||
|
if (n.id !== id) return n;
|
||||||
|
const vars = [...((n.params?.vars as string[] | undefined) ?? [])];
|
||||||
|
vars.push(letters(vars.length + 1)[vars.length]);
|
||||||
|
return { ...n, params: { ...n.params, vars } };
|
||||||
|
}),
|
||||||
|
wires: graph.wires,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function renameNamedVar(id: string, idx: number, value: string) {
|
||||||
|
commit({
|
||||||
|
nodes: graph.nodes.map(n => {
|
||||||
|
if (n.id !== id) return n;
|
||||||
|
const vars = [...((n.params?.vars as string[] | undefined) ?? [])];
|
||||||
|
vars[idx] = value;
|
||||||
|
return { ...n, params: { ...n.params, vars } };
|
||||||
|
}),
|
||||||
|
wires: graph.wires,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function removeNamedVar(id: string, idx: number) {
|
||||||
|
const nodes = graph.nodes.map(n => {
|
||||||
|
if (n.id !== id) return n;
|
||||||
|
const vars = [...((n.params?.vars as string[] | undefined) ?? [])];
|
||||||
|
vars.splice(idx, 1);
|
||||||
|
return { ...n, params: { ...n.params, vars } };
|
||||||
|
});
|
||||||
|
// Drop the wire at the removed port and shift higher ports down by one.
|
||||||
|
const wires = graph.wires
|
||||||
|
.filter(w => !(w.to === id && w.toPort === idx))
|
||||||
|
.map(w => (w.to === id && w.toPort > idx ? { ...w, toPort: w.toPort - 1 } : w));
|
||||||
|
commit({ nodes, wires });
|
||||||
|
}
|
||||||
function moveNode(id: string, x: number, y: number, record: boolean) {
|
function moveNode(id: string, x: number, y: number, record: boolean) {
|
||||||
const g = graphRef.current;
|
const g = graphRef.current;
|
||||||
commit({ nodes: g.nodes.map(n => (n.id === id ? { ...n, x, y } : n)), wires: g.wires }, record);
|
commit({ nodes: g.nodes.map(n => (n.id === id ? { ...n, x, y } : n)), wires: g.wires }, record);
|
||||||
@@ -316,19 +476,29 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
commit({ nodes: graph.nodes.filter(x => x.id !== id), wires: graph.wires.filter(w => w.from !== id && w.to !== id) });
|
commit({ nodes: graph.nodes.filter(x => x.id !== id), wires: graph.wires.filter(w => w.from !== id && w.to !== id) });
|
||||||
if (selected === id) setSelected(null);
|
if (selected === id) setSelected(null);
|
||||||
}
|
}
|
||||||
function addWire(from: string, to: string) {
|
function addWire(from: string, to: string, toPort: number) {
|
||||||
if (from === to) return;
|
if (from === to) return;
|
||||||
const fn = graph.nodes.find(n => n.id === from);
|
const fn = graph.nodes.find(n => n.id === from);
|
||||||
const tn = graph.nodes.find(n => n.id === to);
|
const tn = graph.nodes.find(n => n.id === to);
|
||||||
if (!fn || !tn || !hasOutput(fn.kind) || !hasInput(tn.kind)) return;
|
if (!fn || !tn || !hasOutput(fn.kind) || tn.kind === 'source') return;
|
||||||
if (graph.wires.some(w => w.from === from && w.to === to)) return;
|
// One wire per input port: replace any existing wire on that port.
|
||||||
commit({ nodes: graph.nodes, wires: [...graph.wires, { from, to }] });
|
const wires = graph.wires.filter(w => !(w.to === to && w.toPort === toPort));
|
||||||
|
if (wires.some(w => w.from === from && w.to === to && w.toPort === toPort)) return;
|
||||||
|
commit({ nodes: graph.nodes, wires: [...wires, { from, to, toPort }] });
|
||||||
}
|
}
|
||||||
function deleteWire(idx: number) {
|
function deleteWire(idx: number) {
|
||||||
commit({ nodes: graph.nodes, wires: graph.wires.filter((_, i) => i !== idx) });
|
commit({ nodes: graph.nodes, wires: graph.wires.filter((_, i) => i !== idx) });
|
||||||
if (selectedWire === idx) setSelectedWire(null);
|
if (selectedWire === idx) setSelectedWire(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The lowest input port of `target` not yet wired (for node-body drops).
|
||||||
|
function firstFreePort(target: GNode): number {
|
||||||
|
const used = new Set(graphRef.current.wires.filter(w => w.to === target.id).map(w => w.toPort));
|
||||||
|
const cnt = inPortCount(target, graphRef.current.wires);
|
||||||
|
for (let i = 0; i < cnt; i++) if (!used.has(i)) return i;
|
||||||
|
return cnt; // all full — append (min-arity nodes grow)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Pointer / drag / wire ──────────────────────────────────────────────────
|
// ── Pointer / drag / wire ──────────────────────────────────────────────────
|
||||||
function toCanvas(e: MouseEvent) {
|
function toCanvas(e: MouseEvent) {
|
||||||
const el = canvasRef.current!;
|
const el = canvasRef.current!;
|
||||||
@@ -350,9 +520,9 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
pendingRef.current = null;
|
pendingRef.current = null;
|
||||||
setPendingWire(null);
|
setPendingWire(null);
|
||||||
}
|
}
|
||||||
function finishWire(target: GNode) {
|
function finishWire(target: GNode, port: number) {
|
||||||
const cur = pendingRef.current;
|
const cur = pendingRef.current;
|
||||||
if (cur && hasInput(target.kind)) addWire(cur.from, target.id);
|
if (cur && target.kind !== 'source') addWire(cur.from, target.id, port);
|
||||||
endWire();
|
endWire();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,12 +592,12 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
|
|
||||||
const byId = new Map(graph.nodes.map(n => [n.id, n]));
|
const byId = new Map(graph.nodes.map(n => [n.id, n]));
|
||||||
const sel = graph.nodes.find(n => n.id === selected) ?? null;
|
const sel = graph.nodes.find(n => n.id === selected) ?? null;
|
||||||
const compiled = compile(graph);
|
const { errors: nodeErrors, first: validationError } = validate(graph);
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (!def) return;
|
if (!def) return;
|
||||||
if (create && !sigName) { setError('Enter a name for the new signal.'); return; }
|
if (create && !sigName) { setError('Enter a name for the new signal.'); return; }
|
||||||
if (compiled.error) { setError(compiled.error); return; }
|
if (validationError) { setError(validationError); return; }
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
@@ -441,9 +611,10 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
const body: any = {
|
const body: any = {
|
||||||
...def,
|
...def,
|
||||||
name: sigName,
|
name: sigName,
|
||||||
inputs: compiled.inputs,
|
graph: compile(graph),
|
||||||
pipeline: compiled.pipeline,
|
|
||||||
meta,
|
meta,
|
||||||
|
inputs: undefined,
|
||||||
|
pipeline: undefined,
|
||||||
ds: undefined,
|
ds: undefined,
|
||||||
signal: undefined,
|
signal: undefined,
|
||||||
};
|
};
|
||||||
@@ -474,7 +645,8 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
|
|
||||||
function nodeClass(n: GNode): string {
|
function nodeClass(n: GNode): string {
|
||||||
const cat = n.kind === 'source' ? 'trigger' : n.kind === 'output' ? 'action' : 'flow';
|
const cat = n.kind === 'source' ? 'trigger' : n.kind === 'output' ? 'action' : 'flow';
|
||||||
return `flow-node flow-node-${cat}${selected === n.id ? ' flow-node-selected' : ''}`;
|
const err = nodeErrors.has(n.id) ? ' flow-node-error' : '';
|
||||||
|
return `flow-node flow-node-${cat}${selected === n.id ? ' flow-node-selected' : ''}${err}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -505,8 +677,8 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
onClick={() => addOp(op)}>{op.label}</button>
|
onClick={() => addOp(op)}>{op.label}</button>
|
||||||
))}
|
))}
|
||||||
<div class="flow-palette-hint hint">
|
<div class="flow-palette-hint hint">
|
||||||
Wire input signals into the first operation, chain operations, and connect the
|
Wire signals into each operation's input ports, then connect the result
|
||||||
last one to <b>Output</b>. The first node receives every input as <code>a,b,c,d</code>.
|
to <b>Output</b>. Formula / Lua nodes name their own inputs.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -519,7 +691,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
{graph.wires.map((w, idx) => {
|
{graph.wires.map((w, idx) => {
|
||||||
const a = byId.get(w.from); const b = byId.get(w.to);
|
const a = byId.get(w.from); const b = byId.get(w.to);
|
||||||
if (!a || !b) return null;
|
if (!a || !b) return null;
|
||||||
const p1 = outAnchor(a); const p2 = inAnchor(b);
|
const p1 = outAnchor(a); const p2 = inAnchor(b, w.toPort);
|
||||||
return (
|
return (
|
||||||
<path key={idx}
|
<path key={idx}
|
||||||
class={`flow-wire${selectedWire === idx ? ' flow-wire-selected' : ''}`}
|
class={`flow-wire${selectedWire === idx ? ' flow-wire-selected' : ''}`}
|
||||||
@@ -535,35 +707,47 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
})()}
|
})()}
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{graph.nodes.map(node => (
|
{graph.nodes.map(node => {
|
||||||
<div key={node.id}
|
const nIn = inPortCount(node, graph.wires);
|
||||||
class={nodeClass(node)}
|
return (
|
||||||
style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px;`}
|
<div key={node.id}
|
||||||
onMouseDown={(e) => startNodeDrag(e, node)}
|
class={nodeClass(node)}
|
||||||
onMouseUp={() => { if (pendingRef.current) finishWire(node); }}>
|
style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px; min-height:${nodeMinHeight(node, graph.wires)}px;`}
|
||||||
<div class="flow-node-header">
|
onMouseDown={(e) => startNodeDrag(e, node)}
|
||||||
<span class="flow-node-title">{node.kind === 'source' ? 'Signal' : node.kind === 'output' ? 'Output' : opLabel(node.op ?? '')}</span>
|
onMouseUp={() => { if (pendingRef.current) finishWire(node, firstFreePort(node)); }}>
|
||||||
{node.kind !== 'output' && (
|
<div class="flow-node-header">
|
||||||
<button class="flow-node-del" title="Delete node"
|
<span class="flow-node-title">{node.kind === 'source' ? 'Signal' : node.kind === 'output' ? 'Output' : opLabel(node.op ?? '')}</span>
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
{node.kind !== 'output' && (
|
||||||
onClick={(e) => { e.stopPropagation(); deleteNode(node.id); }}>✕</button>
|
<button class="flow-node-del" title="Delete node"
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => { e.stopPropagation(); deleteNode(node.id); }}>✕</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div class="flow-node-body hint">{nodeSummary(node)}</div>
|
||||||
|
|
||||||
|
{Array.from({ length: nIn }, (_, port) => {
|
||||||
|
const label = portLabel(node, port);
|
||||||
|
return (
|
||||||
|
<Fragment key={port}>
|
||||||
|
<div class="flow-port flow-port-in" title={label || `Input ${port + 1}`}
|
||||||
|
style={`top:${PORT_TOP + port * PORT_GAP - PORT_R}px; left:${-PORT_R}px;`}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onMouseUp={(e) => { e.stopPropagation(); finishWire(node, port); }} />
|
||||||
|
{label && (
|
||||||
|
<span class="flow-port-label-in"
|
||||||
|
style={`top:${PORT_TOP + port * PORT_GAP - 0.6 * REM}px;`}>{label}</span>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{hasOutput(node.kind) && (
|
||||||
|
<div class="flow-port flow-port-out" title="Output"
|
||||||
|
style={`top:${PORT_TOP - PORT_R}px; right:${-PORT_R}px;`}
|
||||||
|
onMouseDown={(e) => startWire(e, node)} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div class="flow-node-body hint">{nodeSummary(node)}</div>
|
);
|
||||||
|
})}
|
||||||
{hasInput(node.kind) && (
|
|
||||||
<div class="flow-port flow-port-in" title="Input"
|
|
||||||
style={`top:${PORT_TOP - PORT_R}px; left:${-PORT_R}px;`}
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
onMouseUp={(e) => { e.stopPropagation(); finishWire(node); }} />
|
|
||||||
)}
|
|
||||||
{hasOutput(node.kind) && (
|
|
||||||
<div class="flow-port flow-port-out" title="Output"
|
|
||||||
style={`top:${PORT_TOP - PORT_R}px; right:${-PORT_R}px;`}
|
|
||||||
onMouseDown={(e) => startWire(e, node)} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -619,16 +803,17 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
{sel?.kind === 'source' && (
|
{sel?.kind === 'source' && (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<div class="wizard-section-title">Input signal</div>
|
<div class="wizard-section-title">Input signal</div>
|
||||||
<div class="wizard-field">
|
|
||||||
<label>Data source</label>
|
|
||||||
<SearchableSelect value={sel.ds ?? ''} options={dataSources}
|
|
||||||
onSelect={(ds) => { loadSignals(ds); patchNode(sel.id, { ds, signal: '' }); }} />
|
|
||||||
</div>
|
|
||||||
<div class="wizard-field">
|
<div class="wizard-field">
|
||||||
<label>Signal</label>
|
<label>Signal</label>
|
||||||
<SearchableSelect value={sel.signal ?? ''} options={dsSignals[sel.ds ?? ''] ?? []}
|
<SignalPicker
|
||||||
onSelect={(signal) => patchNode(sel.id, { signal })}
|
value={sel.signal ? `${sel.ds ?? ''}:${sel.signal}` : ''}
|
||||||
placeholder={sel.ds ? 'Search signals…' : 'Select data source first'} />
|
options={allSignalOptions()}
|
||||||
|
onOpen={openAllSignals}
|
||||||
|
onChange={(ref) => {
|
||||||
|
const i = ref.indexOf(':');
|
||||||
|
if (i < 0) patchNode(sel.id, { ds: ref, signal: '' });
|
||||||
|
else patchNode(sel.id, { ds: ref.slice(0, i), signal: ref.slice(i + 1) });
|
||||||
|
}} />
|
||||||
</div>
|
</div>
|
||||||
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(sel.id)}>Delete node</button>
|
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(sel.id)}>Delete node</button>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
@@ -637,8 +822,23 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
{sel?.kind === 'op' && (
|
{sel?.kind === 'op' && (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<div class="wizard-section-title">{opLabel(sel.op ?? '')}</div>
|
<div class="wizard-section-title">{opLabel(sel.op ?? '')}</div>
|
||||||
{opParamDefs(sel.op ?? '').length === 0 && (
|
{opArity(sel.op).kind === 'named' && (
|
||||||
<p class="hint">No parameters — this operation transforms its input directly.</p>
|
<div class="wizard-field">
|
||||||
|
<label>Inputs</label>
|
||||||
|
{((sel.params?.vars as string[] | undefined) ?? []).map((v, i) => (
|
||||||
|
<div key={i} class="synth-var-row">
|
||||||
|
<input class="prop-input" type="text" value={v}
|
||||||
|
onInput={(e) => renameNamedVar(sel.id, i, (e.target as HTMLInputElement).value)} />
|
||||||
|
<button class="icon-btn" title="Remove input"
|
||||||
|
onClick={() => removeNamedVar(sel.id, i)}>✕</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button class="panel-btn" style="margin-top:0.4rem;" onClick={() => addNamedVar(sel.id)}>+ Input</button>
|
||||||
|
<p class="hint" style="margin-top:0.4rem;">Use these names in the {sel.op === 'lua' ? 'script' : 'formula'} below.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{opParamDefs(sel.op ?? '').length === 0 && opArity(sel.op).kind !== 'named' && (
|
||||||
|
<p class="hint">No parameters — this operation transforms its inputs directly.</p>
|
||||||
)}
|
)}
|
||||||
{opParamDefs(sel.op ?? '').map(pd => (
|
{opParamDefs(sel.op ?? '').map(pd => (
|
||||||
<div key={pd.key} class="wizard-field">
|
<div key={pd.key} class="wizard-field">
|
||||||
@@ -669,9 +869,9 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
|
|
||||||
<div class="wizard-footer">
|
<div class="wizard-footer">
|
||||||
{error && <span class="wizard-error" style="flex:1;">{error}</span>}
|
{error && <span class="wizard-error" style="flex:1;">{error}</span>}
|
||||||
{!error && compiled.error && <span class="hint" style="flex:1;">{compiled.error}</span>}
|
{!error && validationError && <span class="hint" style="flex:1;">{validationError}</span>}
|
||||||
<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 || !!compiled.error}>
|
<button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving || loading || !!validationError}>
|
||||||
{saving ? 'Saving…' : 'Save Signal'}
|
{saving ? 'Saving…' : 'Save Signal'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { writable } from './store';
|
||||||
|
|
||||||
|
// A dialog request pushed by a server-side control-logic action.dialog node and
|
||||||
|
// delivered over the WebSocket. Unlike panel logic dialogs (lib/logic.ts) these
|
||||||
|
// originate on the server and are addressed to the connected user by identity.
|
||||||
|
export interface ControlDialog {
|
||||||
|
id: string;
|
||||||
|
kind: 'info' | 'error' | 'input';
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active control-logic dialogs awaiting the user's acknowledgement / input.
|
||||||
|
export const controlDialogs = writable<ControlDialog[]>([]);
|
||||||
|
|
||||||
|
// pushControlDialog is invoked by the WS client on a "dialog" message.
|
||||||
|
export function pushControlDialog(d: ControlDialog): void {
|
||||||
|
controlDialogs.update(list => (list.some(x => x.id === d.id) ? list : [...list, d]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// dismissControlDialog removes a dialog once answered or cancelled.
|
||||||
|
export function dismissControlDialog(id: string): void {
|
||||||
|
controlDialogs.update(list => list.filter(d => d.id !== id));
|
||||||
|
}
|
||||||
+22
-1
@@ -275,12 +275,33 @@ export interface PipelineNode {
|
|||||||
params: Record<string, any>;
|
params: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DAG form of a synthetic signal (mirrors backend synthetic.Graph). A node is a
|
||||||
|
// source (ds:signal root), an op (DSP node with ordered `inputs`), or the single
|
||||||
|
// output. When present it supersedes the legacy inputs+pipeline linear form.
|
||||||
|
export type SynthNodeKind = 'source' | 'op' | 'output';
|
||||||
|
export interface SynthGraphNode {
|
||||||
|
id: string;
|
||||||
|
kind: SynthNodeKind;
|
||||||
|
op?: string;
|
||||||
|
params?: Record<string, any>;
|
||||||
|
ds?: string;
|
||||||
|
signal?: string;
|
||||||
|
inputs?: string[]; // upstream node ids, in input order
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
}
|
||||||
|
export interface SynthGraph {
|
||||||
|
nodes: SynthGraphNode[];
|
||||||
|
output: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SignalDef {
|
export interface SignalDef {
|
||||||
name: string;
|
name: string;
|
||||||
ds?: string; // legacy single input
|
ds?: string; // legacy single input
|
||||||
signal?: string; // legacy single input
|
signal?: string; // legacy single input
|
||||||
inputs?: InputRef[];
|
inputs?: InputRef[];
|
||||||
pipeline: PipelineNode[];
|
pipeline?: PipelineNode[];
|
||||||
|
graph?: SynthGraph; // DAG form (preferred when present)
|
||||||
meta: {
|
meta: {
|
||||||
unit?: string;
|
unit?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { writable, type Readable } from './store';
|
import { writable, type Readable } from './store';
|
||||||
import { writeLocalState } from './localstate';
|
import { writeLocalState } from './localstate';
|
||||||
|
import { pushControlDialog } from './controldialogs';
|
||||||
import type { SignalRef, SignalValue, SignalMeta, HistoryPoint } from './types';
|
import type { SignalRef, SignalValue, SignalMeta, HistoryPoint } from './types';
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
@@ -163,6 +164,16 @@ class WsClient {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'dialog': {
|
||||||
|
// Server-side control logic requesting a user notification / input.
|
||||||
|
pushControlDialog({
|
||||||
|
id: String(msg.id),
|
||||||
|
kind: msg.kind === 'error' || msg.kind === 'input' ? msg.kind : 'info',
|
||||||
|
title: msg.title,
|
||||||
|
message: msg.message,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'error':
|
case 'error':
|
||||||
// Resolve any pending history callbacks with empty data on error
|
// Resolve any pending history callbacks with empty data on error
|
||||||
if (key && this.histCallbacks.has(key)) {
|
if (key && this.histCallbacks.has(key)) {
|
||||||
@@ -270,6 +281,14 @@ class WsClient {
|
|||||||
console.log('[ws] write', ref.ds, ref.name, value, 'ws state:', this.ws?.readyState);
|
console.log('[ws] write', ref.ds, ref.name, value, 'ws state:', this.ws?.readyState);
|
||||||
this._send({ type: 'write', ds: ref.ds, name: ref.name, value });
|
this._send({ type: 'write', ds: ref.ds, name: ref.name, value });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answer a control-logic input dialog. `value === null` cancels (dismisses)
|
||||||
|
* the dialog without writing its target server variable.
|
||||||
|
*/
|
||||||
|
sendDialogResponse(id: string, value: number | null): void {
|
||||||
|
this._send({ type: 'dialogResponse', id, value });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton instance
|
// Singleton instance
|
||||||
|
|||||||
@@ -1263,6 +1263,8 @@ body {
|
|||||||
.flow-node-flow { border-top: 3px solid #10b981; }
|
.flow-node-flow { border-top: 3px solid #10b981; }
|
||||||
.flow-node-action { border-top: 3px solid #3b82f6; }
|
.flow-node-action { border-top: 3px solid #3b82f6; }
|
||||||
.flow-node-selected { border-color: #3b82f6; box-shadow: 0 0 0 2px #3b82f680; }
|
.flow-node-selected { border-color: #3b82f6; box-shadow: 0 0 0 2px #3b82f680; }
|
||||||
|
.flow-node-error { border-color: #ef4444; box-shadow: 0 0 0 2px #ef444455; }
|
||||||
|
.flow-node-error.flow-node-selected { box-shadow: 0 0 0 2px #ef4444aa; }
|
||||||
|
|
||||||
.flow-node-header {
|
.flow-node-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1309,6 +1311,24 @@ body {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Named input ports (Formula/Lua) show their variable name beside the port. */
|
||||||
|
.flow-port-label-in {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.55rem;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Named-input editor row in the inspector */
|
||||||
|
.synth-var-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
.synth-var-row > input { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
/* Expression field: signal-insert helper row */
|
/* Expression field: signal-insert helper row */
|
||||||
.flow-expr-insert {
|
.flow-expr-insert {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -90,6 +90,17 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
|||||||
// ── uPlot (timeseries) ──────────────────────────────────────────────────
|
// ── uPlot (timeseries) ──────────────────────────────────────────────────
|
||||||
let uplot: uPlot | null = null;
|
let uplot: uPlot | null = null;
|
||||||
|
|
||||||
|
// Newest sample timestamp across all series (seconds). Anchoring the live
|
||||||
|
// x-axis to this — rather than wall-clock Date.now() — avoids client/server
|
||||||
|
// clock skew clipping the latest point, and keeps the right edge on real data.
|
||||||
|
function latestSampleTs(): number {
|
||||||
|
let m = -Infinity;
|
||||||
|
for (const b of buffers) {
|
||||||
|
if (b.timestamps.length) m = Math.max(m, b.timestamps[b.timestamps.length - 1]);
|
||||||
|
}
|
||||||
|
return isFinite(m) ? m : Date.now() / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
// windowSec: apply rolling window for live mode; 0 = use full buffer (historical)
|
// windowSec: apply rolling window for live mode; 0 = use full buffer (historical)
|
||||||
function uplotData(windowSec = 0): uPlot.AlignedData {
|
function uplotData(windowSec = 0): uPlot.AlignedData {
|
||||||
if (signals.length === 0) return [[]];
|
if (signals.length === 0) return [[]];
|
||||||
@@ -165,7 +176,22 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
|||||||
{ stroke: '#94a3b8', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } },
|
{ stroke: '#94a3b8', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } },
|
||||||
yAxis,
|
yAxis,
|
||||||
],
|
],
|
||||||
scales: { x: { time: true }, y: { ...scaleY, range: scaleYRange } },
|
scales: {
|
||||||
|
x: {
|
||||||
|
time: true,
|
||||||
|
// Live mode with a finite window: pin the x-axis to a rolling
|
||||||
|
// [newest − window, newest] span so a single old/outlier sample
|
||||||
|
// can't stretch the axis and compress real points to the right.
|
||||||
|
// Historical mode (timeRange) keeps uPlot's data-driven auto-range.
|
||||||
|
...(!timeRange && timeWindow > 0
|
||||||
|
? { range: (): [number, number] => {
|
||||||
|
const r = latestSampleTs();
|
||||||
|
return [r - timeWindow, r];
|
||||||
|
} }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
y: { ...scaleY, range: scaleYRange },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+22
-1
@@ -1,10 +1,31 @@
|
|||||||
{
|
{
|
||||||
"panels": {},
|
"panels": {
|
||||||
|
"epics_test": {
|
||||||
|
"owner": ""
|
||||||
|
},
|
||||||
|
"new-plot-panel": {
|
||||||
|
"owner": "",
|
||||||
|
"folder": "fld-c5396468072d4f69"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"owner": "martino",
|
||||||
|
"order": 1
|
||||||
|
},
|
||||||
|
"test-1781729251317": {
|
||||||
|
"owner": "",
|
||||||
|
"order": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
"folders": {
|
"folders": {
|
||||||
"fld-68b0bb9c23c9fd3b": {
|
"fld-68b0bb9c23c9fd3b": {
|
||||||
"id": "fld-68b0bb9c23c9fd3b",
|
"id": "fld-68b0bb9c23c9fd3b",
|
||||||
"name": "Test",
|
"name": "Test",
|
||||||
"owner": ""
|
"owner": ""
|
||||||
|
},
|
||||||
|
"fld-c5396468072d4f69": {
|
||||||
|
"id": "fld-c5396468072d4f69",
|
||||||
|
"name": "Plots",
|
||||||
|
"owner": "martino"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
Binary file not shown.
@@ -1,9 +1 @@
|
|||||||
[
|
[]
|
||||||
{
|
|
||||||
"id": "cl-0f99598ca4d0ea15",
|
|
||||||
"name": "New control logic",
|
|
||||||
"enabled": false,
|
|
||||||
"nodes": [],
|
|
||||||
"wires": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"id": "cl-0f99598ca4d0ea15",
|
||||||
|
"name": "New control logic",
|
||||||
|
"enabled": false,
|
||||||
|
"nodes": [],
|
||||||
|
"wires": []
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user