Add missing node tracking for config-defined nodes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ian Gulliver
2026-01-28 08:38:58 -08:00
parent 6ae561b968
commit 9ec193ef1d
7 changed files with 254 additions and 104 deletions

View File

@@ -2,6 +2,7 @@ package tendrils
import ( import (
"encoding/json" "encoding/json"
"net"
"os" "os"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@@ -14,10 +15,54 @@ type Config struct {
type Location struct { type Location struct {
Name string `yaml:"name" json:"name"` Name string `yaml:"name" json:"name"`
Direction string `yaml:"direction,omitempty" json:"direction,omitempty"` Direction string `yaml:"direction,omitempty" json:"direction,omitempty"`
Nodes []string `yaml:"nodes,omitempty" json:"nodes,omitempty"` Nodes []*NodeRef `yaml:"nodes,omitempty" json:"nodes,omitempty"`
Children []*Location `yaml:"children,omitempty" json:"children,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) { func LoadConfig(path string) (*Config, error) {
if path == "" { if path == "" {
return &Config{}, nil return &Config{}, nil

View File

@@ -4,168 +4,168 @@ locations:
children: children:
- name: LIGHTING-2 Rack - name: LIGHTING-2 Rack
nodes: nodes:
- lighting-2 - name: lighting-2
- "48:59:00:41:00:29" # Pixie Driver 8k Port 1 - mac: "48:59:00:41:00:29" # Pixie Driver 8k Port 1
- "48:59:00:28:00:27" # Pixie Driver 8k Port 2 - mac: "48:59:00:28:00:27" # Pixie Driver 8k Port 2
- "48:59:00:43:00:29" # Pixie Driver 8k Port 3 - mac: "48:59:00:43:00:29" # Pixie Driver 8k Port 3
- "48:59:00:42:00:29" # Pixie Driver 8k Port 4 - mac: "48:59:00:42:00:29" # Pixie Driver 8k Port 4
- "48:59:00:3c:00:3e" # Pixie Driver 8k Port 5 - mac: "48:59:00:3c:00:3e" # Pixie Driver 8k Port 5
- "48:59:00:3f:00:3e" # Pixie Driver 8k Port 6 - mac: "48:59:00:3f:00:3e" # Pixie Driver 8k Port 6
- "48:59:00:25:00:3e" # Pixie Driver 8k Port 7 - mac: "48:59:00:25:00:3e" # Pixie Driver 8k Port 7
- "48:59:00:41:00:3e" # Pixie Driver 8k Port 8 - mac: "48:59:00:41:00:3e" # Pixie Driver 8k Port 8
- ART9 # Cyc - name: ART9 # Cyc
- ART10 # Cyc - name: ART10 # Cyc
- ART11 # Cyc - name: ART11 # Cyc
- ART12 # Cyc - name: ART12 # Cyc
- ART13 # Cyc - name: ART13 # Cyc
- ART14 # Cyc - name: ART14 # Cyc
- direction: horizontal - direction: horizontal
children: children:
- nodes: - nodes:
- ART16 # R2X1 - name: ART16 # R2X1
- nodes: - nodes:
- ART20 # R2X2 - name: ART20 # R2X2
- direction: horizontal - direction: horizontal
children: children:
- nodes: - nodes:
- "MON1-A" - name: MON1-A
- "MON1-B" - name: MON1-B
- name: Under Apron - name: Under Apron
direction: horizontal direction: horizontal
children: children:
- name: AUDIO Rack - name: AUDIO Rack
nodes: nodes:
- audio - name: audio
- "MICS-A" - name: MICS-A
- "00:0e:dd:a7:29:93" # MICS-A bridge interface - mac: "00:0e:dd:a7:29:93" # MICS-A bridge interface
- "MICS-B" - name: MICS-B
- "00:0e:dd:a8:3e:b3" # MICS-B bridge interface - mac: "00:0e:dd:a8:3e:b3" # MICS-B bridge interface
- "MICS-C" - name: MICS-C
- "00:0e:dd:a7:6f:55" # MICS-C bridge interface - mac: "00:0e:dd:a7:6f:55" # MICS-C bridge interface
- "MICS-D" - name: MICS-D
- "00:0e:dd:64:3d:51" # MICS-D bridge interface - mac: "00:0e:dd:64:3d:51" # MICS-D bridge interface
- "MICS-E" - name: MICS-E
- "00:0e:dd:ac:fc:7d" # MICS-E bridge interface - mac: "00:0e:dd:ac:fc:7d" # MICS-E bridge interface
- name: LIGHTING-1 Rack - name: LIGHTING-1 Rack
nodes: nodes:
- lighting-1 - name: lighting-1
- "48:59:00:27:00:27" # Pixie Driver 8k Port 1 - mac: "48:59:00:27:00:27" # Pixie Driver 8k Port 1
- "48:59:00:37:00:27" # Pixie Driver 8k Port 2 - mac: "48:59:00:37:00:27" # Pixie Driver 8k Port 2
- "48:59:00:3e:00:27" # Pixie Driver 8k Port 3 - mac: "48:59:00:3e:00:27" # Pixie Driver 8k Port 3
- "48:59:00:3f:00:27" # Pixie Driver 8k Port 4 - mac: "48:59:00:3f:00:27" # Pixie Driver 8k Port 4
- "48:59:00:47:00:1a" # Pixie Driver 8k Port 5 - mac: "48:59:00:47:00:1a" # Pixie Driver 8k Port 5
- "48:59:00:44:00:1a" # Pixie Driver 8k Port 6 - mac: "48:59:00:44:00:1a" # Pixie Driver 8k Port 6
- "48:59:00:42:00:19" # Pixie Driver 8k Port 7 - mac: "48:59:00:42:00:19" # Pixie Driver 8k Port 7
- "48:59:00:44:00:19" # Pixie Driver 8k Port 8 - mac: "48:59:00:44:00:19" # Pixie Driver 8k Port 8
- name: VIDEO Rack - name: VIDEO Rack
nodes: nodes:
- video - name: video
- "ATEM 2 M/E Constellation 4K" - name: "ATEM 2 M/E Constellation 4K"
- "HyperDeck-Studio-4K-Pro" - name: HyperDeck-Studio-4K-Pro
- RX-QLAB-1 - name: RX-QLAB-1
- RX-QLAB-2 - name: RX-QLAB-2
- TX-PROJ-1 - name: TX-PROJ-1
- TX-PROJ-2 - name: TX-PROJ-2
- TX-M4 - name: TX-M4
- TX-M16 - name: TX-M16
- TX-MISC - name: TX-MISC
- TX-PREVIEW - name: TX-PREVIEW
- direction: horizontal - direction: horizontal
children: children:
- nodes: - nodes:
- "Y001-MAIN1-L-d1e155" - name: Y001-MAIN1-L-d1e155
- "ac:44:f2:4e:84:d6" # MAIN1-L bridge interface - mac: "ac:44:f2:4e:84:d6" # MAIN1-L bridge interface
- name: Stage 2 - name: Stage 2
nodes: nodes:
- "MON2" - name: MON2
- name: Stage 3 - name: Stage 3
nodes: nodes:
- "MON3" - name: MON3
- nodes: - nodes:
- "Y001-MAIN1-R-d1e194" - name: Y001-MAIN1-R-d1e194
- "ac:44:f2:4e:84:d4" # MAIN1-R bridge interface - mac: "ac:44:f2:4e:84:d4" # MAIN1-R bridge interface
- direction: horizontal - direction: horizontal
children: children:
- nodes: - nodes:
- "RX-PROJ-1" - name: RX-PROJ-1
- nodes: - nodes:
- "RX-PROJ-2" - name: RX-PROJ-2
- direction: horizontal - direction: horizontal
children: children:
- nodes: - nodes:
- satellite-2 - name: satellite-2
- "Y001-MAIN2-L-d1e298" - name: Y001-MAIN2-L-d1e298
- "ac:44:f2:4e:87:2a" # MAIN2-L bridge interface - mac: "ac:44:f2:4e:87:2a" # MAIN2-L bridge interface
- ART3 # Wash - name: ART3 # Wash
- ART4 # Wash - name: ART4 # Wash
- ART5 # Wash - name: ART5 # Wash
- ART17 # Focus - name: ART17 # Focus
- nodes: - nodes:
- ART15 # R3X - name: ART15 # R3X
- ART18 # Focus - name: ART18 # Focus
- nodes: - nodes:
- satellite-3 - name: satellite-3
- "Y001-MAIN2-R-f0dd93" - name: Y001-MAIN2-R-f0dd93
- "ac:44:f2:4e:87:27" # MAIN2-R bridge interface - mac: "ac:44:f2:4e:87:27" # MAIN2-R bridge interface
- ART6 # Wash - name: ART6 # Wash
- ART7 # Wash - name: ART7 # Wash
- DMX8 # Wash - name: DMX32 # Wash
- ART19 # Focus - name: ART19 # Focus
- name: Booth - name: Booth
direction: vertical direction: vertical
children: children:
- nodes: - nodes:
- satellite-1 - name: satellite-1
- direction: horizontal - direction: horizontal
children: children:
- name: Lighting Control - name: Lighting Control
nodes: nodes:
- qlab - name: qlab
- TX-QLAB-1 - name: TX-QLAB-1
- TX-QLAB-2 - name: TX-QLAB-2
- "SK_PTZEXTREMEV2 [457081]" - name: "SK_PTZEXTREMEV2 [457081]"
- "SK_RACKPRO2 [452514]" - name: "SK_RACKPRO2 [452514]"
- pigeon - name: pigeon
- showpi1 - name: showpi1
- d8:3a:dd:e3:5b:db # showpi2/artmap - mac: "d8:3a:dd:e3:5b:db" # showpi2/artmap
- name: Sound Control - name: Sound Control
nodes: nodes:
- SQ-7 - name: SQ-7
- "00:04:c4:15:07:a4" # SQ-7 bridge port - mac: "00:04:c4:15:07:a4" # SQ-7 bridge port
- BT - name: BT
- name: Camera Control - name: Camera Control
nodes: nodes:
- RX-CC-PREVIEW - name: RX-CC-PREVIEW
- RX-CC-M16 - name: RX-CC-M16
- "AtemPanel-7c2e0da86d22" - name: AtemPanel-7c2e0da86d22
- "AtemPanel-7c2e0da86d4c" - name: AtemPanel-7c2e0da86d4c
- name: Video Control - name: Video Control
nodes: nodes:
- RX-VC-M4 - name: RX-VC-M4
- RX-VC-M16 - name: RX-VC-M16
- "ATEM-2-ME-Advanced-Panel-20" - name: ATEM-2-ME-Advanced-Panel-20
- name: Control - name: Control
nodes: nodes:
- sunset - name: sunset
- RX-CONTROL-1 - name: RX-CONTROL-1

View File

@@ -13,6 +13,7 @@ const (
ErrorTypeNew PortErrorType = "new" ErrorTypeNew PortErrorType = "new"
ErrorTypeUnreachable PortErrorType = "unreachable" ErrorTypeUnreachable PortErrorType = "unreachable"
ErrorTypeHighUtilization PortErrorType = "high_utilization" ErrorTypeHighUtilization PortErrorType = "high_utilization"
ErrorTypeMissing PortErrorType = "missing"
) )
type PortError struct { type PortError struct {
@@ -327,3 +328,53 @@ func (e *ErrorTracker) clearUnreachableLocked(node *Node) (changed bool, becameR
} }
return becameReachable, becameReachable 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
}

View File

@@ -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) 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) n.logUpdates(node, added, isNew, source)
if hasNewIP(added) { if hasNewIP(added) {

View File

@@ -991,6 +991,9 @@
if (node.interfaces) { if (node.interfaces) {
node.interfaces.forEach(iface => { node.interfaces.forEach(iface => {
if (iface.mac) ids.push(iface.mac.toLowerCase()); if (iface.mac) ids.push(iface.mac.toLowerCase());
if (iface.ips) {
iface.ips.forEach(ip => ids.push(ip.toLowerCase()));
}
}); });
} }
return ids; return ids;
@@ -1147,7 +1150,7 @@
name: loc.name || '', name: loc.name || '',
anonymous: anonymous, anonymous: anonymous,
direction: loc.direction || 'horizontal', 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, parent: parent,
children: [] children: []
}; };

View File

@@ -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() { func (t *Tendrils) Run() {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
@@ -141,6 +183,7 @@ func (t *Tendrils) Run() {
continue continue
} }
t.config = cfg t.config = cfg
t.syncConfigNodes()
log.Printf("reloaded config from %s", t.ConfigFile) log.Printf("reloaded config from %s", t.ConfigFile)
t.NotifyUpdate() t.NotifyUpdate()
} }
@@ -153,6 +196,7 @@ func (t *Tendrils) Run() {
t.config = cfg t.config = cfg
t.populateLocalAddresses() t.populateLocalAddresses()
t.syncConfigNodes()
t.startHTTPServer() t.startHTTPServer()
if !t.DisableARP { if !t.DisableARP {

View File

@@ -140,6 +140,7 @@ type Node struct {
PoEBudget *PoEBudget `json:"poe_budget,omitempty"` PoEBudget *PoEBudget `json:"poe_budget,omitempty"`
IsDanteClockMaster bool `json:"is_dante_clock_master,omitempty"` IsDanteClockMaster bool `json:"is_dante_clock_master,omitempty"`
DanteTxChannels string `json:"dante_tx_channels,omitempty"` DanteTxChannels string `json:"dante_tx_channels,omitempty"`
Missing bool `json:"missing,omitempty"`
pollTrigger chan struct{} pollTrigger chan struct{}
} }
@@ -159,6 +160,7 @@ func (n *Node) WithInterface(ifaceKey string) *Node {
PoEBudget: n.PoEBudget, PoEBudget: n.PoEBudget,
IsDanteClockMaster: n.IsDanteClockMaster, IsDanteClockMaster: n.IsDanteClockMaster,
DanteTxChannels: n.DanteTxChannels, DanteTxChannels: n.DanteTxChannels,
Missing: n.Missing,
} }
} }