diff --git a/errors.go b/errors.go index e159164..3face41 100644 --- a/errors.go +++ b/errors.go @@ -27,7 +27,8 @@ type Error struct { InDelta uint64 `json:"in_delta,omitempty"` OutDelta uint64 `json:"out_delta,omitempty"` Utilization float64 `json:"utilization,omitempty"` - FirstSeen time.Time `json:"first_seen,omitempty"` + FirstSeen time.Time `json:"first_seen"` + LastSeen time.Time `json:"last_seen"` LastUpdated time.Time `json:"last_updated,omitempty"` } @@ -50,19 +51,22 @@ func (e *ErrorTracker) AddUnreachable(node *Node) { defer e.mu.Unlock() key := "unreachable:" + node.ID - if _, exists := e.errors[key]; exists { + now := time.Now().UTC() + + if existing, exists := e.errors[key]; exists { + existing.LastSeen = now + e.t.NotifyUpdate() return } - now := time.Now() e.nextID++ e.errors[key] = &Error{ - ID: fmt.Sprintf("err-%d", e.nextID), - NodeID: node.ID, - NodeName: node.DisplayName(), - Type: ErrorTypeUnreachable, - FirstSeen: now, - LastUpdated: now, + ID: fmt.Sprintf("err-%d", e.nextID), + NodeID: node.ID, + NodeName: node.DisplayName(), + Type: ErrorTypeUnreachable, + FirstSeen: now, + LastSeen: now, } e.t.NotifyUpdate() } @@ -83,13 +87,14 @@ func (e *ErrorTracker) AddPortError(node *Node, portName string, stats *Interfac defer e.mu.Unlock() key := node.ID + ":" + portName - now := time.Now() + now := time.Now().UTC() if existing, ok := e.errors[key]; ok { existing.InErrors = stats.InErrors existing.OutErrors = stats.OutErrors existing.InDelta += inDelta existing.OutDelta += outDelta + existing.LastSeen = now existing.LastUpdated = now } else { e.nextID++ @@ -104,6 +109,7 @@ func (e *ErrorTracker) AddPortError(node *Node, portName string, stats *Interfac InDelta: inDelta, OutDelta: outDelta, FirstSeen: now, + LastSeen: now, LastUpdated: now, } log.Printf("[ERROR] port errors on %s %s: in=%d out=%d", node.DisplayName(), portName, inDelta, outDelta) @@ -116,14 +122,15 @@ func (e *ErrorTracker) AddUtilizationError(node *Node, portName string, utilizat defer e.mu.Unlock() key := "util:" + node.ID + ":" + portName - now := time.Now() + now := time.Now().UTC() if existing, ok := e.errors[key]; ok { + existing.LastSeen = now if utilization > existing.Utilization { existing.Utilization = utilization existing.LastUpdated = now - e.t.NotifyUpdate() } + e.t.NotifyUpdate() return } @@ -136,20 +143,38 @@ func (e *ErrorTracker) AddUtilizationError(node *Node, portName string, utilizat Type: ErrorTypeHighUtilization, Utilization: utilization, FirstSeen: now, + LastSeen: now, LastUpdated: now, } log.Printf("[ERROR] high utilization on %s %s: %.0f%%", node.DisplayName(), portName, utilization) e.t.NotifyUpdate() } +func (e *ErrorTracker) UpdateUtilizationLastSeen(node *Node, portName string, utilization float64) { + e.mu.Lock() + defer e.mu.Unlock() + + key := "util:" + node.ID + ":" + portName + if existing, ok := e.errors[key]; ok { + now := time.Now().UTC() + existing.LastSeen = now + if utilization > existing.Utilization { + existing.Utilization = utilization + existing.LastUpdated = now + } + e.t.NotifyUpdate() + } +} + func (e *ErrorTracker) AddPortFlap(node *Node, portName string) { e.mu.Lock() defer e.mu.Unlock() key := "flap:" + node.ID + ":" + portName - now := time.Now() + now := time.Now().UTC() if existing, ok := e.errors[key]; ok { + existing.LastSeen = now existing.LastUpdated = now e.t.NotifyUpdate() return @@ -157,13 +182,13 @@ func (e *ErrorTracker) AddPortFlap(node *Node, portName string) { e.nextID++ e.errors[key] = &Error{ - ID: fmt.Sprintf("err-%d", e.nextID), - NodeID: node.ID, - NodeName: node.DisplayName(), - Port: portName, - Type: ErrorTypePortFlap, - FirstSeen: now, - LastUpdated: now, + ID: fmt.Sprintf("err-%d", e.nextID), + NodeID: node.ID, + NodeName: node.DisplayName(), + Port: portName, + Type: ErrorTypePortFlap, + FirstSeen: now, + LastSeen: now, } e.t.NotifyUpdate() } @@ -173,9 +198,10 @@ func (e *ErrorTracker) AddPortDown(node *Node, portName string) { defer e.mu.Unlock() key := "down:" + node.ID + ":" + portName - now := time.Now() + now := time.Now().UTC() if existing, ok := e.errors[key]; ok { + existing.LastSeen = now existing.LastUpdated = now e.t.NotifyUpdate() return @@ -183,13 +209,13 @@ func (e *ErrorTracker) AddPortDown(node *Node, portName string) { e.nextID++ e.errors[key] = &Error{ - ID: fmt.Sprintf("err-%d", e.nextID), - NodeID: node.ID, - NodeName: node.DisplayName(), - Port: portName, - Type: ErrorTypePortDown, - FirstSeen: now, - LastUpdated: now, + ID: fmt.Sprintf("err-%d", e.nextID), + NodeID: node.ID, + NodeName: node.DisplayName(), + Port: portName, + Type: ErrorTypePortDown, + FirstSeen: now, + LastSeen: now, } e.t.NotifyUpdate() } diff --git a/static/js/components.js b/static/js/components.js index aa48a47..43bda6d 100644 --- a/static/js/components.js +++ b/static/js/components.js @@ -54,7 +54,8 @@ export function createNodeElement(node, switchConnection, nodeLocation, uplinkIn const errOut = switchConnection.errors?.out || 0; const r = switchConnection.rates; buildLinkStats(statsEl, portLabel, switchConnection.speed, errIn, errOut, - r ? {rxBytes: r.outBytes, rxPkts: r.outPkts, txBytes: r.inBytes, txPkts: r.inPkts} : null); + r ? {rxBytes: r.outBytes, rxPkts: r.outPkts, txBytes: r.inBytes, txPkts: r.inPkts} : null, + switchConnection.uptime, switchConnection.lastError); } else { const container = div.querySelector(':scope > .port-hover'); if (container) container.remove(); @@ -163,7 +164,8 @@ export function createNodeElement(node, switchConnection, nodeLocation, uplinkIn const errOut = uplinkInfo.errors?.out || 0; const r = uplinkInfo.rates; buildLinkStats(statsEl, uplinkLabel, uplinkInfo.speed, errIn, errOut, - r ? {rxBytes: r.inBytes, rxPkts: r.inPkts, txBytes: r.outBytes, txPkts: r.outPkts} : null); + r ? {rxBytes: r.inBytes, rxPkts: r.inPkts, txBytes: r.outBytes, txPkts: r.outPkts} : null, + uplinkInfo.uptime, uplinkInfo.lastError); } else { const rootEl = div.querySelector(':scope > .root-label'); if (rootEl) rootEl.remove(); diff --git a/static/js/nodes.js b/static/js/nodes.js index 6ef9ee9..90d8ec6 100644 --- a/static/js/nodes.js +++ b/static/js/nodes.js @@ -99,3 +99,13 @@ export function getInterfaceRates(node, ifaceName) { outBytes: iface.stats.out_bytes_rate || 0 }; } + +export function getInterfaceUptime(node, ifaceName) { + const iface = findInterface(node, ifaceName); + return iface?.stats?.uptime || 0; +} + +export function getInterfaceLastError(node, ifaceName) { + const iface = findInterface(node, ifaceName); + return iface?.stats?.last_error || null; +} diff --git a/static/js/render.js b/static/js/render.js index d4a7f14..44c3f3d 100644 --- a/static/js/render.js +++ b/static/js/render.js @@ -1,4 +1,4 @@ -import { getLabel, getShortLabel, isSwitch, getInterfaceSpeed, getInterfaceErrors, getInterfaceRates, getFirstName } from './nodes.js'; +import { getLabel, getShortLabel, isSwitch, getInterfaceSpeed, getInterfaceErrors, getInterfaceRates, getInterfaceUptime, getInterfaceLastError, getFirstName } from './nodes.js'; import { buildSwitchUplinks, buildLocationTree, buildNodeIndex, findLocationForNode, findEffectiveSwitch } from './topology.js'; import { formatUniverse } from './format.js'; import { createNodeElement, renderLocation } from './components.js'; @@ -71,7 +71,11 @@ export function render(data, config) { errorsA: getInterfaceErrors(nodeA, link.interface_a), errorsB: getInterfaceErrors(nodeB, link.interface_b), ratesA: getInterfaceRates(nodeA, link.interface_a), - ratesB: getInterfaceRates(nodeB, link.interface_b) + ratesB: getInterfaceRates(nodeB, link.interface_b), + uptimeA: getInterfaceUptime(nodeA, link.interface_a), + uptimeB: getInterfaceUptime(nodeB, link.interface_b), + lastErrorA: getInterfaceLastError(nodeA, link.interface_a), + lastErrorB: getInterfaceLastError(nodeB, link.interface_b) }); } else if (aIsSwitch && !bIsSwitch) { const nodeLoc = nodeLocations.get(nodeB.id); @@ -84,7 +88,9 @@ export function render(data, config) { external: effectiveSwitch && !isLocalSwitch, speed: getInterfaceSpeed(nodeA, link.interface_a), errors: getInterfaceErrors(nodeA, link.interface_a), - rates: getInterfaceRates(nodeA, link.interface_a) + rates: getInterfaceRates(nodeA, link.interface_a), + uptime: getInterfaceUptime(nodeA, link.interface_a), + lastError: getInterfaceLastError(nodeA, link.interface_a) }); } else if (bIsSwitch && !aIsSwitch) { const nodeLoc = nodeLocations.get(nodeA.id); @@ -97,7 +103,9 @@ export function render(data, config) { external: effectiveSwitch && !isLocalSwitch, speed: getInterfaceSpeed(nodeB, link.interface_b), errors: getInterfaceErrors(nodeB, link.interface_b), - rates: getInterfaceRates(nodeB, link.interface_b) + rates: getInterfaceRates(nodeB, link.interface_b), + uptime: getInterfaceUptime(nodeB, link.interface_b), + lastError: getInterfaceLastError(nodeB, link.interface_b) }); } }); diff --git a/static/js/table.js b/static/js/table.js index 008c380..0d2360c 100644 --- a/static/js/table.js +++ b/static/js/table.js @@ -1,4 +1,4 @@ -import { getLabel, getFirstName, isSwitch, getInterfaceSpeed, getInterfaceErrors, getInterfaceRates } from './nodes.js'; +import { getLabel, getFirstName, isSwitch, getInterfaceSpeed, getInterfaceErrors, getInterfaceRates, getInterfaceUptime, getInterfaceLastError } from './nodes.js'; import { buildSwitchUplinks } from './topology.js'; import { escapeHtml, formatUniverse } from './format.js'; import { tableData, tableSortKeys, setTableSortKeys } from './state.js'; @@ -107,6 +107,8 @@ export function renderNetworkTable() { speed: getInterfaceSpeed(nodeA, link.interface_a), errors: getInterfaceErrors(nodeA, link.interface_a), rates: getInterfaceRates(nodeA, link.interface_a), + uptime: getInterfaceUptime(nodeA, link.interface_a), + lastError: getInterfaceLastError(nodeA, link.interface_a), isLocalPort: false }); } else if (bIsSwitch && !aIsSwitch) { @@ -116,6 +118,8 @@ export function renderNetworkTable() { speed: getInterfaceSpeed(nodeB, link.interface_b), errors: getInterfaceErrors(nodeB, link.interface_b), rates: getInterfaceRates(nodeB, link.interface_b), + uptime: getInterfaceUptime(nodeB, link.interface_b), + lastError: getInterfaceLastError(nodeB, link.interface_b), isLocalPort: false }); } else if (aIsSwitch && bIsSwitch) { @@ -129,7 +133,11 @@ export function renderNetworkTable() { errorsA: getInterfaceErrors(nodeA, link.interface_a), errorsB: getInterfaceErrors(nodeB, link.interface_b), ratesA: getInterfaceRates(nodeA, link.interface_a), - ratesB: getInterfaceRates(nodeB, link.interface_b) + ratesB: getInterfaceRates(nodeB, link.interface_b), + uptimeA: getInterfaceUptime(nodeA, link.interface_a), + uptimeB: getInterfaceUptime(nodeB, link.interface_b), + lastErrorA: getInterfaceLastError(nodeA, link.interface_a), + lastErrorB: getInterfaceLastError(nodeB, link.interface_b) }); } }); @@ -145,6 +153,8 @@ export function renderNetworkTable() { speed: uplink.speed, errors: uplink.errors, rates: uplink.rates, + uptime: uplink.uptime, + lastError: uplink.lastError, isLocalPort: true }); } @@ -165,6 +175,30 @@ export function renderNetworkTable() { return util.toFixed(0); }; + const formatUptime = (seconds) => { + if (!seconds || seconds <= 0) return ''; + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (d > 0) return d + 'd' + (h > 0 ? ' ' + h + 'h' : ''); + if (h > 0) return h + 'h' + (m > 0 ? ' ' + m + 'm' : ''); + return m + 'm'; + }; + + const formatTimeSince = (utcString) => { + if (!utcString) return ''; + const date = new Date(utcString); + const seconds = Math.floor((Date.now() - date.getTime()) / 1000); + if (seconds < 0) return ''; + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (d > 0) return d + 'd' + (h > 0 ? ' ' + h + 'h' : ''); + if (h > 0) return h + 'h' + (m > 0 ? ' ' + m + 'm' : ''); + if (m > 0) return m + 'm'; + return '<1m'; + }; + let rows = nodes.map(node => { const name = getLabel(node); const ips = []; @@ -178,6 +212,8 @@ export function renderNetworkTable() { const speed = isRoot ? null : (conn?.speed || 0); const errors = isRoot ? null : (conn?.errors || { in: 0, out: 0 }); const rates = isRoot ? null : (conn?.rates || { inBytes: 0, outBytes: 0 }); + const uptime = isRoot ? null : (conn?.uptime || 0); + const lastErrorTime = isRoot ? null : (conn?.lastError || null); const useLocalPerspective = isRoot || conn?.isLocalPort; const isUnreachable = node.unreachable; @@ -200,7 +236,11 @@ export function renderNetworkTable() { outUtil: outRateVal == null || !speed ? null : (outRateVal * 8 / speed) * 100, inPkts: rates == null ? null : (useLocalPerspective ? rates.inPkts : rates.outPkts), outPkts: rates == null ? null : (useLocalPerspective ? rates.outPkts : rates.inPkts), - status: isUnreachable ? 'unreachable' : (errors && (errors.in + errors.out) > 0 ? 'errors' : 'ok') + status: isUnreachable ? 'unreachable' : (errors && (errors.in + errors.out) > 0 ? 'errors' : 'ok'), + uptime, + uptimeStr: formatUptime(uptime), + lastErrorTime, + lastErrorStr: formatTimeSince(lastErrorTime) }; }); @@ -211,7 +251,7 @@ export function renderNetworkTable() { html += '