Add error LastSeen tracking, port uptime and last error to table/hovercards

This commit is contained in:
Ian Gulliver
2026-02-02 09:59:03 -08:00
parent e9cbeebe55
commit 2a8e376cbf
9 changed files with 224 additions and 61 deletions

View File

@@ -27,7 +27,8 @@ type Error struct {
InDelta uint64 `json:"in_delta,omitempty"` InDelta uint64 `json:"in_delta,omitempty"`
OutDelta uint64 `json:"out_delta,omitempty"` OutDelta uint64 `json:"out_delta,omitempty"`
Utilization float64 `json:"utilization,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"` LastUpdated time.Time `json:"last_updated,omitempty"`
} }
@@ -50,19 +51,22 @@ func (e *ErrorTracker) AddUnreachable(node *Node) {
defer e.mu.Unlock() defer e.mu.Unlock()
key := "unreachable:" + node.ID 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 return
} }
now := time.Now()
e.nextID++ e.nextID++
e.errors[key] = &Error{ e.errors[key] = &Error{
ID: fmt.Sprintf("err-%d", e.nextID), ID: fmt.Sprintf("err-%d", e.nextID),
NodeID: node.ID, NodeID: node.ID,
NodeName: node.DisplayName(), NodeName: node.DisplayName(),
Type: ErrorTypeUnreachable, Type: ErrorTypeUnreachable,
FirstSeen: now, FirstSeen: now,
LastUpdated: now, LastSeen: now,
} }
e.t.NotifyUpdate() e.t.NotifyUpdate()
} }
@@ -83,13 +87,14 @@ func (e *ErrorTracker) AddPortError(node *Node, portName string, stats *Interfac
defer e.mu.Unlock() defer e.mu.Unlock()
key := node.ID + ":" + portName key := node.ID + ":" + portName
now := time.Now() now := time.Now().UTC()
if existing, ok := e.errors[key]; ok { if existing, ok := e.errors[key]; ok {
existing.InErrors = stats.InErrors existing.InErrors = stats.InErrors
existing.OutErrors = stats.OutErrors existing.OutErrors = stats.OutErrors
existing.InDelta += inDelta existing.InDelta += inDelta
existing.OutDelta += outDelta existing.OutDelta += outDelta
existing.LastSeen = now
existing.LastUpdated = now existing.LastUpdated = now
} else { } else {
e.nextID++ e.nextID++
@@ -104,6 +109,7 @@ func (e *ErrorTracker) AddPortError(node *Node, portName string, stats *Interfac
InDelta: inDelta, InDelta: inDelta,
OutDelta: outDelta, OutDelta: outDelta,
FirstSeen: now, FirstSeen: now,
LastSeen: now,
LastUpdated: now, LastUpdated: now,
} }
log.Printf("[ERROR] port errors on %s %s: in=%d out=%d", node.DisplayName(), portName, inDelta, outDelta) 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() defer e.mu.Unlock()
key := "util:" + node.ID + ":" + portName key := "util:" + node.ID + ":" + portName
now := time.Now() now := time.Now().UTC()
if existing, ok := e.errors[key]; ok { if existing, ok := e.errors[key]; ok {
existing.LastSeen = now
if utilization > existing.Utilization { if utilization > existing.Utilization {
existing.Utilization = utilization existing.Utilization = utilization
existing.LastUpdated = now existing.LastUpdated = now
e.t.NotifyUpdate()
} }
e.t.NotifyUpdate()
return return
} }
@@ -136,20 +143,38 @@ func (e *ErrorTracker) AddUtilizationError(node *Node, portName string, utilizat
Type: ErrorTypeHighUtilization, Type: ErrorTypeHighUtilization,
Utilization: utilization, Utilization: utilization,
FirstSeen: now, FirstSeen: now,
LastSeen: now,
LastUpdated: now, LastUpdated: now,
} }
log.Printf("[ERROR] high utilization on %s %s: %.0f%%", node.DisplayName(), portName, utilization) log.Printf("[ERROR] high utilization on %s %s: %.0f%%", node.DisplayName(), portName, utilization)
e.t.NotifyUpdate() 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) { func (e *ErrorTracker) AddPortFlap(node *Node, portName string) {
e.mu.Lock() e.mu.Lock()
defer e.mu.Unlock() defer e.mu.Unlock()
key := "flap:" + node.ID + ":" + portName key := "flap:" + node.ID + ":" + portName
now := time.Now() now := time.Now().UTC()
if existing, ok := e.errors[key]; ok { if existing, ok := e.errors[key]; ok {
existing.LastSeen = now
existing.LastUpdated = now existing.LastUpdated = now
e.t.NotifyUpdate() e.t.NotifyUpdate()
return return
@@ -157,13 +182,13 @@ func (e *ErrorTracker) AddPortFlap(node *Node, portName string) {
e.nextID++ e.nextID++
e.errors[key] = &Error{ e.errors[key] = &Error{
ID: fmt.Sprintf("err-%d", e.nextID), ID: fmt.Sprintf("err-%d", e.nextID),
NodeID: node.ID, NodeID: node.ID,
NodeName: node.DisplayName(), NodeName: node.DisplayName(),
Port: portName, Port: portName,
Type: ErrorTypePortFlap, Type: ErrorTypePortFlap,
FirstSeen: now, FirstSeen: now,
LastUpdated: now, LastSeen: now,
} }
e.t.NotifyUpdate() e.t.NotifyUpdate()
} }
@@ -173,9 +198,10 @@ func (e *ErrorTracker) AddPortDown(node *Node, portName string) {
defer e.mu.Unlock() defer e.mu.Unlock()
key := "down:" + node.ID + ":" + portName key := "down:" + node.ID + ":" + portName
now := time.Now() now := time.Now().UTC()
if existing, ok := e.errors[key]; ok { if existing, ok := e.errors[key]; ok {
existing.LastSeen = now
existing.LastUpdated = now existing.LastUpdated = now
e.t.NotifyUpdate() e.t.NotifyUpdate()
return return
@@ -183,13 +209,13 @@ func (e *ErrorTracker) AddPortDown(node *Node, portName string) {
e.nextID++ e.nextID++
e.errors[key] = &Error{ e.errors[key] = &Error{
ID: fmt.Sprintf("err-%d", e.nextID), ID: fmt.Sprintf("err-%d", e.nextID),
NodeID: node.ID, NodeID: node.ID,
NodeName: node.DisplayName(), NodeName: node.DisplayName(),
Port: portName, Port: portName,
Type: ErrorTypePortDown, Type: ErrorTypePortDown,
FirstSeen: now, FirstSeen: now,
LastUpdated: now, LastSeen: now,
} }
e.t.NotifyUpdate() e.t.NotifyUpdate()
} }

View File

@@ -54,7 +54,8 @@ export function createNodeElement(node, switchConnection, nodeLocation, uplinkIn
const errOut = switchConnection.errors?.out || 0; const errOut = switchConnection.errors?.out || 0;
const r = switchConnection.rates; const r = switchConnection.rates;
buildLinkStats(statsEl, portLabel, switchConnection.speed, errIn, errOut, 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 { } else {
const container = div.querySelector(':scope > .port-hover'); const container = div.querySelector(':scope > .port-hover');
if (container) container.remove(); if (container) container.remove();
@@ -163,7 +164,8 @@ export function createNodeElement(node, switchConnection, nodeLocation, uplinkIn
const errOut = uplinkInfo.errors?.out || 0; const errOut = uplinkInfo.errors?.out || 0;
const r = uplinkInfo.rates; const r = uplinkInfo.rates;
buildLinkStats(statsEl, uplinkLabel, uplinkInfo.speed, errIn, errOut, 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 { } else {
const rootEl = div.querySelector(':scope > .root-label'); const rootEl = div.querySelector(':scope > .root-label');
if (rootEl) rootEl.remove(); if (rootEl) rootEl.remove();

View File

@@ -99,3 +99,13 @@ export function getInterfaceRates(node, ifaceName) {
outBytes: iface.stats.out_bytes_rate || 0 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;
}

View File

@@ -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 { buildSwitchUplinks, buildLocationTree, buildNodeIndex, findLocationForNode, findEffectiveSwitch } from './topology.js';
import { formatUniverse } from './format.js'; import { formatUniverse } from './format.js';
import { createNodeElement, renderLocation } from './components.js'; import { createNodeElement, renderLocation } from './components.js';
@@ -71,7 +71,11 @@ export function render(data, config) {
errorsA: getInterfaceErrors(nodeA, link.interface_a), errorsA: getInterfaceErrors(nodeA, link.interface_a),
errorsB: getInterfaceErrors(nodeB, link.interface_b), errorsB: getInterfaceErrors(nodeB, link.interface_b),
ratesA: getInterfaceRates(nodeA, link.interface_a), 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) { } else if (aIsSwitch && !bIsSwitch) {
const nodeLoc = nodeLocations.get(nodeB.id); const nodeLoc = nodeLocations.get(nodeB.id);
@@ -84,7 +88,9 @@ export function render(data, config) {
external: effectiveSwitch && !isLocalSwitch, external: effectiveSwitch && !isLocalSwitch,
speed: getInterfaceSpeed(nodeA, link.interface_a), speed: getInterfaceSpeed(nodeA, link.interface_a),
errors: getInterfaceErrors(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) { } else if (bIsSwitch && !aIsSwitch) {
const nodeLoc = nodeLocations.get(nodeA.id); const nodeLoc = nodeLocations.get(nodeA.id);
@@ -97,7 +103,9 @@ export function render(data, config) {
external: effectiveSwitch && !isLocalSwitch, external: effectiveSwitch && !isLocalSwitch,
speed: getInterfaceSpeed(nodeB, link.interface_b), speed: getInterfaceSpeed(nodeB, link.interface_b),
errors: getInterfaceErrors(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)
}); });
} }
}); });

View File

@@ -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 { buildSwitchUplinks } from './topology.js';
import { escapeHtml, formatUniverse } from './format.js'; import { escapeHtml, formatUniverse } from './format.js';
import { tableData, tableSortKeys, setTableSortKeys } from './state.js'; import { tableData, tableSortKeys, setTableSortKeys } from './state.js';
@@ -107,6 +107,8 @@ export function renderNetworkTable() {
speed: getInterfaceSpeed(nodeA, link.interface_a), speed: getInterfaceSpeed(nodeA, link.interface_a),
errors: getInterfaceErrors(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),
isLocalPort: false isLocalPort: false
}); });
} else if (bIsSwitch && !aIsSwitch) { } else if (bIsSwitch && !aIsSwitch) {
@@ -116,6 +118,8 @@ export function renderNetworkTable() {
speed: getInterfaceSpeed(nodeB, link.interface_b), speed: getInterfaceSpeed(nodeB, link.interface_b),
errors: getInterfaceErrors(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),
isLocalPort: false isLocalPort: false
}); });
} else if (aIsSwitch && bIsSwitch) { } else if (aIsSwitch && bIsSwitch) {
@@ -129,7 +133,11 @@ export function renderNetworkTable() {
errorsA: getInterfaceErrors(nodeA, link.interface_a), errorsA: getInterfaceErrors(nodeA, link.interface_a),
errorsB: getInterfaceErrors(nodeB, link.interface_b), errorsB: getInterfaceErrors(nodeB, link.interface_b),
ratesA: getInterfaceRates(nodeA, link.interface_a), 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, speed: uplink.speed,
errors: uplink.errors, errors: uplink.errors,
rates: uplink.rates, rates: uplink.rates,
uptime: uplink.uptime,
lastError: uplink.lastError,
isLocalPort: true isLocalPort: true
}); });
} }
@@ -165,6 +175,30 @@ export function renderNetworkTable() {
return util.toFixed(0); 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 => { let rows = nodes.map(node => {
const name = getLabel(node); const name = getLabel(node);
const ips = []; const ips = [];
@@ -178,6 +212,8 @@ export function renderNetworkTable() {
const speed = isRoot ? null : (conn?.speed || 0); const speed = isRoot ? null : (conn?.speed || 0);
const errors = isRoot ? null : (conn?.errors || { in: 0, out: 0 }); const errors = isRoot ? null : (conn?.errors || { in: 0, out: 0 });
const rates = isRoot ? null : (conn?.rates || { inBytes: 0, outBytes: 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 useLocalPerspective = isRoot || conn?.isLocalPort;
const isUnreachable = node.unreachable; const isUnreachable = node.unreachable;
@@ -200,7 +236,11 @@ export function renderNetworkTable() {
outUtil: outRateVal == null || !speed ? null : (outRateVal * 8 / speed) * 100, outUtil: outRateVal == null || !speed ? null : (outRateVal * 8 / speed) * 100,
inPkts: rates == null ? null : (useLocalPerspective ? rates.inPkts : rates.outPkts), inPkts: rates == null ? null : (useLocalPerspective ? rates.inPkts : rates.outPkts),
outPkts: rates == null ? null : (useLocalPerspective ? rates.outPkts : rates.inPkts), 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 += '<th colspan="4"></th>'; html += '<th colspan="4"></th>';
html += '<th colspan="4" class="group-in">In</th>'; html += '<th colspan="4" class="group-in">In</th>';
html += '<th colspan="4" class="group-out">Out</th>'; html += '<th colspan="4" class="group-out">Out</th>';
html += '<th></th>'; html += '<th colspan="3"></th>';
html += '</tr><tr>'; html += '</tr><tr>';
html += '<th data-sort="name">Name</th>'; html += '<th data-sort="name">Name</th>';
html += '<th data-sort="ip">IP</th>'; html += '<th data-sort="ip">IP</th>';
@@ -225,6 +265,8 @@ export function renderNetworkTable() {
html += '<th data-sort="outUtil" class="group-out">%</th>'; html += '<th data-sort="outUtil" class="group-out">%</th>';
html += '<th data-sort="outRate" class="group-out">Mb</th>'; html += '<th data-sort="outRate" class="group-out">Mb</th>';
html += '<th data-sort="outPkts" class="group-out">Kp</th>'; html += '<th data-sort="outPkts" class="group-out">Kp</th>';
html += '<th data-sort="uptime">Uptime</th>';
html += '<th data-sort="lastErrorTime">Last Err</th>';
html += '<th data-sort="status">Status</th>'; html += '<th data-sort="status">Status</th>';
html += '</tr></thead><tbody>'; html += '</tr></thead><tbody>';
@@ -243,6 +285,8 @@ export function renderNetworkTable() {
html += '<td class="numeric group-out">' + (r.outRate == null ? '' : formatUtilLocal(r.outRate, r.speed)) + '</td>'; html += '<td class="numeric group-out">' + (r.outRate == null ? '' : formatUtilLocal(r.outRate, r.speed)) + '</td>';
html += '<td class="numeric group-out">' + (r.outRate == null ? '' : formatMbpsLocal(r.outRate)) + '</td>'; html += '<td class="numeric group-out">' + (r.outRate == null ? '' : formatMbpsLocal(r.outRate)) + '</td>';
html += '<td class="numeric group-out">' + (r.outPkts == null ? '' : formatKppsLocal(r.outPkts)) + '</td>'; html += '<td class="numeric group-out">' + (r.outPkts == null ? '' : formatKppsLocal(r.outPkts)) + '</td>';
html += '<td class="numeric">' + r.uptimeStr + '</td>';
html += '<td class="numeric">' + r.lastErrorStr + '</td>';
html += '<td class="' + statusClass + '">' + r.status + '</td>'; html += '<td class="' + statusClass + '">' + r.status + '</td>';
html += '</tr>'; html += '</tr>';
}); });

View File

@@ -15,7 +15,9 @@ export function buildSwitchUplinks(allSwitches, switchLinks) {
remotePort: link.portB, remotePort: link.portB,
localSpeed: link.speedA, localSpeed: link.speedA,
localErrors: link.errorsA, localErrors: link.errorsA,
localRates: link.ratesA localRates: link.ratesA,
localUptime: link.uptimeA,
localLastError: link.lastErrorA
}); });
adjacency.get(link.switchB.id).push({ adjacency.get(link.switchB.id).push({
neighbor: link.switchA, neighbor: link.switchA,
@@ -23,7 +25,9 @@ export function buildSwitchUplinks(allSwitches, switchLinks) {
remotePort: link.portA, remotePort: link.portA,
localSpeed: link.speedB, localSpeed: link.speedB,
localErrors: link.errorsB, localErrors: link.errorsB,
localRates: link.ratesB localRates: link.ratesB,
localUptime: link.uptimeB,
localLastError: link.lastErrorB
}); });
}); });
@@ -81,7 +85,9 @@ export function buildSwitchUplinks(allSwitches, switchLinks) {
parentName: getLabel(current), parentName: getLabel(current),
speed: reverseEdge?.localSpeed || 0, speed: reverseEdge?.localSpeed || 0,
errors: reverseEdge?.localErrors || null, errors: reverseEdge?.localErrors || null,
rates: reverseEdge?.localRates || null rates: reverseEdge?.localRates || null,
uptime: reverseEdge?.localUptime || 0,
lastError: reverseEdge?.localLastError || null
}); });
queue.push(edge.neighbor); queue.push(edge.neighbor);
} }

View File

@@ -64,7 +64,31 @@ function formatUtilization(bytesPerSec, speed) {
return util.toFixed(0) + '%'; return util.toFixed(0) + '%';
} }
export function buildLinkStats(container, portLabel, speed, errIn, errOut, rates) { function 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';
}
function 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';
}
export function buildLinkStats(container, portLabel, speed, errIn, errOut, rates, uptime, lastError) {
const plainLines = []; const plainLines = [];
if (portLabel) { if (portLabel) {
addClickableValue(container, 'PORT', portLabel, plainLines); addClickableValue(container, 'PORT', portLabel, plainLines);
@@ -81,6 +105,15 @@ export function buildLinkStats(container, portLabel, speed, errIn, errOut, rates
container.appendChild(document.createTextNode('\n')); container.appendChild(document.createTextNode('\n'));
addClickableValue(container, 'TX', txUtil + ' ' + formatShortMbps(rates.txBytes) + ' ' + formatShortKpps(rates.txPkts), plainLines); addClickableValue(container, 'TX', txUtil + ' ' + formatShortMbps(rates.txBytes) + ' ' + formatShortKpps(rates.txPkts), plainLines);
} }
if (uptime) {
container.appendChild(document.createTextNode('\n'));
addClickableValue(container, 'UP', formatUptime(uptime), plainLines);
}
if (lastError) {
const errorAge = formatTimeSince(lastError);
container.appendChild(document.createTextNode('\n'));
addClickableValue(container, 'LERR', errorAge + ' ago', plainLines);
}
container.addEventListener('click', (e) => { container.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
navigator.clipboard.writeText(plainLines.join('\n')); navigator.clipboard.writeText(plainLines.join('\n'));
@@ -160,6 +193,12 @@ export async function clearAllErrors() {
await fetch('/tendrils/api/errors/clear?all=true', { method: 'POST' }); await fetch('/tendrils/api/errors/clear?all=true', { method: 'POST' });
} }
function formatLocalTime(utcString) {
if (!utcString) return '';
const date = new Date(utcString);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
export function updateErrorPanel() { export function updateErrorPanel() {
const panel = document.getElementById('error-panel'); const panel = document.getElementById('error-panel');
const countEl = document.getElementById('error-count'); const countEl = document.getElementById('error-count');
@@ -241,6 +280,11 @@ export function updateErrorPanel() {
item.appendChild(typeEl); item.appendChild(typeEl);
} }
const timestampEl = document.createElement('div');
timestampEl.className = 'error-timestamp';
timestampEl.textContent = 'First: ' + formatLocalTime(err.first_seen) + ' / Last: ' + formatLocalTime(err.last_seen);
item.appendChild(timestampEl);
const dismissBtn = document.createElement('button'); const dismissBtn = document.createElement('button');
dismissBtn.textContent = 'Dismiss'; dismissBtn.textContent = 'Dismiss';
dismissBtn.addEventListener('click', () => clearError(err.id)); dismissBtn.addEventListener('click', () => clearError(err.id));

View File

@@ -1151,6 +1151,12 @@ body.sacn-mode .node:not(.sacn-out):not(.sacn-in):hover .node-info-wrapper {
color: #888; color: #888;
} }
.error-item .error-timestamp {
font-size: 10px;
color: #666;
margin-top: 2px;
}
.error-item button { .error-item button {
align-self: flex-end; align-self: flex-end;
padding: 2px 6px; padding: 2px 6px;

View File

@@ -386,15 +386,16 @@ func (i *Interface) MarshalJSON() ([]byte, error) {
} }
type InterfaceStats struct { type InterfaceStats struct {
Speed uint64 `json:"speed,omitempty"` Speed uint64 `json:"speed,omitempty"`
Uptime uint64 `json:"uptime,omitempty"` Uptime uint64 `json:"uptime,omitempty"`
InErrors uint64 `json:"in_errors,omitempty"` InErrors uint64 `json:"in_errors,omitempty"`
OutErrors uint64 `json:"out_errors,omitempty"` OutErrors uint64 `json:"out_errors,omitempty"`
InPktsRate float64 `json:"in_pkts_rate,omitempty"` InPktsRate float64 `json:"in_pkts_rate,omitempty"`
OutPktsRate float64 `json:"out_pkts_rate,omitempty"` OutPktsRate float64 `json:"out_pkts_rate,omitempty"`
InBytesRate float64 `json:"in_bytes_rate,omitempty"` InBytesRate float64 `json:"in_bytes_rate,omitempty"`
OutBytesRate float64 `json:"out_bytes_rate,omitempty"` OutBytesRate float64 `json:"out_bytes_rate,omitempty"`
PoE *PoEStats `json:"poe,omitempty"` LastError *time.Time `json:"last_error,omitempty"`
PoE *PoEStats `json:"poe,omitempty"`
} }
func round2(v float64) float64 { func round2(v float64) float64 {
@@ -403,15 +404,16 @@ func round2(v float64) float64 {
func (s *InterfaceStats) MarshalJSON() ([]byte, error) { func (s *InterfaceStats) MarshalJSON() ([]byte, error) {
type statsJSON struct { type statsJSON struct {
Speed uint64 `json:"speed,omitempty"` Speed uint64 `json:"speed,omitempty"`
Uptime uint64 `json:"uptime,omitempty"` Uptime uint64 `json:"uptime,omitempty"`
InErrors uint64 `json:"in_errors,omitempty"` InErrors uint64 `json:"in_errors,omitempty"`
OutErrors uint64 `json:"out_errors,omitempty"` OutErrors uint64 `json:"out_errors,omitempty"`
InPktsRate float64 `json:"in_pkts_rate,omitempty"` InPktsRate float64 `json:"in_pkts_rate,omitempty"`
OutPktsRate float64 `json:"out_pkts_rate,omitempty"` OutPktsRate float64 `json:"out_pkts_rate,omitempty"`
InBytesRate float64 `json:"in_bytes_rate,omitempty"` InBytesRate float64 `json:"in_bytes_rate,omitempty"`
OutBytesRate float64 `json:"out_bytes_rate,omitempty"` OutBytesRate float64 `json:"out_bytes_rate,omitempty"`
PoE *PoEStats `json:"poe,omitempty"` LastError *time.Time `json:"last_error,omitempty"`
PoE *PoEStats `json:"poe,omitempty"`
} }
return json.Marshal(statsJSON{ return json.Marshal(statsJSON{
Speed: s.Speed, Speed: s.Speed,
@@ -422,6 +424,7 @@ func (s *InterfaceStats) MarshalJSON() ([]byte, error) {
OutPktsRate: round2(s.OutPktsRate), OutPktsRate: round2(s.OutPktsRate),
InBytesRate: round2(s.InBytesRate), InBytesRate: round2(s.InBytesRate),
OutBytesRate: round2(s.OutBytesRate), OutBytesRate: round2(s.OutBytesRate),
LastError: s.LastError,
PoE: s.PoE, PoE: s.PoE,
}) })
} }
@@ -503,6 +506,10 @@ func (n *Node) SetInterfaceStats(portName string, stats *InterfaceStats) {
return return
} }
if oldStats != nil {
stats.LastError = oldStats.LastError
}
var inDelta, outDelta uint64 var inDelta, outDelta uint64
if oldStats != nil { if oldStats != nil {
if stats.InErrors > oldStats.InErrors { if stats.InErrors > oldStats.InErrors {
@@ -512,7 +519,15 @@ func (n *Node) SetInterfaceStats(portName string, stats *InterfaceStats) {
outDelta = stats.OutErrors - oldStats.OutErrors outDelta = stats.OutErrors - oldStats.OutErrors
} }
} }
if inDelta > 0 || outDelta > 0 {
hasErrors := stats.InErrors > 0 || stats.OutErrors > 0
hasNewErrors := inDelta > 0 || outDelta > 0
if hasErrors && (hasNewErrors || stats.LastError == nil) {
now := time.Now().UTC()
stats.LastError = &now
}
if hasNewErrors {
n.errors.AddPortError(n, portName, stats, inDelta, outDelta) n.errors.AddPortError(n, portName, stats, inDelta, outDelta)
} }
@@ -535,6 +550,8 @@ func (n *Node) SetInterfaceStats(portName string, stats *InterfaceStats) {
if oldUtilization < 70.0 && utilization >= 70.0 { if oldUtilization < 70.0 && utilization >= 70.0 {
n.errors.AddUtilizationError(n, portName, utilization) n.errors.AddUtilizationError(n, portName, utilization)
} else if utilization >= 70.0 {
n.errors.UpdateUtilizationLastSeen(n, portName, utilization)
} }
} }
} }