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 (
"encoding/json"
"net"
"os"
"gopkg.in/yaml.v3"
@@ -14,10 +15,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"`
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) {
if path == "" {
return &Config{}, nil

View File

@@ -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

View File

@@ -13,6 +13,7 @@ const (
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
}

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)
if node.Missing && source != "config" {
node.Missing = false
n.t.errors.ClearMissing(node)
}
n.logUpdates(node, added, isNew, source)
if hasNewIP(added) {

View File

@@ -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: []
};

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() {
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 {

View File

@@ -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,
}
}