From 9ec193ef1d017eb3a70a035b00852bfe6176df33 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Wed, 28 Jan 2026 08:38:58 -0800 Subject: [PATCH] Add missing node tracking for config-defined nodes Co-Authored-By: Claude Opus 4.5 --- config.go | 53 ++++++++++++- config.yaml | 192 +++++++++++++++++++++++----------------------- errors.go | 57 +++++++++++++- nodes.go | 5 ++ static/index.html | 5 +- tendrils.go | 44 +++++++++++ types.go | 2 + 7 files changed, 254 insertions(+), 104 deletions(-) diff --git a/config.go b/config.go index 0fd6432..0173279 100644 --- a/config.go +++ b/config.go @@ -2,6 +2,7 @@ package tendrils import ( "encoding/json" + "net" "os" "gopkg.in/yaml.v3" @@ -12,10 +13,54 @@ type Config struct { } type Location struct { - Name string `yaml:"name" json:"name"` - Direction string `yaml:"direction,omitempty" json:"direction,omitempty"` - Nodes []string `yaml:"nodes,omitempty" json:"nodes,omitempty"` - Children []*Location `yaml:"children,omitempty" json:"children,omitempty"` + Name string `yaml:"name" json:"name"` + Direction string `yaml:"direction,omitempty" json:"direction,omitempty"` + Nodes []*NodeRef `yaml:"nodes,omitempty" json:"nodes,omitempty"` + Children []*Location `yaml:"children,omitempty" json:"children,omitempty"` +} + +type NodeRef struct { + Name string `yaml:"name,omitempty" json:"name,omitempty"` + MAC string `yaml:"mac,omitempty" json:"mac,omitempty"` + IP string `yaml:"ip,omitempty" json:"ip,omitempty"` +} + +func (r *NodeRef) DisplayName() string { + if r.Name != "" { + return r.Name + } + if r.MAC != "" { + return r.MAC + } + return r.IP +} + +func (r *NodeRef) ParseMAC() net.HardwareAddr { + if r.MAC == "" { + return nil + } + mac, _ := net.ParseMAC(r.MAC) + return mac +} + +func (r *NodeRef) ParseIP() net.IP { + if r.IP == "" { + return nil + } + return net.ParseIP(r.IP) +} + +func (c *Config) AllNodeRefs() []*NodeRef { + var refs []*NodeRef + var collect func([]*Location) + collect = func(locs []*Location) { + for _, loc := range locs { + refs = append(refs, loc.Nodes...) + collect(loc.Children) + } + } + collect(c.Locations) + return refs } func LoadConfig(path string) (*Config, error) { diff --git a/config.yaml b/config.yaml index ca7aa0d..af311d8 100644 --- a/config.yaml +++ b/config.yaml @@ -4,168 +4,168 @@ locations: children: - name: LIGHTING-2 Rack nodes: - - lighting-2 - - "48:59:00:41:00:29" # Pixie Driver 8k Port 1 - - "48:59:00:28:00:27" # Pixie Driver 8k Port 2 - - "48:59:00:43:00:29" # Pixie Driver 8k Port 3 - - "48:59:00:42:00:29" # Pixie Driver 8k Port 4 - - "48:59:00:3c:00:3e" # Pixie Driver 8k Port 5 - - "48:59:00:3f:00:3e" # Pixie Driver 8k Port 6 - - "48:59:00:25:00:3e" # Pixie Driver 8k Port 7 - - "48:59:00:41:00:3e" # Pixie Driver 8k Port 8 - - ART9 # Cyc - - ART10 # Cyc - - ART11 # Cyc - - ART12 # Cyc - - ART13 # Cyc - - ART14 # Cyc + - name: lighting-2 + - mac: "48:59:00:41:00:29" # Pixie Driver 8k Port 1 + - mac: "48:59:00:28:00:27" # Pixie Driver 8k Port 2 + - mac: "48:59:00:43:00:29" # Pixie Driver 8k Port 3 + - mac: "48:59:00:42:00:29" # Pixie Driver 8k Port 4 + - mac: "48:59:00:3c:00:3e" # Pixie Driver 8k Port 5 + - mac: "48:59:00:3f:00:3e" # Pixie Driver 8k Port 6 + - mac: "48:59:00:25:00:3e" # Pixie Driver 8k Port 7 + - mac: "48:59:00:41:00:3e" # Pixie Driver 8k Port 8 + - name: ART9 # Cyc + - name: ART10 # Cyc + - name: ART11 # Cyc + - name: ART12 # Cyc + - name: ART13 # Cyc + - name: ART14 # Cyc - direction: horizontal children: - nodes: - - ART16 # R2X1 + - name: ART16 # R2X1 - nodes: - - ART20 # R2X2 + - name: ART20 # R2X2 - direction: horizontal children: - nodes: - - "MON1-A" - - "MON1-B" + - name: MON1-A + - name: MON1-B - name: Under Apron direction: horizontal children: - name: AUDIO Rack nodes: - - audio - - "MICS-A" - - "00:0e:dd:a7:29:93" # MICS-A bridge interface - - "MICS-B" - - "00:0e:dd:a8:3e:b3" # MICS-B bridge interface - - "MICS-C" - - "00:0e:dd:a7:6f:55" # MICS-C bridge interface - - "MICS-D" - - "00:0e:dd:64:3d:51" # MICS-D bridge interface - - "MICS-E" - - "00:0e:dd:ac:fc:7d" # MICS-E bridge interface + - name: audio + - name: MICS-A + - mac: "00:0e:dd:a7:29:93" # MICS-A bridge interface + - name: MICS-B + - mac: "00:0e:dd:a8:3e:b3" # MICS-B bridge interface + - name: MICS-C + - mac: "00:0e:dd:a7:6f:55" # MICS-C bridge interface + - name: MICS-D + - mac: "00:0e:dd:64:3d:51" # MICS-D bridge interface + - name: MICS-E + - mac: "00:0e:dd:ac:fc:7d" # MICS-E bridge interface - name: LIGHTING-1 Rack nodes: - - lighting-1 - - "48:59:00:27:00:27" # Pixie Driver 8k Port 1 - - "48:59:00:37:00:27" # Pixie Driver 8k Port 2 - - "48:59:00:3e:00:27" # Pixie Driver 8k Port 3 - - "48:59:00:3f:00:27" # Pixie Driver 8k Port 4 - - "48:59:00:47:00:1a" # Pixie Driver 8k Port 5 - - "48:59:00:44:00:1a" # Pixie Driver 8k Port 6 - - "48:59:00:42:00:19" # Pixie Driver 8k Port 7 - - "48:59:00:44:00:19" # Pixie Driver 8k Port 8 + - name: lighting-1 + - mac: "48:59:00:27:00:27" # Pixie Driver 8k Port 1 + - mac: "48:59:00:37:00:27" # Pixie Driver 8k Port 2 + - mac: "48:59:00:3e:00:27" # Pixie Driver 8k Port 3 + - mac: "48:59:00:3f:00:27" # Pixie Driver 8k Port 4 + - mac: "48:59:00:47:00:1a" # Pixie Driver 8k Port 5 + - mac: "48:59:00:44:00:1a" # Pixie Driver 8k Port 6 + - mac: "48:59:00:42:00:19" # Pixie Driver 8k Port 7 + - mac: "48:59:00:44:00:19" # Pixie Driver 8k Port 8 - name: VIDEO Rack nodes: - - video - - "ATEM 2 M/E Constellation 4K" - - "HyperDeck-Studio-4K-Pro" - - RX-QLAB-1 - - RX-QLAB-2 - - TX-PROJ-1 - - TX-PROJ-2 - - TX-M4 - - TX-M16 - - TX-MISC - - TX-PREVIEW + - name: video + - name: "ATEM 2 M/E Constellation 4K" + - name: HyperDeck-Studio-4K-Pro + - name: RX-QLAB-1 + - name: RX-QLAB-2 + - name: TX-PROJ-1 + - name: TX-PROJ-2 + - name: TX-M4 + - name: TX-M16 + - name: TX-MISC + - name: TX-PREVIEW - direction: horizontal children: - nodes: - - "Y001-MAIN1-L-d1e155" - - "ac:44:f2:4e:84:d6" # MAIN1-L bridge interface + - name: Y001-MAIN1-L-d1e155 + - mac: "ac:44:f2:4e:84:d6" # MAIN1-L bridge interface - name: Stage 2 nodes: - - "MON2" + - name: MON2 - name: Stage 3 nodes: - - "MON3" + - name: MON3 - nodes: - - "Y001-MAIN1-R-d1e194" - - "ac:44:f2:4e:84:d4" # MAIN1-R bridge interface + - name: Y001-MAIN1-R-d1e194 + - mac: "ac:44:f2:4e:84:d4" # MAIN1-R bridge interface - direction: horizontal children: - nodes: - - "RX-PROJ-1" + - name: RX-PROJ-1 - nodes: - - "RX-PROJ-2" + - name: RX-PROJ-2 - direction: horizontal children: - nodes: - - satellite-2 - - "Y001-MAIN2-L-d1e298" - - "ac:44:f2:4e:87:2a" # MAIN2-L bridge interface - - ART3 # Wash - - ART4 # Wash - - ART5 # Wash - - ART17 # Focus + - name: satellite-2 + - name: Y001-MAIN2-L-d1e298 + - mac: "ac:44:f2:4e:87:2a" # MAIN2-L bridge interface + - name: ART3 # Wash + - name: ART4 # Wash + - name: ART5 # Wash + - name: ART17 # Focus - nodes: - - ART15 # R3X - - ART18 # Focus + - name: ART15 # R3X + - name: ART18 # Focus - nodes: - - satellite-3 - - "Y001-MAIN2-R-f0dd93" - - "ac:44:f2:4e:87:27" # MAIN2-R bridge interface - - ART6 # Wash - - ART7 # Wash - - DMX8 # Wash - - ART19 # Focus + - name: satellite-3 + - name: Y001-MAIN2-R-f0dd93 + - mac: "ac:44:f2:4e:87:27" # MAIN2-R bridge interface + - name: ART6 # Wash + - name: ART7 # Wash + - name: DMX32 # Wash + - name: ART19 # Focus - name: Booth direction: vertical children: - nodes: - - satellite-1 + - name: satellite-1 - direction: horizontal children: - name: Lighting Control nodes: - - qlab - - TX-QLAB-1 - - TX-QLAB-2 - - "SK_PTZEXTREMEV2 [457081]" - - "SK_RACKPRO2 [452514]" - - pigeon - - showpi1 - - d8:3a:dd:e3:5b:db # showpi2/artmap - + - name: qlab + - name: TX-QLAB-1 + - name: TX-QLAB-2 + - name: "SK_PTZEXTREMEV2 [457081]" + - name: "SK_RACKPRO2 [452514]" + - name: pigeon + - name: showpi1 + - mac: "d8:3a:dd:e3:5b:db" # showpi2/artmap + - name: Sound Control nodes: - - SQ-7 - - "00:04:c4:15:07:a4" # SQ-7 bridge port - - BT + - name: SQ-7 + - mac: "00:04:c4:15:07:a4" # SQ-7 bridge port + - name: BT - name: Camera Control nodes: - - RX-CC-PREVIEW - - RX-CC-M16 - - "AtemPanel-7c2e0da86d22" - - "AtemPanel-7c2e0da86d4c" + - name: RX-CC-PREVIEW + - name: RX-CC-M16 + - name: AtemPanel-7c2e0da86d22 + - name: AtemPanel-7c2e0da86d4c - name: Video Control nodes: - - RX-VC-M4 - - RX-VC-M16 - - "ATEM-2-ME-Advanced-Panel-20" + - name: RX-VC-M4 + - name: RX-VC-M16 + - name: ATEM-2-ME-Advanced-Panel-20 - name: Control nodes: - - sunset - - RX-CONTROL-1 + - name: sunset + - name: RX-CONTROL-1 diff --git a/errors.go b/errors.go index 72149ef..4c2359e 100644 --- a/errors.go +++ b/errors.go @@ -9,10 +9,11 @@ import ( type PortErrorType string const ( - ErrorTypeStartup PortErrorType = "startup" - ErrorTypeNew PortErrorType = "new" - ErrorTypeUnreachable PortErrorType = "unreachable" + ErrorTypeStartup PortErrorType = "startup" + ErrorTypeNew PortErrorType = "new" + ErrorTypeUnreachable PortErrorType = "unreachable" ErrorTypeHighUtilization PortErrorType = "high_utilization" + ErrorTypeMissing PortErrorType = "missing" ) type PortError struct { @@ -327,3 +328,53 @@ func (e *ErrorTracker) clearUnreachableLocked(node *Node) (changed bool, becameR } return becameReachable, becameReachable } + +func (e *ErrorTracker) SetMissing(node *Node) { + changed := e.setMissingLocked(node) + if changed { + e.t.NotifyUpdate() + } +} + +func (e *ErrorTracker) setMissingLocked(node *Node) bool { + e.mu.Lock() + defer e.mu.Unlock() + + key := "missing:" + node.TypeID + + if _, exists := e.errors[key]; exists { + return false + } + + now := time.Now() + e.nextID++ + e.errors[key] = &PortError{ + ID: fmt.Sprintf("err-%d", e.nextID), + NodeTypeID: node.TypeID, + NodeName: node.DisplayName(), + PortName: "", + ErrorType: ErrorTypeMissing, + FirstSeen: now, + LastUpdated: now, + } + return true +} + +func (e *ErrorTracker) ClearMissing(node *Node) { + changed := e.clearMissingLocked(node) + if changed { + e.t.NotifyUpdate() + } +} + +func (e *ErrorTracker) clearMissingLocked(node *Node) bool { + e.mu.Lock() + defer e.mu.Unlock() + + key := "missing:" + node.TypeID + if _, exists := e.errors[key]; exists { + delete(e.errors, key) + return true + } + return false +} diff --git a/nodes.go b/nodes.go index b7ff7ad..3882869 100644 --- a/nodes.go +++ b/nodes.go @@ -67,6 +67,11 @@ func (n *Nodes) updateLocked(target *Node, mac net.HardwareAddr, ips []net.IP, i added := n.applyNodeUpdates(node, targetID, mac, ips, ifaceName, nodeName) + if node.Missing && source != "config" { + node.Missing = false + n.t.errors.ClearMissing(node) + } + n.logUpdates(node, added, isNew, source) if hasNewIP(added) { diff --git a/static/index.html b/static/index.html index 08afd0a..534dee1 100644 --- a/static/index.html +++ b/static/index.html @@ -991,6 +991,9 @@ if (node.interfaces) { node.interfaces.forEach(iface => { if (iface.mac) ids.push(iface.mac.toLowerCase()); + if (iface.ips) { + iface.ips.forEach(ip => ids.push(ip.toLowerCase())); + } }); } return ids; @@ -1147,7 +1150,7 @@ name: loc.name || '', anonymous: anonymous, direction: loc.direction || 'horizontal', - nodeRefs: (loc.nodes || []).map(n => n.toLowerCase()), + nodeRefs: (loc.nodes || []).map(n => (n.name || n.mac || n.ip || '').toLowerCase()), parent: parent, children: [] }; diff --git a/tendrils.go b/tendrils.go index 409fc82..28f6e1c 100644 --- a/tendrils.go +++ b/tendrils.go @@ -119,6 +119,48 @@ func (t *Tendrils) unsubscribeSSE(id int) { } } +func (t *Tendrils) syncConfigNodes() { + if t.config == nil { + return + } + + for _, ref := range t.config.AllNodeRefs() { + var node *Node + var created bool + + if ref.Name != "" { + node = t.nodes.GetByName(ref.Name) + if node == nil { + node = t.nodes.GetOrCreateByName(ref.Name) + created = true + } + } else if mac := ref.ParseMAC(); mac != nil { + node = t.nodes.GetByMAC(mac) + if node == nil { + t.nodes.Update(nil, mac, nil, "", "", "config") + node = t.nodes.GetByMAC(mac) + created = true + } + } else if ip := ref.ParseIP(); ip != nil { + node = t.nodes.GetByIP(ip) + if node == nil { + t.nodes.Update(nil, nil, []net.IP{ip}, "", "", "config") + node = t.nodes.GetByIP(ip) + created = true + } + } + + if node == nil { + continue + } + + if created { + node.Missing = true + t.errors.SetMissing(node) + } + } +} + func (t *Tendrils) Run() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -141,6 +183,7 @@ func (t *Tendrils) Run() { continue } t.config = cfg + t.syncConfigNodes() log.Printf("reloaded config from %s", t.ConfigFile) t.NotifyUpdate() } @@ -153,6 +196,7 @@ func (t *Tendrils) Run() { t.config = cfg t.populateLocalAddresses() + t.syncConfigNodes() t.startHTTPServer() if !t.DisableARP { diff --git a/types.go b/types.go index 0addc73..a7cf08a 100644 --- a/types.go +++ b/types.go @@ -140,6 +140,7 @@ type Node struct { PoEBudget *PoEBudget `json:"poe_budget,omitempty"` IsDanteClockMaster bool `json:"is_dante_clock_master,omitempty"` DanteTxChannels string `json:"dante_tx_channels,omitempty"` + Missing bool `json:"missing,omitempty"` pollTrigger chan struct{} } @@ -159,6 +160,7 @@ func (n *Node) WithInterface(ifaceKey string) *Node { PoEBudget: n.PoEBudget, IsDanteClockMaster: n.IsDanteClockMaster, DanteTxChannels: n.DanteTxChannels, + Missing: n.Missing, } }