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';
export function sortTable(column) {
const existingIdx = tableSortKeys.findIndex(k => k.column === column);
const newKeys = [...tableSortKeys];
if (existingIdx === 0) {
newKeys[0] = { ...newKeys[0], asc: !newKeys[0].asc };
} else {
if (existingIdx > 0) {
newKeys.splice(existingIdx, 1);
}
newKeys.unshift({ column, asc: true });
}
setTableSortKeys(newKeys);
}
export function sortRows(rows, sortKeys) {
if (!sortKeys || sortKeys.length === 0) return rows;
const indexed = rows.map((r, i) => ({ r, i }));
indexed.sort((a, b) => {
for (const { column, asc } of sortKeys) {
let va = a.r[column];
let vb = b.r[column];
if (va == null) va = '';
if (vb == null) vb = '';
let cmp;
if (typeof va === 'number' && typeof vb === 'number') {
cmp = va - vb;
} else {
va = String(va).toLowerCase();
vb = String(vb).toLowerCase();
cmp = va.localeCompare(vb, undefined, { numeric: true, sensitivity: 'base' });
}
if (cmp !== 0) return asc ? cmp : -cmp;
}
return a.i - b.i;
});
return indexed.map(x => x.r);
}
export function renderTable() {
if (!tableData) return;
const container = document.getElementById('table-container');
const mode = document.body.classList.contains('dante-mode') ? 'dante' :
document.body.classList.contains('artnet-mode') ? 'artnet' :
document.body.classList.contains('sacn-mode') ? 'sacn' : 'network';
let tableHtml = '';
if (mode === 'network') {
tableHtml = renderNetworkTable();
} else if (mode === 'dante') {
tableHtml = renderDanteTable();
} else if (mode === 'artnet') {
tableHtml = renderArtnetTable();
} else if (mode === 'sacn') {
tableHtml = renderSacnTable();
}
let scrollDiv = container.querySelector('.table-scroll');
if (!scrollDiv) {
scrollDiv = document.createElement('div');
scrollDiv.className = 'table-scroll';
container.appendChild(scrollDiv);
}
const scrollTop = scrollDiv.scrollTop;
scrollDiv.innerHTML = tableHtml;
scrollDiv.scrollTop = scrollTop;
scrollDiv.querySelectorAll('th[data-sort]').forEach(th => {
th.addEventListener('click', () => {
sortTable(th.dataset.sort);
renderTable();
});
const primarySort = tableSortKeys[0];
if (primarySort && primarySort.column === th.dataset.sort) {
th.classList.add(primarySort.asc ? 'sorted-asc' : 'sorted-desc');
}
});
}
export function renderNetworkTable() {
const nodes = tableData.nodes || [];
const links = tableData.links || [];
const nodesByTypeId = new Map();
nodes.forEach(node => nodesByTypeId.set(node.id, node));
const upstreamConnections = new Map();
const allSwitches = nodes.filter(n => isSwitch(n));
const switchLinks = [];
links.forEach(link => {
const nodeA = nodesByTypeId.get(link.node_a_id);
const nodeB = nodesByTypeId.get(link.node_b_id);
if (!nodeA || !nodeB) return;
const aIsSwitch = isSwitch(nodeA);
const bIsSwitch = isSwitch(nodeB);
if (aIsSwitch && !bIsSwitch) {
upstreamConnections.set(nodeB.id, {
switchName: getLabel(nodeA),
port: link.interface_a || '?',
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) {
upstreamConnections.set(nodeA.id, {
switchName: getLabel(nodeB),
port: link.interface_b || '?',
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) {
switchLinks.push({
switchA: nodeA,
switchB: nodeB,
portA: link.interface_a || '?',
portB: link.interface_b || '?',
speedA: getInterfaceSpeed(nodeA, link.interface_a),
speedB: getInterfaceSpeed(nodeB, link.interface_b),
errorsA: getInterfaceErrors(nodeA, link.interface_a),
errorsB: getInterfaceErrors(nodeB, link.interface_b),
ratesA: getInterfaceRates(nodeA, link.interface_a),
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)
});
}
});
const switchUplinks = buildSwitchUplinks(allSwitches, switchLinks);
for (const [switchId, uplink] of switchUplinks) {
if (uplink === 'ROOT') {
upstreamConnections.set(switchId, 'ROOT');
} else {
upstreamConnections.set(switchId, {
switchName: uplink.parentName,
port: uplink.localPort,
speed: uplink.speed,
errors: uplink.errors,
rates: uplink.rates,
uptime: uplink.uptime,
lastError: uplink.lastError,
isLocalPort: true
});
}
}
const formatMbpsLocal = (bytesPerSec) => {
const mbps = (bytesPerSec * 8) / 1000000;
return Math.round(mbps);
};
const formatKppsLocal = (pps) => {
return (pps / 1000).toFixed(1);
};
const formatUtilLocal = (bytesPerSec, speed) => {
if (!speed) return '';
const util = (bytesPerSec * 8 / speed) * 100;
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 = [];
(node.interfaces || []).forEach(iface => {
if (iface.ips) iface.ips.forEach(ip => ips.push(ip));
});
const conn = upstreamConnections.get(node.id);
const isRoot = conn === 'ROOT';
const upstream = isRoot ? 'ROOT' : (conn ? conn.switchName + ':' + conn.port : '');
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;
const speedStr = speed == null ? '' : (speed >= 1e9 ? (speed/1e9)+'G' : speed >= 1e6 ? (speed/1e6)+'M' : speed > 0 ? speed : '0');
const inRateVal = rates == null ? null : (useLocalPerspective ? rates.inBytes : rates.outBytes);
const outRateVal = rates == null ? null : (useLocalPerspective ? rates.outBytes : rates.inBytes);
return {
name,
ip: ips[0] || '',
upstream,
speed,
speedStr,
inErrors: errors == null ? null : (useLocalPerspective ? errors.in : errors.out),
outErrors: errors == null ? null : (useLocalPerspective ? errors.out : errors.in),
inRate: inRateVal,
outRate: outRateVal,
inUtil: inRateVal == null || !speed ? null : (inRateVal * 8 / speed) * 100,
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'),
uptime,
uptimeStr: formatUptime(uptime),
lastErrorTime,
lastErrorStr: formatTimeSince(lastErrorTime)
};
});
rows = sortRows(rows, tableSortKeys);
let html = '
';
html += '';
html += '| Name | ';
html += 'IP | ';
html += 'Upstream | ';
html += 'Speed | ';
html += 'Err | ';
html += '% | ';
html += 'Mb | ';
html += 'Kp | ';
html += 'Err | ';
html += '% | ';
html += 'Mb | ';
html += 'Kp | ';
html += 'Uptime | ';
html += 'Last Err | ';
html += 'Status | ';
html += '
';
rows.forEach(r => {
const statusClass = r.status === 'unreachable' ? 'status-error' : r.status === 'errors' ? 'status-warn' : 'status-ok';
html += '';
html += '| ' + escapeHtml(r.name) + ' | ';
html += '' + escapeHtml(r.ip) + ' | ';
html += '' + escapeHtml(r.upstream) + ' | ';
html += '' + r.speedStr + ' | ';
html += '' + (r.inErrors == null ? '' : r.inErrors) + ' | ';
html += '' + (r.inRate == null ? '' : formatUtilLocal(r.inRate, r.speed)) + ' | ';
html += '' + (r.inRate == null ? '' : formatMbpsLocal(r.inRate)) + ' | ';
html += '' + (r.inPkts == null ? '' : formatKppsLocal(r.inPkts)) + ' | ';
html += '' + (r.outErrors == null ? '' : r.outErrors) + ' | ';
html += '' + (r.outRate == null ? '' : formatUtilLocal(r.outRate, r.speed)) + ' | ';
html += '' + (r.outRate == null ? '' : formatMbpsLocal(r.outRate)) + ' | ';
html += '' + (r.outPkts == null ? '' : formatKppsLocal(r.outPkts)) + ' | ';
html += '' + r.uptimeStr + ' | ';
html += '' + r.lastErrorStr + ' | ';
html += '' + r.status + ' | ';
html += '
';
});
html += '
';
return html;
}
export function renderDanteTable() {
const nodes = tableData.nodes || [];
const nodesByTypeId = new Map();
nodes.forEach(node => nodesByTypeId.set(node.id, node));
let rows = [];
nodes.forEach(node => {
const name = getFirstName(node);
const nameTitle = getLabel(node);
const tx = node.dante_flows?.tx || [];
tx.forEach(peer => {
const peerNode = nodesByTypeId.get(peer.node_id);
const peerName = peerNode ? getFirstName(peerNode) : '??';
const peerTitle = peerNode ? getLabel(peerNode) : '??';
(peer.channels || []).forEach(ch => {
rows.push({
source: name,
sourceTitle: nameTitle,
dest: peerName,
destTitle: peerTitle,
txChannel: ch.tx_channel,
rxChannel: ch.rx_channel,
type: ch.type || '',
status: ch.status || 'active'
});
});
if (!peer.channels || peer.channels.length === 0) {
rows.push({ source: name, sourceTitle: nameTitle, dest: peerName, destTitle: peerTitle, txChannel: '', rxChannel: 0, type: '', status: 'active' });
}
});
});
rows = sortRows(rows, tableSortKeys);
let html = '';
html += '| Source | ';
html += 'TX Channel | ';
html += 'Destination | ';
html += 'RX Channel | ';
html += 'Type | ';
html += 'Status | ';
html += '
';
rows.forEach(r => {
const statusClass = r.status === 'no-source' ? 'status-warn' : 'status-ok';
html += '';
html += '| ' + escapeHtml(r.source) + ' | ';
html += '' + escapeHtml(r.txChannel) + ' | ';
html += '' + escapeHtml(r.dest) + ' | ';
html += '' + (r.rxChannel || '') + ' | ';
html += '' + escapeHtml(r.type) + ' | ';
html += '' + escapeHtml(r.status) + ' | ';
html += '
';
});
html += '
';
return html;
}
export function renderArtnetTable() {
const nodes = tableData.nodes || [];
const txByUniverse = new Map();
const rxByUniverse = new Map();
nodes.forEach(node => {
const name = getFirstName(node);
const title = getLabel(node);
(node.artnet_inputs || []).forEach(u => {
if (!txByUniverse.has(u)) txByUniverse.set(u, []);
txByUniverse.get(u).push({ name, title });
});
(node.artnet_outputs || []).forEach(u => {
if (!rxByUniverse.has(u)) rxByUniverse.set(u, []);
rxByUniverse.get(u).push({ name, title });
});
});
const allUniverses = new Set([...txByUniverse.keys(), ...rxByUniverse.keys()]);
let rows = [];
allUniverses.forEach(u => {
const txNodes = txByUniverse.get(u) || [];
const rxNodes = rxByUniverse.get(u) || [];
const maxLen = Math.max(txNodes.length, rxNodes.length, 1);
for (let i = 0; i < maxLen; i++) {
rows.push({
universe: u,
universeStr: formatUniverse(u),
tx: txNodes[i]?.name || '',
txTitle: txNodes[i]?.title || '',
rx: rxNodes[i]?.name || '',
rxTitle: rxNodes[i]?.title || ''
});
}
});
rows = sortRows(rows, tableSortKeys);
let html = '';
html += '| TX | ';
html += 'Universe | ';
html += 'RX | ';
html += '
';
rows.forEach(r => {
html += '';
html += '| ' + escapeHtml(r.tx) + ' | ';
html += '' + r.universeStr + ' | ';
html += '' + escapeHtml(r.rx) + ' | ';
html += '
';
});
html += '
';
return html;
}
export function renderSacnTable() {
const nodes = tableData.nodes || [];
const txByUniverse = new Map();
const rxByUniverse = new Map();
nodes.forEach(node => {
const name = getFirstName(node);
const title = getLabel(node);
(node.sacn_outputs || []).forEach(u => {
if (!txByUniverse.has(u)) txByUniverse.set(u, []);
txByUniverse.get(u).push({ name, title });
});
(node.multicast_groups || []).forEach(g => {
if (typeof g === 'string' && g.startsWith('sacn:')) {
const u = parseInt(g.substring(5), 10);
if (!isNaN(u)) {
if (!rxByUniverse.has(u)) rxByUniverse.set(u, []);
rxByUniverse.get(u).push({ name, title });
}
}
});
(node.sacn_unicast_inputs || []).forEach(u => {
if (!rxByUniverse.has(u)) rxByUniverse.set(u, []);
const existing = rxByUniverse.get(u);
if (!existing.some(e => e.name === name)) {
existing.push({ name, title });
}
});
});
const allUniverses = new Set([...txByUniverse.keys(), ...rxByUniverse.keys()]);
let rows = [];
allUniverses.forEach(u => {
const txNodes = txByUniverse.get(u) || [];
const rxNodes = rxByUniverse.get(u) || [];
const maxLen = Math.max(txNodes.length, rxNodes.length, 1);
for (let i = 0; i < maxLen; i++) {
rows.push({
universe: u,
tx: txNodes[i]?.name || '',
txTitle: txNodes[i]?.title || '',
rx: rxNodes[i]?.name || '',
rxTitle: rxNodes[i]?.title || ''
});
}
});
rows = sortRows(rows, tableSortKeys);
let html = '';
html += '| TX | ';
html += 'Universe | ';
html += 'RX | ';
html += '
';
rows.forEach(r => {
html += '';
html += '| ' + escapeHtml(r.tx) + ' | ';
html += '' + r.universe + ' | ';
html += '' + escapeHtml(r.rx) + ' | ';
html += '
';
});
html += '
';
return html;
}