2026-01-30 11:38:09 -08:00
|
|
|
import { formatBytes, formatPackets, formatMbps, formatPps, formatLinkSpeed } from './format.js';
|
|
|
|
|
import { openFlowHash } from './flow.js';
|
2026-02-02 11:18:06 -08:00
|
|
|
import { portErrors, setErrorPanelCollapsed, errorPanelCollapsed, tableData } from './state.js';
|
2026-01-30 11:38:09 -08:00
|
|
|
|
|
|
|
|
export function addClickableValue(container, label, value, plainLines, plainFormat) {
|
|
|
|
|
const lbl = document.createElement('span');
|
|
|
|
|
lbl.className = 'lbl';
|
|
|
|
|
lbl.textContent = label;
|
|
|
|
|
container.appendChild(lbl);
|
|
|
|
|
container.appendChild(document.createTextNode(' '));
|
|
|
|
|
const val = document.createElement('span');
|
|
|
|
|
val.className = 'clickable-value';
|
|
|
|
|
val.textContent = value;
|
|
|
|
|
val.addEventListener('click', (e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
navigator.clipboard.writeText(value);
|
|
|
|
|
});
|
|
|
|
|
container.appendChild(val);
|
|
|
|
|
plainLines.push(plainFormat ? plainFormat(label, value) : label + ': ' + value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function buildClickableList(container, items, label, plainFormat, flowInfo) {
|
|
|
|
|
const plainLines = [];
|
|
|
|
|
items.forEach((item, idx) => {
|
|
|
|
|
if (idx > 0) container.appendChild(document.createTextNode('\n'));
|
|
|
|
|
const lbl = document.createElement('span');
|
|
|
|
|
lbl.className = 'lbl';
|
|
|
|
|
lbl.textContent = label;
|
|
|
|
|
container.appendChild(lbl);
|
|
|
|
|
container.appendChild(document.createTextNode(' '));
|
|
|
|
|
const val = document.createElement('span');
|
|
|
|
|
val.className = 'clickable-value';
|
|
|
|
|
val.textContent = item;
|
|
|
|
|
val.addEventListener('click', (e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
if (flowInfo && flowInfo.universes && flowInfo.universes[idx] !== undefined) {
|
2026-01-30 23:27:45 -08:00
|
|
|
openFlowHash(flowInfo.protocol, flowInfo.universes[idx], flowInfo.nodeName);
|
2026-01-30 11:38:09 -08:00
|
|
|
} else {
|
|
|
|
|
navigator.clipboard.writeText(item);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
container.appendChild(val);
|
|
|
|
|
plainLines.push(plainFormat ? plainFormat(label, item) : label + ': ' + item);
|
|
|
|
|
});
|
|
|
|
|
container.addEventListener('click', (e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
navigator.clipboard.writeText(plainLines.join('\n'));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 17:09:41 -08:00
|
|
|
function formatShortMbps(bytesPerSec) {
|
|
|
|
|
const mbps = (bytesPerSec * 8) / 1000000;
|
|
|
|
|
return Math.round(mbps) + 'Mb';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatShortKpps(pps) {
|
|
|
|
|
const kpps = pps / 1000;
|
|
|
|
|
return kpps.toFixed(1) + 'Kp';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatUtilization(bytesPerSec, speed) {
|
|
|
|
|
if (!speed) return '?%';
|
|
|
|
|
const util = (bytesPerSec * 8 / speed) * 100;
|
|
|
|
|
return util.toFixed(0) + '%';
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 09:59:03 -08:00
|
|
|
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) {
|
2026-01-30 11:38:09 -08:00
|
|
|
const plainLines = [];
|
|
|
|
|
if (portLabel) {
|
|
|
|
|
addClickableValue(container, 'PORT', portLabel, plainLines);
|
|
|
|
|
container.appendChild(document.createTextNode('\n'));
|
|
|
|
|
}
|
|
|
|
|
addClickableValue(container, 'LINK', formatLinkSpeed(speed), plainLines);
|
|
|
|
|
container.appendChild(document.createTextNode('\n'));
|
|
|
|
|
addClickableValue(container, 'ERR', 'RX ' + errIn + ' / TX ' + errOut, plainLines);
|
|
|
|
|
if (rates) {
|
2026-02-01 17:09:41 -08:00
|
|
|
const rxUtil = formatUtilization(rates.rxBytes, speed);
|
|
|
|
|
const txUtil = formatUtilization(rates.txBytes, speed);
|
2026-01-30 11:38:09 -08:00
|
|
|
container.appendChild(document.createTextNode('\n'));
|
2026-02-01 17:09:41 -08:00
|
|
|
addClickableValue(container, 'RX', rxUtil + ' ' + formatShortMbps(rates.rxBytes) + ' ' + formatShortKpps(rates.rxPkts), plainLines);
|
2026-01-30 11:38:09 -08:00
|
|
|
container.appendChild(document.createTextNode('\n'));
|
2026-02-01 17:09:41 -08:00
|
|
|
addClickableValue(container, 'TX', txUtil + ' ' + formatShortMbps(rates.txBytes) + ' ' + formatShortKpps(rates.txPkts), plainLines);
|
2026-01-30 11:38:09 -08:00
|
|
|
}
|
2026-02-02 09:59:03 -08:00
|
|
|
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);
|
|
|
|
|
}
|
2026-01-30 11:38:09 -08:00
|
|
|
container.addEventListener('click', (e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
navigator.clipboard.writeText(plainLines.join('\n'));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 23:27:45 -08:00
|
|
|
export function buildDanteDetail(container, entries, arrow, sourceNodeName, peerNodeNames) {
|
2026-01-30 11:38:09 -08:00
|
|
|
const plainLines = [];
|
|
|
|
|
entries.forEach((entry, entryIdx) => {
|
2026-01-30 23:27:45 -08:00
|
|
|
const peerNodeName = peerNodeNames ? peerNodeNames[entryIdx] : null;
|
2026-01-30 11:38:09 -08:00
|
|
|
entry.split('\n').forEach((line, lineIdx) => {
|
|
|
|
|
if (entryIdx > 0 && lineIdx === 0) {
|
|
|
|
|
container.appendChild(document.createTextNode('\n\n'));
|
|
|
|
|
plainLines.push('');
|
|
|
|
|
} else if (container.childNodes.length > 0) {
|
|
|
|
|
container.appendChild(document.createTextNode('\n'));
|
|
|
|
|
}
|
|
|
|
|
if (line.startsWith(' ')) {
|
2026-01-30 23:27:45 -08:00
|
|
|
container.appendChild(document.createTextNode(' ' + line.trim()));
|
|
|
|
|
plainLines.push(' ' + line.trim());
|
2026-01-30 11:38:09 -08:00
|
|
|
} else {
|
|
|
|
|
const lbl = document.createElement('span');
|
|
|
|
|
lbl.className = 'lbl';
|
|
|
|
|
lbl.textContent = arrow;
|
|
|
|
|
container.appendChild(lbl);
|
|
|
|
|
container.appendChild(document.createTextNode(' '));
|
|
|
|
|
const val = document.createElement('span');
|
|
|
|
|
val.className = 'clickable-value';
|
|
|
|
|
val.textContent = line;
|
|
|
|
|
val.addEventListener('click', (e) => {
|
|
|
|
|
e.stopPropagation();
|
2026-01-30 23:27:45 -08:00
|
|
|
if (sourceNodeName && peerNodeName) {
|
|
|
|
|
const src = arrow === '→' ? sourceNodeName : peerNodeName;
|
|
|
|
|
const dst = arrow === '→' ? peerNodeName : sourceNodeName;
|
2026-01-30 11:38:09 -08:00
|
|
|
openFlowHash('dante', src, 'to', dst);
|
|
|
|
|
} else {
|
|
|
|
|
navigator.clipboard.writeText(line);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
container.appendChild(val);
|
|
|
|
|
plainLines.push(arrow + ' ' + line);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
container.addEventListener('click', (e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
navigator.clipboard.writeText(plainLines.join('\n'));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function setConnectionStatus(connected) {
|
|
|
|
|
const el = document.getElementById('connection-status');
|
|
|
|
|
const textEl = el.querySelector('.text');
|
|
|
|
|
if (connected) {
|
|
|
|
|
el.className = 'connected';
|
|
|
|
|
textEl.textContent = 'Connected';
|
|
|
|
|
} else {
|
|
|
|
|
el.className = 'disconnected';
|
|
|
|
|
textEl.textContent = 'Disconnected';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function scrollToNode(typeid) {
|
|
|
|
|
const nodeEl = document.querySelector('.node[data-id="' + typeid + '"]');
|
|
|
|
|
if (nodeEl) {
|
|
|
|
|
nodeEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
|
|
|
nodeEl.classList.add('scroll-highlight');
|
|
|
|
|
setTimeout(() => nodeEl.classList.remove('scroll-highlight'), 1000);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function clearError(id) {
|
2026-01-30 22:31:58 -08:00
|
|
|
await fetch('/tendrils/api/errors/clear?id=' + encodeURIComponent(id), { method: 'POST' });
|
2026-01-30 11:38:09 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function clearAllErrors() {
|
2026-01-30 22:31:58 -08:00
|
|
|
await fetch('/tendrils/api/errors/clear?all=true', { method: 'POST' });
|
2026-01-30 11:38:09 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-02 11:18:06 -08:00
|
|
|
export async function removeNode(nodeId) {
|
|
|
|
|
await fetch('/tendrils/api/nodes/remove?id=' + encodeURIComponent(nodeId), { method: 'POST' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 09:59:03 -08:00
|
|
|
function formatLocalTime(utcString) {
|
|
|
|
|
if (!utcString) return '';
|
|
|
|
|
const date = new Date(utcString);
|
|
|
|
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 11:38:09 -08:00
|
|
|
export function updateErrorPanel() {
|
|
|
|
|
const panel = document.getElementById('error-panel');
|
|
|
|
|
const countEl = document.getElementById('error-count');
|
|
|
|
|
const listEl = document.getElementById('error-list');
|
|
|
|
|
|
|
|
|
|
if (portErrors.length === 0) {
|
|
|
|
|
panel.classList.remove('has-errors');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
panel.classList.add('has-errors');
|
|
|
|
|
countEl.textContent = portErrors.length + ' Error' + (portErrors.length !== 1 ? 's' : '');
|
|
|
|
|
|
|
|
|
|
listEl.innerHTML = '';
|
|
|
|
|
portErrors.forEach(err => {
|
|
|
|
|
const item = document.createElement('div');
|
|
|
|
|
item.className = 'error-item';
|
|
|
|
|
|
|
|
|
|
const nodeEl = document.createElement('div');
|
|
|
|
|
nodeEl.className = 'error-node';
|
|
|
|
|
nodeEl.textContent = err.node_name || err.node_id;
|
|
|
|
|
nodeEl.addEventListener('click', () => scrollToNode(err.node_id));
|
|
|
|
|
item.appendChild(nodeEl);
|
|
|
|
|
|
|
|
|
|
if (err.type === 'unreachable') {
|
|
|
|
|
const typeEl = document.createElement('div');
|
|
|
|
|
typeEl.className = 'error-type';
|
|
|
|
|
typeEl.textContent = 'Unreachable';
|
|
|
|
|
item.appendChild(typeEl);
|
|
|
|
|
} else if (err.type === 'high_utilization') {
|
|
|
|
|
const portEl = document.createElement('div');
|
|
|
|
|
portEl.className = 'error-port';
|
|
|
|
|
portEl.textContent = 'Port: ' + err.port;
|
|
|
|
|
item.appendChild(portEl);
|
|
|
|
|
|
|
|
|
|
const countsEl = document.createElement('div');
|
|
|
|
|
countsEl.className = 'error-counts';
|
|
|
|
|
countsEl.textContent = 'Utilization: ' + (err.utilization || 0).toFixed(0) + '%';
|
|
|
|
|
item.appendChild(countsEl);
|
|
|
|
|
|
|
|
|
|
const typeEl = document.createElement('div');
|
|
|
|
|
typeEl.className = 'error-type';
|
|
|
|
|
typeEl.textContent = 'High link utilization';
|
|
|
|
|
item.appendChild(typeEl);
|
2026-01-31 14:20:22 -08:00
|
|
|
} else if (err.type === 'port_flap') {
|
|
|
|
|
const portEl = document.createElement('div');
|
|
|
|
|
portEl.className = 'error-port';
|
|
|
|
|
portEl.textContent = 'Port: ' + err.port;
|
|
|
|
|
item.appendChild(portEl);
|
|
|
|
|
|
|
|
|
|
const typeEl = document.createElement('div');
|
|
|
|
|
typeEl.className = 'error-type';
|
|
|
|
|
typeEl.textContent = 'Port flap detected';
|
|
|
|
|
item.appendChild(typeEl);
|
|
|
|
|
} else if (err.type === 'port_down') {
|
|
|
|
|
const portEl = document.createElement('div');
|
|
|
|
|
portEl.className = 'error-port';
|
|
|
|
|
portEl.textContent = 'Port: ' + err.port;
|
|
|
|
|
item.appendChild(portEl);
|
|
|
|
|
|
|
|
|
|
const typeEl = document.createElement('div');
|
|
|
|
|
typeEl.className = 'error-type';
|
|
|
|
|
typeEl.textContent = 'Port down';
|
|
|
|
|
item.appendChild(typeEl);
|
2026-01-30 11:38:09 -08:00
|
|
|
} else {
|
|
|
|
|
const portEl = document.createElement('div');
|
|
|
|
|
portEl.className = 'error-port';
|
|
|
|
|
portEl.textContent = 'Port: ' + err.port;
|
|
|
|
|
item.appendChild(portEl);
|
|
|
|
|
|
|
|
|
|
const countsEl = document.createElement('div');
|
|
|
|
|
countsEl.className = 'error-counts';
|
2026-01-31 14:20:22 -08:00
|
|
|
countsEl.textContent = 'rx: ' + (err.in_errors || 0) + ' (+' + (err.in_delta || 0) + ') / tx: ' + (err.out_errors || 0) + ' (+' + (err.out_delta || 0) + ')';
|
2026-01-30 11:38:09 -08:00
|
|
|
item.appendChild(countsEl);
|
|
|
|
|
|
|
|
|
|
const typeEl = document.createElement('div');
|
|
|
|
|
typeEl.className = 'error-type';
|
|
|
|
|
typeEl.textContent = 'New errors detected';
|
|
|
|
|
item.appendChild(typeEl);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 09:59:03 -08:00
|
|
|
const timestampEl = document.createElement('div');
|
|
|
|
|
timestampEl.className = 'error-timestamp';
|
|
|
|
|
timestampEl.textContent = 'First: ' + formatLocalTime(err.first_seen) + ' / Last: ' + formatLocalTime(err.last_seen);
|
|
|
|
|
item.appendChild(timestampEl);
|
|
|
|
|
|
2026-02-02 11:33:42 -08:00
|
|
|
const buttonsEl = document.createElement('div');
|
|
|
|
|
buttonsEl.className = 'error-buttons';
|
|
|
|
|
|
2026-02-02 11:18:06 -08:00
|
|
|
const node = tableData?.nodes?.find(n => n.id === err.node_id);
|
|
|
|
|
if (node && node.unreachable && !node.in_config) {
|
|
|
|
|
const removeBtn = document.createElement('button');
|
|
|
|
|
removeBtn.className = 'remove-btn';
|
|
|
|
|
removeBtn.textContent = 'Remove node';
|
|
|
|
|
removeBtn.addEventListener('click', () => removeNode(err.node_id));
|
2026-02-02 11:33:42 -08:00
|
|
|
buttonsEl.appendChild(removeBtn);
|
2026-02-02 11:18:06 -08:00
|
|
|
}
|
|
|
|
|
|
2026-01-30 11:38:09 -08:00
|
|
|
const dismissBtn = document.createElement('button');
|
|
|
|
|
dismissBtn.textContent = 'Dismiss';
|
|
|
|
|
dismissBtn.addEventListener('click', () => clearError(err.id));
|
2026-02-02 11:33:42 -08:00
|
|
|
buttonsEl.appendChild(dismissBtn);
|
2026-01-30 11:38:09 -08:00
|
|
|
|
2026-02-02 11:33:42 -08:00
|
|
|
item.appendChild(buttonsEl);
|
2026-01-30 11:38:09 -08:00
|
|
|
listEl.appendChild(item);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function updateBroadcastStats(stats) {
|
|
|
|
|
const panel = document.getElementById('broadcast-stats');
|
|
|
|
|
const ppsEl = document.getElementById('broadcast-pps');
|
|
|
|
|
const bpsEl = document.getElementById('broadcast-bps');
|
|
|
|
|
const bucketsEl = document.getElementById('broadcast-buckets');
|
|
|
|
|
|
|
|
|
|
if (!stats) {
|
|
|
|
|
ppsEl.textContent = '0 pps';
|
|
|
|
|
bpsEl.textContent = '0 B/s';
|
|
|
|
|
bucketsEl.innerHTML = '';
|
|
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bucketsEl.innerHTML = '';
|
|
|
|
|
if (stats.buckets && stats.buckets.length > 0) {
|
|
|
|
|
stats.buckets.filter(b => b.packets_per_s >= 0.5).forEach(bucket => {
|
|
|
|
|
const div = document.createElement('div');
|
|
|
|
|
div.className = 'bucket';
|
|
|
|
|
div.innerHTML = '<span class="bucket-name">' + bucket.name + '</span>' +
|
|
|
|
|
'<span class="bucket-rate">' + formatPackets(bucket.packets_per_s) + '</span>';
|
|
|
|
|
bucketsEl.appendChild(div);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function setupErrorPanelListeners() {
|
|
|
|
|
document.getElementById('clear-all-errors').addEventListener('click', clearAllErrors);
|
|
|
|
|
document.getElementById('toggle-errors').addEventListener('click', () => {
|
|
|
|
|
const panel = document.getElementById('error-panel');
|
|
|
|
|
const btn = document.getElementById('toggle-errors');
|
|
|
|
|
const newCollapsed = !errorPanelCollapsed;
|
|
|
|
|
setErrorPanelCollapsed(newCollapsed);
|
|
|
|
|
if (newCollapsed) {
|
|
|
|
|
panel.classList.add('collapsed');
|
|
|
|
|
btn.textContent = 'Show';
|
|
|
|
|
} else {
|
|
|
|
|
panel.classList.remove('collapsed');
|
|
|
|
|
btn.textContent = 'Hide';
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|