Files
tendrils/static/js/render.js
2026-01-30 13:03:35 -08:00

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));
}
}