384 lines
15 KiB
JavaScript
384 lines
15 KiB
JavaScript
import { getLabel, getShortLabel, isSwitch, getInterfaceSpeed, getInterfaceErrors, getInterfaceRates } from './nodes.js';
|
|
import { buildSwitchUplinks, buildLocationTree, buildNodeIndex, findLocationForNode, findEffectiveSwitch } from './topology.js';
|
|
import { formatUniverse } from './format.js';
|
|
import { createNodeElement, renderLocation } from './components.js';
|
|
import { updateErrorPanel, updateBroadcastStats } from './ui.js';
|
|
import { renderTable } from './table.js';
|
|
import { showFlowView } from './flow.js';
|
|
import {
|
|
nodeElements, locationElements,
|
|
setUsedNodeIds, setUsedLocationIds, setPortErrors,
|
|
setTableData, setFlowViewData, currentView,
|
|
resetAnonCounter
|
|
} from './state.js';
|
|
|
|
export function render(data, config) {
|
|
resetAnonCounter();
|
|
|
|
const nodes = data.nodes || [];
|
|
const links = data.links || [];
|
|
|
|
setPortErrors(data.errors || []);
|
|
const unreachableNodeIds = new Set(nodes.filter(n => n.unreachable).map(n => n.id));
|
|
const errorNodeIds = new Set((data.errors || []).filter(e => e.type !== 'unreachable').map(e => e.node_id));
|
|
|
|
const locationTree = buildLocationTree(config.locations || [], null);
|
|
const nodeIndex = new Map();
|
|
buildNodeIndex(locationTree, nodeIndex);
|
|
|
|
const nodesByTypeId = new Map();
|
|
nodes.forEach(node => {
|
|
nodesByTypeId.set(node.id, node);
|
|
});
|
|
|
|
const nodeLocations = new Map();
|
|
const assignedNodes = new Map();
|
|
const unassignedNodes = [];
|
|
|
|
nodes.forEach(node => {
|
|
const loc = findLocationForNode(node, nodeIndex);
|
|
if (loc) {
|
|
nodeLocations.set(node.id, loc);
|
|
if (!assignedNodes.has(loc)) {
|
|
assignedNodes.set(loc, []);
|
|
}
|
|
assignedNodes.get(loc).push(node);
|
|
} else {
|
|
unassignedNodes.push(node);
|
|
}
|
|
});
|
|
|
|
const switchConnections = new Map();
|
|
const switchLinks = [];
|
|
const allSwitches = nodes.filter(n => isSwitch(n));
|
|
|
|
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) {
|
|
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)
|
|
});
|
|
} else if (aIsSwitch && !bIsSwitch) {
|
|
const nodeLoc = nodeLocations.get(nodeB.id);
|
|
const effectiveSwitch = findEffectiveSwitch(nodeLoc, assignedNodes);
|
|
const isLocalSwitch = effectiveSwitch && effectiveSwitch.id === nodeA.id;
|
|
switchConnections.set(nodeB.id, {
|
|
port: link.interface_a || '?',
|
|
switchName: getLabel(nodeA),
|
|
showSwitchName: !isLocalSwitch,
|
|
external: effectiveSwitch && !isLocalSwitch,
|
|
speed: getInterfaceSpeed(nodeA, link.interface_a),
|
|
errors: getInterfaceErrors(nodeA, link.interface_a),
|
|
rates: getInterfaceRates(nodeA, link.interface_a)
|
|
});
|
|
} else if (bIsSwitch && !aIsSwitch) {
|
|
const nodeLoc = nodeLocations.get(nodeA.id);
|
|
const effectiveSwitch = findEffectiveSwitch(nodeLoc, assignedNodes);
|
|
const isLocalSwitch = effectiveSwitch && effectiveSwitch.id === nodeB.id;
|
|
switchConnections.set(nodeA.id, {
|
|
port: link.interface_b || '?',
|
|
switchName: getLabel(nodeB),
|
|
showSwitchName: !isLocalSwitch,
|
|
external: effectiveSwitch && !isLocalSwitch,
|
|
speed: getInterfaceSpeed(nodeB, link.interface_b),
|
|
errors: getInterfaceErrors(nodeB, link.interface_b),
|
|
rates: getInterfaceRates(nodeB, link.interface_b)
|
|
});
|
|
}
|
|
});
|
|
|
|
const danteNodes = new Map();
|
|
|
|
const formatDanteChannel = (ch) => {
|
|
let str = ch.tx_channel + ' → ' + String(ch.rx_channel).padStart(2, '0');
|
|
if (ch.type) str += ' [' + ch.type + ']';
|
|
if (ch.status === 'no-source') str += ' ⚠';
|
|
return str;
|
|
};
|
|
|
|
nodes.forEach(node => {
|
|
const nodeId = node.id;
|
|
const danteTx = node.dante_flows?.tx || [];
|
|
const danteRx = node.dante_flows?.rx || [];
|
|
|
|
if (danteTx.length === 0 && danteRx.length === 0) return;
|
|
|
|
const txEntries = danteTx.map(peer => {
|
|
const peerNode = nodesByTypeId.get(peer.node_id);
|
|
const peerName = peerNode ? getShortLabel(peerNode) : '??';
|
|
const channels = (peer.channels || []).map(formatDanteChannel);
|
|
const channelSummary = channels.length > 0 ? '\n ' + channels.join('\n ') : '';
|
|
return { text: peerName + channelSummary, peerId: peer.node_id };
|
|
});
|
|
|
|
const rxEntries = danteRx.map(peer => {
|
|
const peerNode = nodesByTypeId.get(peer.node_id);
|
|
const peerName = peerNode ? getShortLabel(peerNode) : '??';
|
|
const channels = (peer.channels || []).map(formatDanteChannel);
|
|
const channelSummary = channels.length > 0 ? '\n ' + channels.join('\n ') : '';
|
|
return { text: peerName + channelSummary, peerId: peer.node_id };
|
|
});
|
|
|
|
txEntries.sort((a, b) => a.text.split('\n')[0].localeCompare(b.text.split('\n')[0]));
|
|
rxEntries.sort((a, b) => a.text.split('\n')[0].localeCompare(b.text.split('\n')[0]));
|
|
|
|
danteNodes.set(nodeId, {
|
|
isTx: danteTx.length > 0,
|
|
isRx: danteRx.length > 0,
|
|
txTo: txEntries.map(e => e.text),
|
|
txToPeerIds: txEntries.map(e => e.peerId),
|
|
rxFrom: rxEntries.map(e => e.text),
|
|
rxFromPeerIds: rxEntries.map(e => e.peerId)
|
|
});
|
|
});
|
|
|
|
const artnetNodes = new Map();
|
|
|
|
const universeInputs = new Map();
|
|
const universeOutputs = new Map();
|
|
|
|
nodes.forEach(node => {
|
|
const name = getShortLabel(node);
|
|
(node.artnet_inputs || []).forEach(u => {
|
|
if (!universeInputs.has(u)) universeInputs.set(u, []);
|
|
universeInputs.get(u).push(name);
|
|
});
|
|
(node.artnet_outputs || []).forEach(u => {
|
|
if (!universeOutputs.has(u)) universeOutputs.set(u, []);
|
|
universeOutputs.get(u).push(name);
|
|
});
|
|
});
|
|
|
|
const collapseNames = (names) => {
|
|
const counts = {};
|
|
names.forEach(n => counts[n] = (counts[n] || 0) + 1);
|
|
return Object.entries(counts).map(([name, count]) => count > 1 ? name + ' x' + count : name);
|
|
};
|
|
|
|
nodes.forEach(node => {
|
|
const nodeId = node.id;
|
|
const artnetInputs = node.artnet_inputs || [];
|
|
const artnetOutputs = node.artnet_outputs || [];
|
|
|
|
if (artnetInputs.length === 0 && artnetOutputs.length === 0) return;
|
|
|
|
const sortedInputs = artnetInputs.slice().sort((a, b) => a - b);
|
|
const sortedOutputs = artnetOutputs.slice().sort((a, b) => a - b);
|
|
|
|
const inputs = sortedInputs.map(u => {
|
|
const sources = collapseNames(universeOutputs.get(u) || []);
|
|
const uniStr = formatUniverse(u);
|
|
if (sources.length > 0) {
|
|
return { display: sources[0] + ' [' + uniStr + ']', firstTarget: sources[0], universe: u };
|
|
}
|
|
return { display: uniStr, firstTarget: null, universe: u };
|
|
});
|
|
const outputs = sortedOutputs.map(u => {
|
|
const dests = collapseNames(universeInputs.get(u) || []);
|
|
const uniStr = formatUniverse(u);
|
|
if (dests.length > 0) {
|
|
return { display: dests[0] + ' [' + uniStr + ']', firstTarget: dests[0], universe: u };
|
|
}
|
|
return { display: uniStr, firstTarget: null, universe: u };
|
|
});
|
|
|
|
artnetNodes.set(nodeId, {
|
|
isOut: outputs.length > 0,
|
|
isIn: inputs.length > 0,
|
|
outputs: outputs,
|
|
inputs: inputs
|
|
});
|
|
});
|
|
|
|
const sacnNodes = new Map();
|
|
|
|
const sacnUniverseInputs = new Map();
|
|
const sacnUniverseOutputs = new Map();
|
|
|
|
function getSacnInputs(node) {
|
|
const inputs = [];
|
|
(node.multicast_groups || []).forEach(g => {
|
|
if (typeof g === 'string' && g.startsWith('sacn:')) {
|
|
const u = parseInt(g.substring(5), 10);
|
|
if (!isNaN(u)) inputs.push(u);
|
|
}
|
|
});
|
|
(node.sacn_unicast_inputs || []).forEach(u => {
|
|
if (!inputs.includes(u)) inputs.push(u);
|
|
});
|
|
return inputs;
|
|
}
|
|
|
|
nodes.forEach(node => {
|
|
const name = getShortLabel(node);
|
|
getSacnInputs(node).forEach(u => {
|
|
if (!sacnUniverseInputs.has(u)) sacnUniverseInputs.set(u, []);
|
|
sacnUniverseInputs.get(u).push(name);
|
|
});
|
|
(node.sacn_outputs || []).forEach(u => {
|
|
if (!sacnUniverseOutputs.has(u)) sacnUniverseOutputs.set(u, []);
|
|
sacnUniverseOutputs.get(u).push(name);
|
|
});
|
|
});
|
|
|
|
const sacnCollapseNames = (names) => {
|
|
const counts = {};
|
|
names.forEach(n => counts[n] = (counts[n] || 0) + 1);
|
|
return Object.entries(counts).map(([name, count]) => count > 1 ? name + ' x' + count : name);
|
|
};
|
|
|
|
nodes.forEach(node => {
|
|
const nodeId = node.id;
|
|
const sacnInputs = getSacnInputs(node);
|
|
const sacnOutputs = node.sacn_outputs || [];
|
|
|
|
if (sacnInputs.length === 0 && sacnOutputs.length === 0) return;
|
|
|
|
const sortedSacnInputs = sacnInputs.slice().sort((a, b) => a - b);
|
|
const sortedSacnOutputs = sacnOutputs.slice().sort((a, b) => a - b);
|
|
|
|
const inputs = sortedSacnInputs.map(u => {
|
|
const sources = sacnCollapseNames(sacnUniverseOutputs.get(u) || []);
|
|
if (sources.length > 0) {
|
|
return { display: sources[0] + ' [' + u + ']', firstTarget: sources[0], universe: u };
|
|
}
|
|
return { display: String(u), firstTarget: null, universe: u };
|
|
});
|
|
const outputs = sortedSacnOutputs.map(u => {
|
|
const dests = sacnCollapseNames(sacnUniverseInputs.get(u) || []);
|
|
if (dests.length > 0) {
|
|
return { display: dests[0] + ' [' + u + ']', firstTarget: dests[0], universe: u };
|
|
}
|
|
return { display: String(u), firstTarget: null, universe: u };
|
|
});
|
|
|
|
sacnNodes.set(nodeId, {
|
|
isOut: outputs.length > 0,
|
|
isIn: inputs.length > 0,
|
|
outputs: outputs,
|
|
inputs: inputs
|
|
});
|
|
});
|
|
|
|
const switchUplinks = buildSwitchUplinks(allSwitches, switchLinks);
|
|
|
|
const container = document.getElementById('container');
|
|
const usedNodeIdsSet = new Set();
|
|
const usedLocationIdsSet = new Set();
|
|
|
|
locationTree.forEach(loc => {
|
|
const el = renderLocation(loc, assignedNodes, true, switchConnections, switchUplinks, danteNodes, artnetNodes, sacnNodes, errorNodeIds, unreachableNodeIds, usedNodeIdsSet, usedLocationIdsSet);
|
|
if (el && el.parentNode !== container) container.appendChild(el);
|
|
});
|
|
|
|
let unassignedLoc = locationElements.get('__unassigned__');
|
|
if (unassignedNodes.length > 0) {
|
|
if (!unassignedLoc) {
|
|
unassignedLoc = document.createElement('div');
|
|
unassignedLoc.className = 'location top-level';
|
|
const nameEl = document.createElement('div');
|
|
nameEl.className = 'location-name';
|
|
nameEl.textContent = 'Unassigned';
|
|
unassignedLoc.appendChild(nameEl);
|
|
locationElements.set('__unassigned__', unassignedLoc);
|
|
}
|
|
|
|
const switches = unassignedNodes.filter(n => isSwitch(n));
|
|
const nonSwitches = unassignedNodes.filter(n => !isSwitch(n));
|
|
|
|
let switchRow = unassignedLoc.querySelector(':scope > .node-row.switch-row');
|
|
if (switches.length > 0) {
|
|
if (!switchRow) {
|
|
switchRow = document.createElement('div');
|
|
switchRow.className = 'node-row switch-row';
|
|
unassignedLoc.appendChild(switchRow);
|
|
}
|
|
const currentIds = new Set(switches.map(n => n.id));
|
|
Array.from(switchRow.children).forEach(ch => {
|
|
if (!currentIds.has(ch.dataset.id)) ch.remove();
|
|
});
|
|
switches.forEach(node => {
|
|
usedNodeIdsSet.add(node.id);
|
|
const uplink = switchUplinks.get(node.id);
|
|
const danteInfo = danteNodes.get(node.id);
|
|
const artnetInfo = artnetNodes.get(node.id);
|
|
const sacnInfo = sacnNodes.get(node.id);
|
|
const hasError = errorNodeIds.has(node.id);
|
|
const isUnreachable = unreachableNodeIds.has(node.id);
|
|
const el = createNodeElement(node, null, null, uplink, danteInfo, artnetInfo, sacnInfo, hasError, isUnreachable);
|
|
if (el.parentNode !== switchRow) switchRow.appendChild(el);
|
|
});
|
|
} else if (switchRow) {
|
|
switchRow.remove();
|
|
}
|
|
|
|
let nodeRow = unassignedLoc.querySelector(':scope > .node-row:not(.switch-row)');
|
|
if (nonSwitches.length > 0) {
|
|
if (!nodeRow) {
|
|
nodeRow = document.createElement('div');
|
|
nodeRow.className = 'node-row';
|
|
unassignedLoc.appendChild(nodeRow);
|
|
}
|
|
const currentIds = new Set(nonSwitches.map(n => n.id));
|
|
Array.from(nodeRow.children).forEach(ch => {
|
|
if (!currentIds.has(ch.dataset.id)) ch.remove();
|
|
});
|
|
nonSwitches.forEach(node => {
|
|
usedNodeIdsSet.add(node.id);
|
|
const conn = switchConnections.get(node.id);
|
|
const danteInfo = danteNodes.get(node.id);
|
|
const artnetInfo = artnetNodes.get(node.id);
|
|
const sacnInfo = sacnNodes.get(node.id);
|
|
const hasError = errorNodeIds.has(node.id);
|
|
const isUnreachable = unreachableNodeIds.has(node.id);
|
|
const el = createNodeElement(node, conn, null, null, danteInfo, artnetInfo, sacnInfo, hasError, isUnreachable);
|
|
if (el.parentNode !== nodeRow) nodeRow.appendChild(el);
|
|
});
|
|
} else if (nodeRow) {
|
|
nodeRow.remove();
|
|
}
|
|
|
|
if (unassignedLoc.parentNode !== container) container.appendChild(unassignedLoc);
|
|
usedLocationIdsSet.add('__unassigned__');
|
|
} else if (unassignedLoc) {
|
|
unassignedLoc.remove();
|
|
}
|
|
|
|
setUsedNodeIds(usedNodeIdsSet);
|
|
setUsedLocationIds(usedLocationIdsSet);
|
|
|
|
locationElements.forEach((el, id) => {
|
|
if (!usedLocationIdsSet.has(id) && el.parentNode) {
|
|
el.remove();
|
|
}
|
|
});
|
|
|
|
updateErrorPanel();
|
|
updateBroadcastStats(data.broadcast_stats);
|
|
|
|
setTableData(data);
|
|
setFlowViewData(data);
|
|
if (currentView === 'table') {
|
|
renderTable();
|
|
}
|
|
const hash = window.location.hash;
|
|
if (hash.startsWith('#flow/')) {
|
|
showFlowView(hash.slice(6));
|
|
}
|
|
}
|