Add broadcast packet tracking with rate monitoring

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ian Gulliver
2026-01-25 19:40:39 -08:00
parent b2ec349c51
commit bbd938b924
7 changed files with 438 additions and 105 deletions

View File

@@ -4,12 +4,152 @@ import (
"context" "context"
"log" "log"
"net" "net"
"sync"
"time" "time"
"github.com/google/gopacket"
"github.com/google/gopacket/pcap"
"golang.org/x/net/icmp" "golang.org/x/net/icmp"
"golang.org/x/net/ipv4" "golang.org/x/net/ipv4"
) )
type BroadcastSample struct {
Time time.Time
Packets uint64
Bytes uint64
}
type BroadcastStats struct {
mu sync.RWMutex
samples []BroadcastSample
totalPackets uint64
totalBytes uint64
windowSize time.Duration
lastNotify time.Time
notifyMinRate time.Duration
t *Tendrils
}
type BroadcastStatsResponse struct {
TotalPackets uint64 `json:"total_packets"`
TotalBytes uint64 `json:"total_bytes"`
PacketsPerS float64 `json:"packets_per_s"`
BytesPerS float64 `json:"bytes_per_s"`
WindowSecs float64 `json:"window_secs"`
}
func NewBroadcastStats(t *Tendrils) *BroadcastStats {
return &BroadcastStats{
samples: []BroadcastSample{},
windowSize: 60 * time.Second,
notifyMinRate: 1 * time.Second,
t: t,
}
}
func (b *BroadcastStats) Record(packets, bytes uint64) {
b.mu.Lock()
defer b.mu.Unlock()
now := time.Now()
b.samples = append(b.samples, BroadcastSample{
Time: now,
Packets: packets,
Bytes: bytes,
})
b.totalPackets += packets
b.totalBytes += bytes
cutoff := now.Add(-b.windowSize)
for len(b.samples) > 0 && b.samples[0].Time.Before(cutoff) {
b.samples = b.samples[1:]
}
if now.Sub(b.lastNotify) >= b.notifyMinRate {
b.lastNotify = now
b.t.NotifyUpdate()
}
}
func (b *BroadcastStats) GetStats() BroadcastStatsResponse {
b.mu.RLock()
defer b.mu.RUnlock()
now := time.Now()
cutoff := now.Add(-b.windowSize)
var windowPackets, windowBytes uint64
var oldestTime time.Time
for _, s := range b.samples {
if s.Time.After(cutoff) {
if oldestTime.IsZero() || s.Time.Before(oldestTime) {
oldestTime = s.Time
}
windowPackets += s.Packets
windowBytes += s.Bytes
}
}
var windowSecs float64
if !oldestTime.IsZero() {
windowSecs = now.Sub(oldestTime).Seconds()
}
if windowSecs < 1 {
windowSecs = 1
}
return BroadcastStatsResponse{
TotalPackets: b.totalPackets,
TotalBytes: b.totalBytes,
PacketsPerS: float64(windowPackets) / windowSecs,
BytesPerS: float64(windowBytes) / windowSecs,
WindowSecs: windowSecs,
}
}
func (t *Tendrils) listenBroadcast(ctx context.Context, iface net.Interface) {
handle, err := pcap.OpenLive(iface.Name, 65536, true, 5*time.Second)
if err != nil {
log.Printf("[ERROR] broadcast: failed to open interface %s: %v", iface.Name, err)
return
}
defer handle.Close()
if err := handle.SetBPFFilter("ether broadcast"); err != nil {
log.Printf("[ERROR] broadcast: failed to set BPF filter on %s: %v", iface.Name, err)
return
}
packetSource := gopacket.NewPacketSource(handle, handle.LinkType())
packets := packetSource.Packets()
for {
select {
case <-ctx.Done():
return
case packet, ok := <-packets:
if !ok {
return
}
t.handleBroadcastPacket(packet)
}
}
}
func (t *Tendrils) handleBroadcastPacket(packet gopacket.Packet) {
if t.broadcast == nil {
return
}
packetLen := uint64(len(packet.Data()))
t.broadcast.Record(1, packetLen)
if t.DebugBroadcast {
log.Printf("[broadcast] packet: %d bytes", packetLen)
}
}
func (t *Tendrils) pingBroadcast(ctx context.Context, iface net.Interface) { func (t *Tendrils) pingBroadcast(ctx context.Context, iface net.Interface) {
_, broadcast := getInterfaceIPv4(iface) _, broadcast := getInterfaceIPv4(iface)
if broadcast == nil { if broadcast == nil {

View File

@@ -31,6 +31,7 @@ func main() {
debugBMD := flag.Bool("debug-bmd", false, "debug Blackmagic discovery") debugBMD := flag.Bool("debug-bmd", false, "debug Blackmagic discovery")
debugShure := flag.Bool("debug-shure", false, "debug Shure discovery") debugShure := flag.Bool("debug-shure", false, "debug Shure discovery")
debugYamaha := flag.Bool("debug-yamaha", false, "debug Yamaha discovery") debugYamaha := flag.Bool("debug-yamaha", false, "debug Yamaha discovery")
debugBroadcast := flag.Bool("debug-broadcast", false, "debug broadcast traffic monitoring")
flag.Parse() flag.Parse()
t := tendrils.New() t := tendrils.New()
@@ -58,5 +59,6 @@ func main() {
t.DebugBMD = *debugBMD t.DebugBMD = *debugBMD
t.DebugShure = *debugShure t.DebugShure = *debugShure
t.DebugYamaha = *debugYamaha t.DebugYamaha = *debugYamaha
t.DebugBroadcast = *debugBroadcast
t.Run() t.Run()
} }

View File

@@ -206,14 +206,15 @@ func (e *ErrorTracker) GetUnreachableNodes() []string {
return nodes return nodes
} }
func (e *ErrorTracker) SetUnreachable(node *Node, ip string) { func (e *ErrorTracker) SetUnreachable(node *Node, ip string) bool {
changed := e.setUnreachableLocked(node, ip) changed, becameUnreachable := e.setUnreachableLocked(node, ip)
if changed { if changed {
e.t.NotifyUpdate() e.t.NotifyUpdate()
} }
return becameUnreachable
} }
func (e *ErrorTracker) setUnreachableLocked(node *Node, ip string) bool { func (e *ErrorTracker) setUnreachableLocked(node *Node, ip string) (changed bool, becameUnreachable bool) {
e.mu.Lock() e.mu.Lock()
defer e.mu.Unlock() defer e.mu.Unlock()
@@ -221,13 +222,14 @@ func (e *ErrorTracker) setUnreachableLocked(node *Node, ip string) bool {
wasUnreachable := e.unreachableNodes[node.TypeID] wasUnreachable := e.unreachableNodes[node.TypeID]
e.unreachableNodes[node.TypeID] = true e.unreachableNodes[node.TypeID] = true
becameUnreachable = !wasUnreachable
if e.suppressedUnreachable[key] { if e.suppressedUnreachable[key] {
return !wasUnreachable return becameUnreachable, becameUnreachable
} }
if _, exists := e.errors[key]; exists { if _, exists := e.errors[key]; exists {
return !wasUnreachable return becameUnreachable, becameUnreachable
} }
now := time.Now() now := time.Now()
@@ -241,17 +243,18 @@ func (e *ErrorTracker) setUnreachableLocked(node *Node, ip string) bool {
FirstSeen: now, FirstSeen: now,
LastUpdated: now, LastUpdated: now,
} }
return true return true, becameUnreachable
} }
func (e *ErrorTracker) ClearUnreachable(node *Node, ip string) { func (e *ErrorTracker) ClearUnreachable(node *Node, ip string) bool {
changed := e.clearUnreachableLocked(node, ip) changed, becameReachable := e.clearUnreachableLocked(node, ip)
if changed { if changed {
e.t.NotifyUpdate() e.t.NotifyUpdate()
} }
return becameReachable
} }
func (e *ErrorTracker) clearUnreachableLocked(node *Node, ip string) bool { func (e *ErrorTracker) clearUnreachableLocked(node *Node, ip string) (changed bool, becameReachable bool) {
e.mu.Lock() e.mu.Lock()
defer e.mu.Unlock() defer e.mu.Unlock()
@@ -261,10 +264,11 @@ func (e *ErrorTracker) clearUnreachableLocked(node *Node, ip string) bool {
wasUnreachable := e.unreachableNodes[node.TypeID] wasUnreachable := e.unreachableNodes[node.TypeID]
delete(e.unreachableNodes, node.TypeID) delete(e.unreachableNodes, node.TypeID)
becameReachable = wasUnreachable
if _, exists := e.errors[key]; exists { if _, exists := e.errors[key]; exists {
delete(e.errors, key) delete(e.errors, key)
return true return true, becameReachable
} }
return wasUnreachable return becameReachable, becameReachable
} }

View File

@@ -32,6 +32,7 @@ type StatusResponse struct {
DanteFlows []*DanteFlow `json:"dante_flows"` DanteFlows []*DanteFlow `json:"dante_flows"`
PortErrors []*PortError `json:"port_errors"` PortErrors []*PortError `json:"port_errors"`
UnreachableNodes []string `json:"unreachable_nodes"` UnreachableNodes []string `json:"unreachable_nodes"`
BroadcastStats *BroadcastStatsResponse `json:"broadcast_stats,omitempty"`
} }
func (t *Tendrils) startHTTPServer() { func (t *Tendrils) startHTTPServer() {
@@ -132,6 +133,11 @@ func (t *Tendrils) handleAPIConfig(w http.ResponseWriter, r *http.Request) {
} }
func (t *Tendrils) GetStatus() *StatusResponse { func (t *Tendrils) GetStatus() *StatusResponse {
var broadcastStats *BroadcastStatsResponse
if t.broadcast != nil {
stats := t.broadcast.GetStats()
broadcastStats = &stats
}
return &StatusResponse{ return &StatusResponse{
Nodes: t.getNodes(), Nodes: t.getNodes(),
Links: t.getLinks(), Links: t.getLinks(),
@@ -140,6 +146,7 @@ func (t *Tendrils) GetStatus() *StatusResponse {
DanteFlows: t.getDanteFlows(), DanteFlows: t.getDanteFlows(),
PortErrors: t.errors.GetErrors(), PortErrors: t.errors.GetErrors(),
UnreachableNodes: t.errors.GetUnreachableNodes(), UnreachableNodes: t.errors.GetUnreachableNodes(),
BroadcastStats: broadcastStats,
} }
} }

188
ping.go
View File

@@ -1,16 +1,143 @@
package tendrils package tendrils
import ( import (
"log"
"net" "net"
"sync"
"time" "time"
"golang.org/x/net/icmp" "golang.org/x/net/icmp"
"golang.org/x/net/ipv4" "golang.org/x/net/ipv4"
) )
type pendingPing struct {
ip string
response chan bool
}
type PingManager struct {
mu sync.Mutex
conn *icmp.PacketConn
pending map[uint16]*pendingPing
nextID uint16
minID uint16
}
func NewPingManager() *PingManager {
pm := &PingManager{
pending: map[uint16]*pendingPing{},
nextID: 1000,
minID: 1000,
}
conn, err := icmp.ListenPacket("ip4:icmp", "0.0.0.0")
if err != nil {
return pm
}
pm.conn = conn
go pm.readLoop()
return pm
}
func (pm *PingManager) readLoop() {
buf := make([]byte, 1500)
for {
n, peer, err := pm.conn.ReadFrom(buf)
if err != nil {
return
}
msg, err := icmp.ParseMessage(1, buf[:n])
if err != nil {
continue
}
if msg.Type != ipv4.ICMPTypeEchoReply {
continue
}
echo, ok := msg.Body.(*icmp.Echo)
if !ok {
continue
}
ipAddr, ok := peer.(*net.IPAddr)
if !ok {
continue
}
pm.mu.Lock()
id := uint16(echo.ID)
if p, exists := pm.pending[id]; exists {
if p.ip == ipAddr.IP.String() {
select {
case p.response <- true:
default:
log.Printf("[ping] late response from %s (channel full)", ipAddr.IP)
}
}
} else if id >= pm.minID {
log.Printf("[ping] late response from %s (id %d expired)", ipAddr.IP, echo.ID)
}
pm.mu.Unlock()
}
}
func (pm *PingManager) Ping(ipStr string, timeout time.Duration) bool {
if pm.conn == nil {
return false
}
pm.mu.Lock()
pm.nextID++
id := pm.nextID
p := &pendingPing{
ip: ipStr,
response: make(chan bool, 1),
}
pm.pending[id] = p
pm.mu.Unlock()
defer func() {
pm.mu.Lock()
delete(pm.pending, id)
pm.mu.Unlock()
}()
msg := icmp.Message{
Type: ipv4.ICMPTypeEcho,
Code: 0,
Body: &icmp.Echo{
ID: int(id),
Seq: 1,
Data: []byte("tendrils"),
},
}
msgBytes, err := msg.Marshal(nil)
if err != nil {
return false
}
ip := net.ParseIP(ipStr)
_, err = pm.conn.WriteTo(msgBytes, &net.IPAddr{IP: ip})
if err != nil {
return false
}
select {
case <-p.response:
return true
case <-time.After(timeout):
return false
}
}
func (t *Tendrils) pingNode(node *Node) { func (t *Tendrils) pingNode(node *Node) {
t.nodes.mu.RLock() t.nodes.mu.RLock()
var ips []string var ips []string
nodeName := node.DisplayName()
for _, iface := range node.Interfaces { for _, iface := range node.Interfaces {
for ipStr := range iface.IPs { for ipStr := range iface.IPs {
ip := net.ParseIP(ipStr) ip := net.ParseIP(ipStr)
@@ -26,63 +153,14 @@ func (t *Tendrils) pingNode(node *Node) {
} }
for _, ipStr := range ips { for _, ipStr := range ips {
reachable := t.pingIP(ipStr) reachable := t.ping.Ping(ipStr, 2*time.Second)
if reachable { if reachable {
t.errors.ClearUnreachable(node, ipStr) if t.errors.ClearUnreachable(node, ipStr) {
log.Printf("[ping] %s (%s) is now reachable", nodeName, ipStr)
}
} else { } else {
t.errors.SetUnreachable(node, ipStr) if t.errors.SetUnreachable(node, ipStr) {
} log.Printf("[ping] %s (%s) is now unreachable", nodeName, ipStr)
}
}
func (t *Tendrils) pingIP(ipStr string) bool {
conn, err := icmp.ListenPacket("ip4:icmp", "0.0.0.0")
if err != nil {
return false
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(500 * time.Millisecond))
ip := net.ParseIP(ipStr)
seq := uint16(time.Now().UnixNano() & 0xFFFF)
msg := icmp.Message{
Type: ipv4.ICMPTypeEcho,
Code: 0,
Body: &icmp.Echo{
ID: int(seq),
Seq: 1,
Data: []byte("tendrils"),
},
}
msgBytes, err := msg.Marshal(nil)
if err != nil {
return false
}
_, err = conn.WriteTo(msgBytes, &net.IPAddr{IP: ip})
if err != nil {
return false
}
buf := make([]byte, 1500)
for {
n, peer, err := conn.ReadFrom(buf)
if err != nil {
return false
}
parsed, err := icmp.ParseMessage(1, buf[:n])
if err != nil {
continue
}
if parsed.Type == ipv4.ICMPTypeEchoReply {
if ipAddr, ok := peer.(*net.IPAddr); ok {
if ipAddr.IP.String() == ipStr {
return true
}
} }
} }
} }

View File

@@ -310,12 +310,14 @@
.node.has-error { .node.has-error {
box-shadow: 0 0 0 3px #f66; box-shadow: 0 0 0 3px #f66;
animation: error-pulse 2s infinite;
} }
@keyframes error-pulse { .node.unreachable {
0%, 100% { box-shadow: 0 0 0 3px #f66; } box-shadow: 0 0 0 3px #f90;
50% { box-shadow: 0 0 0 3px #f00; } }
.node.has-error.unreachable {
box-shadow: 0 0 0 3px #f66, 0 0 0 6px #f90;
} }
#error-panel { #error-panel {
@@ -427,6 +429,48 @@
.node.scroll-highlight { .node.scroll-highlight {
outline: 3px solid white; outline: 3px solid white;
} }
#broadcast-stats {
position: fixed;
bottom: 10px;
left: 10px;
z-index: 1000;
padding: 8px 12px;
background: #222;
border-radius: 6px;
border: 1px solid #444;
font-size: 11px;
}
#broadcast-stats.warning {
border-color: #f90;
background: #332a1a;
}
#broadcast-stats.critical {
border-color: #f44;
background: #331a1a;
}
#broadcast-stats .label {
color: #888;
margin-right: 4px;
}
#broadcast-stats .value {
color: #eee;
font-weight: bold;
}
#broadcast-stats .rate-row {
display: flex;
gap: 12px;
}
#broadcast-stats .rate-item {
display: flex;
align-items: center;
}
</style> </style>
</head> </head>
<body> <body>
@@ -434,6 +478,17 @@
<div class="dot"></div> <div class="dot"></div>
<span class="text">Connecting...</span> <span class="text">Connecting...</span>
</div> </div>
<div id="broadcast-stats">
<div class="rate-row">
<div class="rate-item">
<span class="label">Broadcast:</span>
<span class="value" id="broadcast-pps">0 pps</span>
</div>
<div class="rate-item">
<span class="value" id="broadcast-bps">0 B/s</span>
</div>
</div>
</div>
<div id="mode-selector"> <div id="mode-selector">
<button id="mode-network" class="active">Network</button> <button id="mode-network" class="active">Network</button>
<button id="mode-dante">Dante</button> <button id="mode-dante">Dante</button>
@@ -450,6 +505,42 @@
<div id="container"></div> <div id="container"></div>
<script> <script>
function formatBytes(bytes) {
if (bytes < 1024) return bytes.toFixed(0) + ' B/s';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB/s';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB/s';
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB/s';
}
function formatPackets(pps) {
if (pps < 1000) return pps.toFixed(0) + ' pps';
if (pps < 1000000) return (pps / 1000).toFixed(1) + 'K pps';
return (pps / 1000000).toFixed(1) + 'M pps';
}
function updateBroadcastStats(stats) {
const panel = document.getElementById('broadcast-stats');
const ppsEl = document.getElementById('broadcast-pps');
const bpsEl = document.getElementById('broadcast-bps');
if (!stats) {
ppsEl.textContent = '0 pps';
bpsEl.textContent = '0 B/s';
panel.className = '';
return;
}
ppsEl.textContent = formatPackets(stats.packets_per_s);
bpsEl.textContent = formatBytes(stats.bytes_per_s);
panel.classList.remove('warning', 'critical');
if (stats.packets_per_s > 1000) {
panel.classList.add('critical');
} else if (stats.packets_per_s > 100) {
panel.classList.add('warning');
}
}
function getLabel(node) { function getLabel(node) {
if (node.names && node.names.length > 0) return node.names.join('\n'); if (node.names && node.names.length > 0) return node.names.join('\n');
if (node.interfaces && node.interfaces.length > 0) { if (node.interfaces && node.interfaces.length > 0) {
@@ -573,11 +664,12 @@
return null; return null;
} }
function createNodeElement(node, switchConnection, nodeLocation, uplinkInfo, danteInfo, hasError) { function createNodeElement(node, switchConnection, nodeLocation, uplinkInfo, danteInfo, hasError, isUnreachable) {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'node' + (isSwitch(node) ? ' switch' : ''); div.className = 'node' + (isSwitch(node) ? ' switch' : '');
div.dataset.typeid = node.typeid; div.dataset.typeid = node.typeid;
if (hasError) div.classList.add('has-error'); if (hasError) div.classList.add('has-error');
if (isUnreachable) div.classList.add('unreachable');
if (danteInfo) { if (danteInfo) {
if (danteInfo.isTx) div.classList.add('dante-tx'); if (danteInfo.isTx) div.classList.add('dante-tx');
@@ -636,12 +728,12 @@
return div; return div;
} }
function renderLocation(loc, assignedNodes, isTopLevel, switchConnections, switchUplinks, danteNodes, errorNodeIds) { function renderLocation(loc, assignedNodes, isTopLevel, switchConnections, switchUplinks, danteNodes, errorNodeIds, unreachableNodeIds) {
const nodes = assignedNodes.get(loc) || []; const nodes = assignedNodes.get(loc) || [];
const hasNodes = nodes.length > 0; const hasNodes = nodes.length > 0;
const childElements = loc.children const childElements = loc.children
.map(child => renderLocation(child, assignedNodes, false, switchConnections, switchUplinks, danteNodes, errorNodeIds)) .map(child => renderLocation(child, assignedNodes, false, switchConnections, switchUplinks, danteNodes, errorNodeIds, unreachableNodeIds))
.filter(el => el !== null); .filter(el => el !== null);
if (!hasNodes && childElements.length === 0) { if (!hasNodes && childElements.length === 0) {
@@ -670,7 +762,8 @@
const uplink = switchUplinks.get(node.typeid); const uplink = switchUplinks.get(node.typeid);
const danteInfo = danteNodes.get(node.typeid); const danteInfo = danteNodes.get(node.typeid);
const hasError = errorNodeIds.has(node.typeid); const hasError = errorNodeIds.has(node.typeid);
switchRow.appendChild(createNodeElement(node, null, loc, uplink, danteInfo, hasError)); const isUnreachable = unreachableNodeIds.has(node.typeid);
switchRow.appendChild(createNodeElement(node, null, loc, uplink, danteInfo, hasError, isUnreachable));
}); });
container.appendChild(switchRow); container.appendChild(switchRow);
} }
@@ -682,7 +775,8 @@
const conn = switchConnections.get(node.typeid); const conn = switchConnections.get(node.typeid);
const danteInfo = danteNodes.get(node.typeid); const danteInfo = danteNodes.get(node.typeid);
const hasError = errorNodeIds.has(node.typeid); const hasError = errorNodeIds.has(node.typeid);
nodeRow.appendChild(createNodeElement(node, conn, loc, null, danteInfo, hasError)); const isUnreachable = unreachableNodeIds.has(node.typeid);
nodeRow.appendChild(createNodeElement(node, conn, loc, null, danteInfo, hasError, isUnreachable));
}); });
container.appendChild(nodeRow); container.appendChild(nodeRow);
} }
@@ -822,9 +916,8 @@
const links = data.links || []; const links = data.links || [];
portErrors = data.port_errors || []; portErrors = data.port_errors || [];
const unreachableNodes = new Set(data.unreachable_nodes || []); const unreachableNodeIds = new Set(data.unreachable_nodes || []);
const errorNodeIds = new Set(portErrors.map(e => e.node_typeid)); const errorNodeIds = new Set(portErrors.filter(e => e.error_type !== 'unreachable').map(e => e.node_typeid));
unreachableNodes.forEach(id => errorNodeIds.add(id));
const locationTree = buildLocationTree(config.locations || [], null); const locationTree = buildLocationTree(config.locations || [], null);
@@ -1011,7 +1104,7 @@
container.innerHTML = ''; container.innerHTML = '';
locationTree.forEach(loc => { locationTree.forEach(loc => {
const el = renderLocation(loc, assignedNodes, true, switchConnections, switchUplinks, danteNodes, errorNodeIds); const el = renderLocation(loc, assignedNodes, true, switchConnections, switchUplinks, danteNodes, errorNodeIds, unreachableNodeIds);
if (el) container.appendChild(el); if (el) container.appendChild(el);
}); });
@@ -1034,7 +1127,8 @@
const uplink = switchUplinks.get(node.typeid); const uplink = switchUplinks.get(node.typeid);
const danteInfo = danteNodes.get(node.typeid); const danteInfo = danteNodes.get(node.typeid);
const hasError = errorNodeIds.has(node.typeid); const hasError = errorNodeIds.has(node.typeid);
switchRow.appendChild(createNodeElement(node, null, null, uplink, danteInfo, hasError)); const isUnreachable = unreachableNodeIds.has(node.typeid);
switchRow.appendChild(createNodeElement(node, null, null, uplink, danteInfo, hasError, isUnreachable));
}); });
unassignedLoc.appendChild(switchRow); unassignedLoc.appendChild(switchRow);
} }
@@ -1046,7 +1140,8 @@
const conn = switchConnections.get(node.typeid); const conn = switchConnections.get(node.typeid);
const danteInfo = danteNodes.get(node.typeid); const danteInfo = danteNodes.get(node.typeid);
const hasError = errorNodeIds.has(node.typeid); const hasError = errorNodeIds.has(node.typeid);
nodeRow.appendChild(createNodeElement(node, conn, null, null, danteInfo, hasError)); const isUnreachable = unreachableNodeIds.has(node.typeid);
nodeRow.appendChild(createNodeElement(node, conn, null, null, danteInfo, hasError, isUnreachable));
}); });
unassignedLoc.appendChild(nodeRow); unassignedLoc.appendChild(nodeRow);
} }
@@ -1055,6 +1150,7 @@
} }
updateErrorPanel(); updateErrorPanel();
updateBroadcastStats(data.broadcast_stats);
} }
connectSSE(); connectSSE();

View File

@@ -36,6 +36,8 @@ type Tendrils struct {
artnet *ArtNetNodes artnet *ArtNetNodes
danteFlows *DanteFlows danteFlows *DanteFlows
errors *ErrorTracker errors *ErrorTracker
ping *PingManager
broadcast *BroadcastStats
config *Config config *Config
sseSubsMu sync.RWMutex sseSubsMu sync.RWMutex
@@ -66,6 +68,7 @@ type Tendrils struct {
DebugBMD bool DebugBMD bool
DebugShure bool DebugShure bool
DebugYamaha bool DebugYamaha bool
DebugBroadcast bool
} }
func New() *Tendrils { func New() *Tendrils {
@@ -73,10 +76,12 @@ func New() *Tendrils {
activeInterfaces: map[string]context.CancelFunc{}, activeInterfaces: map[string]context.CancelFunc{},
artnet: NewArtNetNodes(), artnet: NewArtNetNodes(),
danteFlows: NewDanteFlows(), danteFlows: NewDanteFlows(),
ping: NewPingManager(),
sseSubs: map[int]chan struct{}{}, sseSubs: map[int]chan struct{}{},
} }
t.nodes = NewNodes(t) t.nodes = NewNodes(t)
t.errors = NewErrorTracker(t) t.errors = NewErrorTracker(t)
t.broadcast = NewBroadcastStats(t)
return t return t
} }
@@ -277,6 +282,7 @@ func (t *Tendrils) updateInterfaces(interfaces []net.Interface) {
func (t *Tendrils) startInterface(ctx context.Context, iface net.Interface) { func (t *Tendrils) startInterface(ctx context.Context, iface net.Interface) {
go t.pingBroadcast(ctx, iface) go t.pingBroadcast(ctx, iface)
go t.listenBroadcast(ctx, iface)
if !t.DisableLLDP { if !t.DisableLLDP {
go t.listenLLDP(ctx, iface) go t.listenLLDP(ctx, iface)