Extract CSS and JS from index.html into separate ES modules
This commit is contained in:
3302
static/index.html
3302
static/index.html
File diff suppressed because it is too large
Load Diff
127
static/js/app.js
Normal file
127
static/js/app.js
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { setConnectionStatus, setupErrorPanelListeners } from './ui.js';
|
||||||
|
import { render } from './render.js';
|
||||||
|
import { renderTable } from './table.js';
|
||||||
|
import { showFlowView, closeFlowView } from './flow.js';
|
||||||
|
import {
|
||||||
|
setCurrentConfig, setCurrentMode, setCurrentView, setTableSortKeys,
|
||||||
|
currentMode, currentView, flowViewData
|
||||||
|
} from './state.js';
|
||||||
|
|
||||||
|
let currentConfig = null;
|
||||||
|
|
||||||
|
function connectSSE() {
|
||||||
|
const evtSource = new EventSource('/api/status/stream');
|
||||||
|
let heartbeatTimeout = null;
|
||||||
|
|
||||||
|
function resetHeartbeat() {
|
||||||
|
if (heartbeatTimeout) clearTimeout(heartbeatTimeout);
|
||||||
|
heartbeatTimeout = setTimeout(() => {
|
||||||
|
setConnectionStatus(false);
|
||||||
|
evtSource.close();
|
||||||
|
setTimeout(connectSSE, 2000);
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
evtSource.addEventListener('status', (event) => {
|
||||||
|
resetHeartbeat();
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
currentConfig = data.config || {};
|
||||||
|
setCurrentConfig(currentConfig);
|
||||||
|
render(data, currentConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
evtSource.onopen = () => {
|
||||||
|
setConnectionStatus(true);
|
||||||
|
resetHeartbeat();
|
||||||
|
};
|
||||||
|
|
||||||
|
evtSource.onerror = () => {
|
||||||
|
if (heartbeatTimeout) clearTimeout(heartbeatTimeout);
|
||||||
|
setConnectionStatus(false);
|
||||||
|
evtSource.close();
|
||||||
|
setTimeout(connectSSE, 2000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHash() {
|
||||||
|
let hash = '';
|
||||||
|
if (currentMode !== 'network') hash = currentMode;
|
||||||
|
if (currentView === 'table') hash += (hash ? '-' : '') + 'table';
|
||||||
|
window.location.hash = hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMode(mode) {
|
||||||
|
setCurrentMode(mode);
|
||||||
|
document.body.classList.remove('dante-mode', 'artnet-mode', 'sacn-mode');
|
||||||
|
document.getElementById('mode-network').classList.remove('active');
|
||||||
|
document.getElementById('mode-dante').classList.remove('active');
|
||||||
|
document.getElementById('mode-artnet').classList.remove('active');
|
||||||
|
document.getElementById('mode-sacn').classList.remove('active');
|
||||||
|
|
||||||
|
if (mode === 'dante') {
|
||||||
|
document.body.classList.add('dante-mode');
|
||||||
|
document.getElementById('mode-dante').classList.add('active');
|
||||||
|
} else if (mode === 'artnet') {
|
||||||
|
document.body.classList.add('artnet-mode');
|
||||||
|
document.getElementById('mode-artnet').classList.add('active');
|
||||||
|
} else if (mode === 'sacn') {
|
||||||
|
document.body.classList.add('sacn-mode');
|
||||||
|
document.getElementById('mode-sacn').classList.add('active');
|
||||||
|
} else {
|
||||||
|
document.getElementById('mode-network').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateHash();
|
||||||
|
setTableSortKeys([]);
|
||||||
|
if (currentView === 'table') {
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setView(view) {
|
||||||
|
setCurrentView(view);
|
||||||
|
document.getElementById('view-map').classList.toggle('active', view === 'map');
|
||||||
|
document.getElementById('view-table').classList.toggle('active', view === 'table');
|
||||||
|
document.body.classList.toggle('table-view', view === 'table');
|
||||||
|
updateHash();
|
||||||
|
if (view === 'table') {
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHash() {
|
||||||
|
const hash = window.location.hash.slice(1);
|
||||||
|
if (hash.startsWith('flow/')) {
|
||||||
|
if (flowViewData) showFlowView(hash.slice(5));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeFlowView();
|
||||||
|
const hashParts = hash.split('-');
|
||||||
|
const hashMode = hashParts[0];
|
||||||
|
const hashView = hashParts.includes('table') ? 'table' : 'map';
|
||||||
|
|
||||||
|
if (hashMode === 'dante' || hashMode === 'artnet' || hashMode === 'sacn') {
|
||||||
|
setMode(hashMode);
|
||||||
|
} else if (currentMode !== 'network') {
|
||||||
|
setMode('network');
|
||||||
|
}
|
||||||
|
if (hashView === 'table' && currentView !== 'table') {
|
||||||
|
setView('table');
|
||||||
|
} else if (hashView !== 'table' && currentView === 'table') {
|
||||||
|
setView('map');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('mode-network').addEventListener('click', () => setMode('network'));
|
||||||
|
document.getElementById('mode-dante').addEventListener('click', () => setMode('dante'));
|
||||||
|
document.getElementById('mode-artnet').addEventListener('click', () => setMode('artnet'));
|
||||||
|
document.getElementById('mode-sacn').addEventListener('click', () => setMode('sacn'));
|
||||||
|
document.getElementById('view-map').addEventListener('click', () => setView('map'));
|
||||||
|
document.getElementById('view-table').addEventListener('click', () => setView('table'));
|
||||||
|
|
||||||
|
setupErrorPanelListeners();
|
||||||
|
|
||||||
|
window.addEventListener('hashchange', parseHash);
|
||||||
|
parseHash();
|
||||||
|
|
||||||
|
connectSSE();
|
||||||
430
static/js/components.js
Normal file
430
static/js/components.js
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
import { getLabel, getShortLabel, isSwitch, getSpeedClass } from './nodes.js';
|
||||||
|
import { addClickableValue, buildLinkStats, buildDanteDetail, buildClickableList } from './ui.js';
|
||||||
|
import { nodeElements, locationElements, usedNodeIds, usedLocationIds } from './state.js';
|
||||||
|
|
||||||
|
export function createNodeElement(node, switchConnection, nodeLocation, uplinkInfo, danteInfo, artnetInfo, sacnInfo, hasError, isUnreachable) {
|
||||||
|
let div = nodeElements.get(node.id);
|
||||||
|
if (!div) {
|
||||||
|
div = document.createElement('div');
|
||||||
|
div.dataset.id = node.id;
|
||||||
|
div.addEventListener('click', () => {
|
||||||
|
const nodeData = div._nodeData;
|
||||||
|
if (!nodeData) return;
|
||||||
|
let copyText = nodeData.names?.length > 0 ? nodeData.names.join('\n') : getLabel(nodeData);
|
||||||
|
navigator.clipboard.writeText(copyText).then(() => {
|
||||||
|
div.classList.add('copied');
|
||||||
|
setTimeout(() => div.classList.remove('copied'), 300);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
nodeElements.set(node.id, div);
|
||||||
|
}
|
||||||
|
div._nodeData = node;
|
||||||
|
|
||||||
|
div.className = 'node' + (isSwitch(node) ? ' switch' : '');
|
||||||
|
if (hasError) div.classList.add('has-error');
|
||||||
|
if (isUnreachable) div.classList.add('unreachable');
|
||||||
|
if (danteInfo?.isTx) div.classList.add('dante-tx');
|
||||||
|
if (danteInfo?.isRx) div.classList.add('dante-rx');
|
||||||
|
if (artnetInfo?.isOut) div.classList.add('artnet-out');
|
||||||
|
if (artnetInfo?.isIn) div.classList.add('artnet-in');
|
||||||
|
if (sacnInfo?.isOut) div.classList.add('sacn-out');
|
||||||
|
if (sacnInfo?.isIn) div.classList.add('sacn-in');
|
||||||
|
|
||||||
|
if (!isSwitch(node) && switchConnection) {
|
||||||
|
let container = div.querySelector(':scope > .port-hover');
|
||||||
|
if (!container) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
container.className = 'port-hover';
|
||||||
|
container.innerHTML = '<div class="switch-port"></div><div class="link-stats-wrapper"><div class="link-stats"></div></div>';
|
||||||
|
div.appendChild(container);
|
||||||
|
}
|
||||||
|
const portEl = container.querySelector('.switch-port');
|
||||||
|
portEl.className = 'switch-port';
|
||||||
|
if (switchConnection.external) portEl.classList.add('external');
|
||||||
|
const speedClass = getSpeedClass(switchConnection.speed);
|
||||||
|
if (speedClass) portEl.classList.add(speedClass);
|
||||||
|
const portLabel = switchConnection.showSwitchName
|
||||||
|
? switchConnection.switchName + ':' + switchConnection.port
|
||||||
|
: switchConnection.port;
|
||||||
|
portEl.textContent = portLabel;
|
||||||
|
|
||||||
|
const statsEl = container.querySelector('.link-stats');
|
||||||
|
statsEl.innerHTML = '';
|
||||||
|
const errIn = switchConnection.errors?.in || 0;
|
||||||
|
const errOut = switchConnection.errors?.out || 0;
|
||||||
|
const r = switchConnection.rates;
|
||||||
|
buildLinkStats(statsEl, portLabel, switchConnection.speed, errIn, errOut,
|
||||||
|
r ? {rxBytes: r.outBytes, rxPkts: r.outPkts, txBytes: r.inBytes, txPkts: r.inPkts} : null);
|
||||||
|
} else {
|
||||||
|
const container = div.querySelector(':scope > .port-hover');
|
||||||
|
if (container) container.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
let labelEl = div.querySelector(':scope > .node-label');
|
||||||
|
if (!labelEl) {
|
||||||
|
labelEl = document.createElement('span');
|
||||||
|
labelEl.className = 'node-label';
|
||||||
|
div.appendChild(labelEl);
|
||||||
|
}
|
||||||
|
labelEl.innerHTML = '';
|
||||||
|
if (node.names && node.names.length > 0) {
|
||||||
|
node.names.forEach((name, idx) => {
|
||||||
|
if (idx > 0) labelEl.appendChild(document.createTextNode('\n'));
|
||||||
|
const nameSpan = document.createElement('span');
|
||||||
|
nameSpan.className = 'node-name';
|
||||||
|
nameSpan.textContent = name;
|
||||||
|
nameSpan.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigator.clipboard.writeText(name).then(() => {
|
||||||
|
div.classList.add('copied');
|
||||||
|
setTimeout(() => div.classList.remove('copied'), 300);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
labelEl.appendChild(nameSpan);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
labelEl.textContent = getLabel(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasNodeInfo = node.interfaces && (
|
||||||
|
node.interfaces.some(i => i.ips?.length > 0) ||
|
||||||
|
node.interfaces.some(i => i.mac)
|
||||||
|
);
|
||||||
|
if (hasNodeInfo) {
|
||||||
|
let wrapper = div.querySelector(':scope > .node-info-wrapper');
|
||||||
|
if (!wrapper) {
|
||||||
|
wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'node-info-wrapper';
|
||||||
|
wrapper.innerHTML = '<div class="node-info"></div>';
|
||||||
|
div.appendChild(wrapper);
|
||||||
|
}
|
||||||
|
const nodeInfo = wrapper.querySelector('.node-info');
|
||||||
|
nodeInfo.innerHTML = '';
|
||||||
|
const ips = [];
|
||||||
|
const macs = [];
|
||||||
|
node.interfaces.forEach(iface => {
|
||||||
|
if (iface.ips) iface.ips.forEach(ip => { if (!ips.includes(ip)) ips.push(ip); });
|
||||||
|
if (iface.mac && !macs.includes(iface.mac)) macs.push(iface.mac);
|
||||||
|
});
|
||||||
|
ips.sort();
|
||||||
|
macs.sort();
|
||||||
|
const plainLines = [];
|
||||||
|
ips.forEach((ip, idx) => {
|
||||||
|
if (idx > 0) nodeInfo.appendChild(document.createTextNode('\n'));
|
||||||
|
addClickableValue(nodeInfo, 'IP', ip, plainLines);
|
||||||
|
});
|
||||||
|
macs.forEach((mac, idx) => {
|
||||||
|
if (ips.length > 0 || idx > 0) nodeInfo.appendChild(document.createTextNode('\n'));
|
||||||
|
addClickableValue(nodeInfo, 'MAC', mac, plainLines);
|
||||||
|
});
|
||||||
|
if (plainLines.length > 0) {
|
||||||
|
nodeInfo.onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigator.clipboard.writeText(plainLines.join('\n'));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const wrapper = div.querySelector(':scope > .node-info-wrapper');
|
||||||
|
if (wrapper) wrapper.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSwitch(node) && uplinkInfo === 'ROOT') {
|
||||||
|
const container = div.querySelector(':scope > .uplink-hover');
|
||||||
|
if (container) container.remove();
|
||||||
|
|
||||||
|
let rootEl = div.querySelector(':scope > .root-label');
|
||||||
|
if (!rootEl) {
|
||||||
|
rootEl = document.createElement('div');
|
||||||
|
rootEl.className = 'root-label';
|
||||||
|
rootEl.textContent = 'ROOT';
|
||||||
|
div.appendChild(rootEl);
|
||||||
|
}
|
||||||
|
} else if (isSwitch(node) && uplinkInfo) {
|
||||||
|
const rootEl = div.querySelector(':scope > .root-label');
|
||||||
|
if (rootEl) rootEl.remove();
|
||||||
|
|
||||||
|
let container = div.querySelector(':scope > .uplink-hover');
|
||||||
|
if (!container) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
container.className = 'uplink-hover';
|
||||||
|
container.innerHTML = '<div class="uplink"></div><div class="link-stats-wrapper"><div class="link-stats"></div></div>';
|
||||||
|
div.appendChild(container);
|
||||||
|
}
|
||||||
|
const uplinkEl = container.querySelector('.uplink');
|
||||||
|
uplinkEl.className = 'uplink';
|
||||||
|
const speedClass = getSpeedClass(uplinkInfo.speed);
|
||||||
|
if (speedClass) uplinkEl.classList.add(speedClass);
|
||||||
|
const uplinkLabel = uplinkInfo.localPort + ' → ' + uplinkInfo.parentName + ':' + uplinkInfo.remotePort;
|
||||||
|
uplinkEl.textContent = uplinkLabel;
|
||||||
|
|
||||||
|
const statsEl = container.querySelector('.link-stats');
|
||||||
|
statsEl.innerHTML = '';
|
||||||
|
const errIn = uplinkInfo.errors?.in || 0;
|
||||||
|
const errOut = uplinkInfo.errors?.out || 0;
|
||||||
|
const r = uplinkInfo.rates;
|
||||||
|
buildLinkStats(statsEl, uplinkLabel, uplinkInfo.speed, errIn, errOut,
|
||||||
|
r ? {rxBytes: r.inBytes, rxPkts: r.inPkts, txBytes: r.outBytes, txPkts: r.outPkts} : null);
|
||||||
|
} else {
|
||||||
|
const rootEl = div.querySelector(':scope > .root-label');
|
||||||
|
if (rootEl) rootEl.remove();
|
||||||
|
const container = div.querySelector(':scope > .uplink-hover');
|
||||||
|
if (container) container.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (danteInfo?.isTx) {
|
||||||
|
let container = div.querySelector(':scope > .dante-tx-hover');
|
||||||
|
if (!container) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
container.className = 'dante-hover dante-tx-hover';
|
||||||
|
container.innerHTML = '<div class="dante-info tx-info"><span class="lbl">→</span> <span class="dante-pill-text"></span></div><div class="dante-detail-wrapper"><div class="dante-detail"></div></div>';
|
||||||
|
div.appendChild(container);
|
||||||
|
}
|
||||||
|
const textEl = container.querySelector('.dante-pill-text');
|
||||||
|
const firstDest = danteInfo.txTo[0].split('\n')[0];
|
||||||
|
const txMore = danteInfo.txTo.length > 1 ? ', ...' : '';
|
||||||
|
textEl.textContent = firstDest + txMore;
|
||||||
|
|
||||||
|
const detail = container.querySelector('.dante-detail');
|
||||||
|
detail.innerHTML = '';
|
||||||
|
buildDanteDetail(detail, danteInfo.txTo, '→', node.id, danteInfo.txToPeerIds);
|
||||||
|
} else {
|
||||||
|
const container = div.querySelector(':scope > .dante-tx-hover');
|
||||||
|
if (container) container.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (danteInfo?.isRx) {
|
||||||
|
let container = div.querySelector(':scope > .dante-rx-hover');
|
||||||
|
if (!container) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
container.className = 'dante-hover dante-rx-hover';
|
||||||
|
container.innerHTML = '<div class="dante-info rx-info"><span class="lbl">←</span> <span class="dante-pill-text"></span></div><div class="dante-detail-wrapper"><div class="dante-detail"></div></div>';
|
||||||
|
div.appendChild(container);
|
||||||
|
}
|
||||||
|
const textEl = container.querySelector('.dante-pill-text');
|
||||||
|
const firstSource = danteInfo.rxFrom[0].split('\n')[0];
|
||||||
|
const rxMore = danteInfo.rxFrom.length > 1 ? ', ...' : '';
|
||||||
|
textEl.textContent = firstSource + rxMore;
|
||||||
|
|
||||||
|
const detail = container.querySelector('.dante-detail');
|
||||||
|
detail.innerHTML = '';
|
||||||
|
buildDanteDetail(detail, danteInfo.rxFrom, '←', node.id, danteInfo.rxFromPeerIds);
|
||||||
|
} else {
|
||||||
|
const container = div.querySelector(':scope > .dante-rx-hover');
|
||||||
|
if (container) container.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (artnetInfo?.isOut) {
|
||||||
|
let container = div.querySelector(':scope > .artnet-out-hover');
|
||||||
|
if (!container) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
container.className = 'artnet-hover artnet-out-hover';
|
||||||
|
container.innerHTML = '<div class="artnet-info out-info"><span class="lbl">←</span> <span class="artnet-pill-text"></span></div><div class="artnet-detail-wrapper"><div class="artnet-detail"></div></div>';
|
||||||
|
div.appendChild(container);
|
||||||
|
}
|
||||||
|
const textEl = container.querySelector('.artnet-pill-text');
|
||||||
|
const firstOut = artnetInfo.outputs[0];
|
||||||
|
const outLabel = firstOut.firstTarget || firstOut.display;
|
||||||
|
const outMore = artnetInfo.outputs.length > 1 ? ', ...' : '';
|
||||||
|
textEl.textContent = outLabel + outMore;
|
||||||
|
|
||||||
|
const detail = container.querySelector('.artnet-detail');
|
||||||
|
detail.innerHTML = '';
|
||||||
|
buildClickableList(detail, artnetInfo.outputs.map(o => o.display), '←', (l, v) => l + ' ' + v,
|
||||||
|
{ protocol: 'artnet', nodeId: node.id, universes: artnetInfo.outputs.map(o => o.universe) });
|
||||||
|
} else {
|
||||||
|
const container = div.querySelector(':scope > .artnet-out-hover');
|
||||||
|
if (container) container.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (artnetInfo?.isIn) {
|
||||||
|
let container = div.querySelector(':scope > .artnet-in-hover');
|
||||||
|
if (!container) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
container.className = 'artnet-hover artnet-in-hover';
|
||||||
|
container.innerHTML = '<div class="artnet-info in-info"><span class="lbl">→</span> <span class="artnet-pill-text"></span></div><div class="artnet-detail-wrapper"><div class="artnet-detail"></div></div>';
|
||||||
|
div.appendChild(container);
|
||||||
|
}
|
||||||
|
const textEl = container.querySelector('.artnet-pill-text');
|
||||||
|
const firstIn = artnetInfo.inputs[0];
|
||||||
|
const inLabel = firstIn.firstTarget || firstIn.display;
|
||||||
|
const inMore = artnetInfo.inputs.length > 1 ? ', ...' : '';
|
||||||
|
textEl.textContent = inLabel + inMore;
|
||||||
|
|
||||||
|
const detail = container.querySelector('.artnet-detail');
|
||||||
|
detail.innerHTML = '';
|
||||||
|
buildClickableList(detail, artnetInfo.inputs.map(i => i.display), '→', (l, v) => l + ' ' + v,
|
||||||
|
{ protocol: 'artnet', nodeId: node.id, universes: artnetInfo.inputs.map(i => i.universe) });
|
||||||
|
} else {
|
||||||
|
const container = div.querySelector(':scope > .artnet-in-hover');
|
||||||
|
if (container) container.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sacnInfo?.isOut) {
|
||||||
|
let container = div.querySelector(':scope > .sacn-out-hover');
|
||||||
|
if (!container) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
container.className = 'sacn-hover sacn-out-hover';
|
||||||
|
container.innerHTML = '<div class="sacn-info out-info"><span class="lbl">←</span> <span class="sacn-pill-text"></span></div><div class="sacn-detail-wrapper"><div class="sacn-detail"></div></div>';
|
||||||
|
div.appendChild(container);
|
||||||
|
}
|
||||||
|
const textEl = container.querySelector('.sacn-pill-text');
|
||||||
|
const firstOut = sacnInfo.outputs[0];
|
||||||
|
const outLabel = firstOut.firstTarget || firstOut.display;
|
||||||
|
const outMore = sacnInfo.outputs.length > 1 ? ', ...' : '';
|
||||||
|
textEl.textContent = outLabel + outMore;
|
||||||
|
|
||||||
|
const detail = container.querySelector('.sacn-detail');
|
||||||
|
detail.innerHTML = '';
|
||||||
|
buildClickableList(detail, sacnInfo.outputs.map(o => o.display), '←', (l, v) => l + ' ' + v,
|
||||||
|
{ protocol: 'sacn', nodeId: node.id, universes: sacnInfo.outputs.map(o => o.universe) });
|
||||||
|
} else {
|
||||||
|
const container = div.querySelector(':scope > .sacn-out-hover');
|
||||||
|
if (container) container.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sacnInfo?.isIn) {
|
||||||
|
let container = div.querySelector(':scope > .sacn-in-hover');
|
||||||
|
if (!container) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
container.className = 'sacn-hover sacn-in-hover';
|
||||||
|
container.innerHTML = '<div class="sacn-info in-info"><span class="lbl">→</span> <span class="sacn-pill-text"></span></div><div class="sacn-detail-wrapper"><div class="sacn-detail"></div></div>';
|
||||||
|
div.appendChild(container);
|
||||||
|
}
|
||||||
|
const textEl = container.querySelector('.sacn-pill-text');
|
||||||
|
const firstIn = sacnInfo.inputs[0];
|
||||||
|
const inLabel = firstIn.firstTarget || firstIn.display;
|
||||||
|
const inMore = sacnInfo.inputs.length > 1 ? ', ...' : '';
|
||||||
|
textEl.textContent = inLabel + inMore;
|
||||||
|
|
||||||
|
const detail = container.querySelector('.sacn-detail');
|
||||||
|
detail.innerHTML = '';
|
||||||
|
buildClickableList(detail, sacnInfo.inputs.map(i => i.display), '→', (l, v) => l + ' ' + v,
|
||||||
|
{ protocol: 'sacn', nodeId: node.id, universes: sacnInfo.inputs.map(i => i.universe) });
|
||||||
|
} else {
|
||||||
|
const container = div.querySelector(':scope > .sacn-in-hover');
|
||||||
|
if (container) container.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderLocation(loc, assignedNodes, isTopLevel, switchConnections, switchUplinks, danteNodes, artnetNodes, sacnNodes, errorNodeIds, unreachableNodeIds, usedNodeIdsSet, usedLocationIdsSet) {
|
||||||
|
const nodes = assignedNodes.get(loc) || [];
|
||||||
|
const hasNodes = nodes.length > 0;
|
||||||
|
|
||||||
|
const childElements = loc.children
|
||||||
|
.map(child => renderLocation(child, assignedNodes, false, switchConnections, switchUplinks, danteNodes, artnetNodes, sacnNodes, errorNodeIds, unreachableNodeIds, usedNodeIdsSet, usedLocationIdsSet))
|
||||||
|
.filter(el => el !== null);
|
||||||
|
|
||||||
|
if (!hasNodes && childElements.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
usedLocationIdsSet.add(loc.id);
|
||||||
|
let container = locationElements.get(loc.id);
|
||||||
|
if (!container) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
container.dataset.locid = loc.id;
|
||||||
|
locationElements.set(loc.id, container);
|
||||||
|
}
|
||||||
|
let classes = 'location';
|
||||||
|
if (loc.anonymous) classes += ' anonymous';
|
||||||
|
if (isTopLevel) classes += ' top-level';
|
||||||
|
container.className = classes;
|
||||||
|
|
||||||
|
let nameEl = container.querySelector(':scope > .location-name');
|
||||||
|
if (!nameEl) {
|
||||||
|
nameEl = document.createElement('div');
|
||||||
|
nameEl.className = 'location-name';
|
||||||
|
container.insertBefore(nameEl, container.firstChild);
|
||||||
|
}
|
||||||
|
nameEl.textContent = loc.name;
|
||||||
|
|
||||||
|
const switchRowId = loc.id + '_sw';
|
||||||
|
const nodeRowId = loc.id + '_nd';
|
||||||
|
|
||||||
|
if (hasNodes) {
|
||||||
|
const switches = nodes.filter(n => isSwitch(n));
|
||||||
|
const nonSwitches = nodes.filter(n => !isSwitch(n));
|
||||||
|
|
||||||
|
if (switches.length > 0) {
|
||||||
|
let switchRow = container.querySelector(':scope > .node-row[data-rowid="' + switchRowId + '"]');
|
||||||
|
if (!switchRow) {
|
||||||
|
switchRow = document.createElement('div');
|
||||||
|
switchRow.className = 'node-row';
|
||||||
|
switchRow.dataset.rowid = switchRowId;
|
||||||
|
const insertPt = container.querySelector(':scope > .node-row, :scope > .children');
|
||||||
|
container.insertBefore(switchRow, insertPt);
|
||||||
|
}
|
||||||
|
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, loc, uplink, danteInfo, artnetInfo, sacnInfo, hasError, isUnreachable);
|
||||||
|
if (el.parentNode !== switchRow) switchRow.appendChild(el);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const switchRow = container.querySelector(':scope > .node-row[data-rowid="' + switchRowId + '"]');
|
||||||
|
if (switchRow) switchRow.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nonSwitches.length > 0) {
|
||||||
|
let nodeRow = container.querySelector(':scope > .node-row[data-rowid="' + nodeRowId + '"]');
|
||||||
|
if (!nodeRow) {
|
||||||
|
nodeRow = document.createElement('div');
|
||||||
|
nodeRow.className = 'node-row';
|
||||||
|
nodeRow.dataset.rowid = nodeRowId;
|
||||||
|
const insertPt = container.querySelector(':scope > .children');
|
||||||
|
container.insertBefore(nodeRow, insertPt);
|
||||||
|
}
|
||||||
|
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, loc, null, danteInfo, artnetInfo, sacnInfo, hasError, isUnreachable);
|
||||||
|
if (el.parentNode !== nodeRow) nodeRow.appendChild(el);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const nodeRow = container.querySelector(':scope > .node-row[data-rowid="' + nodeRowId + '"]');
|
||||||
|
if (nodeRow) nodeRow.remove();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const switchRow = container.querySelector(':scope > .node-row[data-rowid="' + switchRowId + '"]');
|
||||||
|
if (switchRow) switchRow.remove();
|
||||||
|
const nodeRow = container.querySelector(':scope > .node-row[data-rowid="' + nodeRowId + '"]');
|
||||||
|
if (nodeRow) nodeRow.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (childElements.length > 0) {
|
||||||
|
let childrenContainer = container.querySelector(':scope > .children');
|
||||||
|
if (!childrenContainer) {
|
||||||
|
childrenContainer = document.createElement('div');
|
||||||
|
container.appendChild(childrenContainer);
|
||||||
|
}
|
||||||
|
childrenContainer.className = 'children ' + loc.direction;
|
||||||
|
childElements.forEach(el => {
|
||||||
|
if (el.parentNode !== childrenContainer) childrenContainer.appendChild(el);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const childrenContainer = container.querySelector(':scope > .children');
|
||||||
|
if (childrenContainer) childrenContainer.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
352
static/js/flow.js
Normal file
352
static/js/flow.js
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
import { getShortLabel, isSwitch, findInterface } from './nodes.js';
|
||||||
|
import { flowViewData, currentMode, currentView } from './state.js';
|
||||||
|
|
||||||
|
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 function buildNetworkGraph(nodes, links) {
|
||||||
|
const graph = new Map();
|
||||||
|
const nodesByTypeId = new Map();
|
||||||
|
nodes.forEach(n => {
|
||||||
|
nodesByTypeId.set(n.id, n);
|
||||||
|
graph.set(n.id, []);
|
||||||
|
});
|
||||||
|
links.forEach(link => {
|
||||||
|
const nodeA = nodesByTypeId.get(link.node_a_id);
|
||||||
|
const nodeB = nodesByTypeId.get(link.node_b_id);
|
||||||
|
if (!nodeA || !nodeB) return;
|
||||||
|
graph.get(link.node_a_id).push({
|
||||||
|
nodeId: link.node_b_id,
|
||||||
|
viaInterface: link.interface_a,
|
||||||
|
fromInterface: link.interface_b
|
||||||
|
});
|
||||||
|
graph.get(link.node_b_id).push({
|
||||||
|
nodeId: link.node_a_id,
|
||||||
|
viaInterface: link.interface_b,
|
||||||
|
fromInterface: link.interface_a
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { graph, nodesByTypeId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findPath(graph, sourceId, destId) {
|
||||||
|
if (sourceId === destId) return [{ nodeId: sourceId }];
|
||||||
|
const visited = new Set([sourceId]);
|
||||||
|
const queue = [[{ nodeId: sourceId }]];
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const path = queue.shift();
|
||||||
|
const current = path[path.length - 1];
|
||||||
|
const edges = graph.get(current.nodeId) || [];
|
||||||
|
for (const edge of edges) {
|
||||||
|
if (visited.has(edge.nodeId)) continue;
|
||||||
|
const newPath = [...path, edge];
|
||||||
|
if (edge.nodeId === destId) return newPath;
|
||||||
|
visited.add(edge.nodeId);
|
||||||
|
queue.push(newPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveNodeId(identifier, nodes) {
|
||||||
|
const lower = identifier.toLowerCase();
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.id === identifier) return node.id;
|
||||||
|
if (node.names) {
|
||||||
|
for (const name of node.names) {
|
||||||
|
if (name.toLowerCase() === lower) return node.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showFlowView(flowSpec) {
|
||||||
|
if (!flowViewData) return;
|
||||||
|
const { nodes, links } = flowViewData;
|
||||||
|
const { graph, nodesByTypeId } = buildNetworkGraph(nodes, links);
|
||||||
|
|
||||||
|
const parts = flowSpec.split('/');
|
||||||
|
const protocol = parts[0];
|
||||||
|
let title = '', paths = [], error = '';
|
||||||
|
|
||||||
|
if (protocol === 'dante') {
|
||||||
|
if (parts.includes('to')) {
|
||||||
|
const toIdx = parts.indexOf('to');
|
||||||
|
const sourceIdent = parts.slice(1, toIdx).join('/');
|
||||||
|
const destIdent = parts.slice(toIdx + 1).join('/');
|
||||||
|
const sourceId = resolveNodeId(sourceIdent, nodes);
|
||||||
|
const destId = resolveNodeId(destIdent, nodes);
|
||||||
|
if (!sourceId) { error = 'Source node not found: ' + sourceIdent; }
|
||||||
|
else if (!destId) { error = 'Destination node not found: ' + destIdent; }
|
||||||
|
else {
|
||||||
|
const sourceNode = nodesByTypeId.get(sourceId);
|
||||||
|
const destNode = nodesByTypeId.get(destId);
|
||||||
|
title = 'Dante: ' + getShortLabel(sourceNode) + ' → ' + getShortLabel(destNode);
|
||||||
|
const path = findPath(graph, sourceId, destId);
|
||||||
|
if (path) paths.push({ path, sourceId, destId });
|
||||||
|
else error = 'No path found between nodes';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const sourceIdent = parts[1];
|
||||||
|
const txChannel = parts[2];
|
||||||
|
const sourceId = resolveNodeId(sourceIdent, nodes);
|
||||||
|
if (!sourceId) { error = 'Source node not found: ' + sourceIdent; }
|
||||||
|
else {
|
||||||
|
const sourceNode = nodesByTypeId.get(sourceId);
|
||||||
|
const danteTx = sourceNode.dante_flows?.tx || [];
|
||||||
|
title = 'Dante TX: ' + getShortLabel(sourceNode) + (txChannel ? ' ch ' + txChannel : '');
|
||||||
|
const destIds = new Set();
|
||||||
|
danteTx.forEach(peer => {
|
||||||
|
if (txChannel) {
|
||||||
|
const hasChannel = (peer.channels || []).some(ch => ch.tx_channel === txChannel);
|
||||||
|
if (hasChannel) destIds.add(peer.node_id);
|
||||||
|
} else {
|
||||||
|
destIds.add(peer.node_id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
destIds.forEach(destId => {
|
||||||
|
const path = findPath(graph, sourceId, destId);
|
||||||
|
if (path) paths.push({ path, sourceId, destId });
|
||||||
|
});
|
||||||
|
if (paths.length === 0 && destIds.size > 0) error = 'No paths found to destinations';
|
||||||
|
else if (destIds.size === 0) error = 'No active flows' + (txChannel ? ' for channel ' + txChannel : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (protocol === 'sacn' || protocol === 'artnet') {
|
||||||
|
const universe = parseInt(parts[1], 10);
|
||||||
|
const sourceIdent = parts[2];
|
||||||
|
const protoName = protocol === 'sacn' ? 'sACN' : 'Art-Net';
|
||||||
|
if (isNaN(universe)) { error = 'Invalid universe'; }
|
||||||
|
else {
|
||||||
|
const sourceIds = [];
|
||||||
|
const destIds = [];
|
||||||
|
nodes.forEach(node => {
|
||||||
|
if (protocol === 'sacn') {
|
||||||
|
if ((node.sacn_outputs || []).includes(universe)) sourceIds.push(node.id);
|
||||||
|
const groups = node.multicast_groups || [];
|
||||||
|
if (groups.some(g => g === 'sacn:' + universe)) destIds.push(node.id);
|
||||||
|
} else {
|
||||||
|
if ((node.artnet_outputs || []).includes(universe)) sourceIds.push(node.id);
|
||||||
|
if ((node.artnet_inputs || []).includes(universe)) destIds.push(node.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (sourceIdent) {
|
||||||
|
const clickedNodeId = resolveNodeId(sourceIdent, nodes);
|
||||||
|
if (!clickedNodeId) { error = 'Node not found: ' + sourceIdent; }
|
||||||
|
else {
|
||||||
|
const clickedNode = nodesByTypeId.get(clickedNodeId);
|
||||||
|
const isSource = sourceIds.includes(clickedNodeId);
|
||||||
|
const isDest = destIds.includes(clickedNodeId);
|
||||||
|
if (isSource) {
|
||||||
|
const destNames = destIds.filter(id => id !== clickedNodeId).map(id => getShortLabel(nodesByTypeId.get(id))).join(', ');
|
||||||
|
title = protoName + ' ' + universe + ': ' + getShortLabel(clickedNode) + ' → ' + (destNames || '?');
|
||||||
|
destIds.forEach(destId => {
|
||||||
|
if (destId !== clickedNodeId) {
|
||||||
|
const path = findPath(graph, clickedNodeId, destId);
|
||||||
|
if (path) paths.push({ path, sourceId: clickedNodeId, destId });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (isDest) {
|
||||||
|
const sourceNames = sourceIds.map(id => getShortLabel(nodesByTypeId.get(id))).join(', ');
|
||||||
|
title = protoName + ' ' + universe + ': ' + (sourceNames || '?') + ' → ' + getShortLabel(clickedNode);
|
||||||
|
sourceIds.forEach(sourceId => {
|
||||||
|
const path = findPath(graph, sourceId, clickedNodeId);
|
||||||
|
if (path) paths.push({ path, sourceId, destId: clickedNodeId });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
error = 'Node is not a source or destination for universe ' + universe;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
title = protoName + ' Universe ' + universe;
|
||||||
|
sourceIds.forEach(sourceId => {
|
||||||
|
destIds.forEach(destId => {
|
||||||
|
if (sourceId !== destId) {
|
||||||
|
const path = findPath(graph, sourceId, destId);
|
||||||
|
if (path) paths.push({ path, sourceId, destId });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!error && paths.length === 0) error = 'No active flows for universe ' + universe;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error = 'Unknown protocol: ' + protocol;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFlowOverlay(title, paths, error, nodesByTypeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderFlowOverlay(title, paths, error, nodesByTypeId) {
|
||||||
|
let overlay = document.getElementById('flow-overlay');
|
||||||
|
if (!overlay) {
|
||||||
|
overlay = document.createElement('div');
|
||||||
|
overlay.id = 'flow-overlay';
|
||||||
|
overlay.className = 'flow-overlay';
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === overlay) closeFlowView();
|
||||||
|
});
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
}
|
||||||
|
overlay.innerHTML = '';
|
||||||
|
overlay.style.display = 'flex';
|
||||||
|
|
||||||
|
const titleEl = document.createElement('div');
|
||||||
|
titleEl.className = 'flow-title';
|
||||||
|
titleEl.textContent = title;
|
||||||
|
titleEl.addEventListener('click', (e) => e.stopPropagation());
|
||||||
|
overlay.appendChild(titleEl);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const errEl = document.createElement('div');
|
||||||
|
errEl.className = 'flow-error';
|
||||||
|
errEl.textContent = error;
|
||||||
|
errEl.addEventListener('click', (e) => e.stopPropagation());
|
||||||
|
overlay.appendChild(errEl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paths.length === 0) {
|
||||||
|
const errEl = document.createElement('div');
|
||||||
|
errEl.className = 'flow-error';
|
||||||
|
errEl.textContent = 'No paths to display';
|
||||||
|
errEl.addEventListener('click', (e) => e.stopPropagation());
|
||||||
|
overlay.appendChild(errEl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paths.length === 1) {
|
||||||
|
const pathEl = renderFlowPath(paths[0], nodesByTypeId);
|
||||||
|
pathEl.addEventListener('click', (e) => e.stopPropagation());
|
||||||
|
overlay.appendChild(pathEl);
|
||||||
|
} else {
|
||||||
|
const summary = document.createElement('div');
|
||||||
|
summary.className = 'flow-receivers-summary';
|
||||||
|
summary.textContent = paths.length + ' flow paths (click to expand)';
|
||||||
|
const listEl = document.createElement('div');
|
||||||
|
listEl.className = 'flow-receiver-list';
|
||||||
|
summary.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
listEl.classList.toggle('expanded');
|
||||||
|
summary.textContent = listEl.classList.contains('expanded')
|
||||||
|
? paths.length + ' flow paths (click to collapse)'
|
||||||
|
: paths.length + ' flow paths (click to expand)';
|
||||||
|
});
|
||||||
|
paths.forEach(p => {
|
||||||
|
const pathEl = renderFlowPath(p, nodesByTypeId);
|
||||||
|
pathEl.addEventListener('click', (e) => e.stopPropagation());
|
||||||
|
listEl.appendChild(pathEl);
|
||||||
|
});
|
||||||
|
overlay.appendChild(summary);
|
||||||
|
overlay.appendChild(listEl);
|
||||||
|
if (paths.length <= 5) {
|
||||||
|
listEl.classList.add('expanded');
|
||||||
|
summary.textContent = paths.length + ' flow paths (click to collapse)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderFlowPath(pathInfo, nodesByTypeId) {
|
||||||
|
const { path, sourceId, destId } = pathInfo;
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'flow-path';
|
||||||
|
|
||||||
|
path.forEach((step, idx) => {
|
||||||
|
const node = nodesByTypeId.get(step.nodeId);
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
if (idx > 0) {
|
||||||
|
const linkEl = document.createElement('div');
|
||||||
|
linkEl.className = 'flow-link';
|
||||||
|
|
||||||
|
const prevNode = nodesByTypeId.get(path[idx - 1].nodeId);
|
||||||
|
|
||||||
|
const portLabels = document.createElement('div');
|
||||||
|
portLabels.className = 'port-labels';
|
||||||
|
const leftPort = document.createElement('span');
|
||||||
|
leftPort.textContent = path[idx].viaInterface || '?';
|
||||||
|
const rightPort = document.createElement('span');
|
||||||
|
rightPort.textContent = path[idx].fromInterface || '?';
|
||||||
|
portLabels.appendChild(leftPort);
|
||||||
|
portLabels.appendChild(rightPort);
|
||||||
|
linkEl.appendChild(portLabels);
|
||||||
|
|
||||||
|
let iface = findInterface(prevNode, path[idx].viaInterface);
|
||||||
|
let flipped = false;
|
||||||
|
if (!iface?.stats) {
|
||||||
|
iface = findInterface(node, path[idx].fromInterface);
|
||||||
|
flipped = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = document.createElement('div');
|
||||||
|
line.className = 'line';
|
||||||
|
if (!path[idx].viaInterface && !path[idx].fromInterface) line.classList.add('unknown');
|
||||||
|
if (iface?.stats && ((iface.stats.in_errors || 0) > 0 || (iface.stats.out_errors || 0) > 0)) {
|
||||||
|
line.classList.add('has-errors');
|
||||||
|
}
|
||||||
|
linkEl.appendChild(line);
|
||||||
|
|
||||||
|
const stats = document.createElement('div');
|
||||||
|
stats.className = 'stats';
|
||||||
|
const statLines = [];
|
||||||
|
if (iface?.stats) {
|
||||||
|
const speed = iface.stats.speed;
|
||||||
|
const speedStr = speed >= 1e9 ? (speed/1e9)+'G' : speed >= 1e6 ? (speed/1e6)+'M' : '?';
|
||||||
|
statLines.push(speedStr);
|
||||||
|
const inBytes = iface.stats.in_bytes_rate || 0;
|
||||||
|
const outBytes = iface.stats.out_bytes_rate || 0;
|
||||||
|
if (speed > 0 && (inBytes > 0 || outBytes > 0)) {
|
||||||
|
const inPct = ((inBytes * 8) / speed * 100).toFixed(0);
|
||||||
|
const outPct = ((outBytes * 8) / speed * 100).toFixed(0);
|
||||||
|
if (flipped) {
|
||||||
|
statLines.push('↓' + inPct + '% ↑' + outPct + '%');
|
||||||
|
} else {
|
||||||
|
statLines.push('↓' + outPct + '% ↑' + inPct + '%');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stats.textContent = statLines.join('\n');
|
||||||
|
linkEl.appendChild(stats);
|
||||||
|
|
||||||
|
container.appendChild(linkEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeEl = document.createElement('div');
|
||||||
|
nodeEl.className = 'flow-node';
|
||||||
|
if (isSwitch(node)) nodeEl.classList.add('switch');
|
||||||
|
if (step.nodeId === sourceId && sourceId !== destId) nodeEl.classList.add('source');
|
||||||
|
else if (step.nodeId === destId) nodeEl.classList.add('dest');
|
||||||
|
nodeEl.textContent = getShortLabel(node);
|
||||||
|
nodeEl.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
closeFlowView();
|
||||||
|
scrollToNode(step.nodeId);
|
||||||
|
});
|
||||||
|
container.appendChild(nodeEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeFlowView() {
|
||||||
|
const overlay = document.getElementById('flow-overlay');
|
||||||
|
if (overlay) overlay.style.display = 'none';
|
||||||
|
const hash = window.location.hash;
|
||||||
|
if (hash.startsWith('#flow/')) {
|
||||||
|
let newHash = '';
|
||||||
|
if (currentMode !== 'network') newHash = currentMode;
|
||||||
|
if (currentView === 'table') newHash += (newHash ? '-' : '') + 'table';
|
||||||
|
history.pushState(null, '', window.location.pathname + window.location.search + (newHash ? '#' + newHash : ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openFlowHash(protocol, ...args) {
|
||||||
|
window.location.hash = 'flow/' + protocol + '/' + args.join('/');
|
||||||
|
}
|
||||||
39
static/js/format.js
Normal file
39
static/js/format.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
export function formatBytes(bytes) {
|
||||||
|
if (bytes < 1024) return bytes.toFixed(0) + ' B/s';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB/s';
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB/s';
|
||||||
|
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB/s';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPackets(pps) {
|
||||||
|
if (pps < 1000) return pps.toFixed(0) + ' pps';
|
||||||
|
if (pps < 1000000) return (pps / 1000).toFixed(1) + 'K pps';
|
||||||
|
return (pps / 1000000).toFixed(1) + 'M pps';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMbps(bytesPerSec) {
|
||||||
|
const mbps = (bytesPerSec * 8) / 1000000;
|
||||||
|
return Math.round(mbps).toLocaleString() + ' Mbit/s';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPps(pps) {
|
||||||
|
return Math.round(pps).toLocaleString() + ' pps';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLinkSpeed(bps) {
|
||||||
|
if (!bps) return '?';
|
||||||
|
const mbps = bps / 1000000;
|
||||||
|
return mbps.toLocaleString() + ' Mbit/s';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatUniverse(u) {
|
||||||
|
const net = (u >> 8) & 0x7f;
|
||||||
|
const subnet = (u >> 4) & 0x0f;
|
||||||
|
const universe = u & 0x0f;
|
||||||
|
return net + ':' + subnet + ':' + universe + ' (' + u + ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
88
static/js/nodes.js
Normal file
88
static/js/nodes.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
export function getLabel(node) {
|
||||||
|
if (node.names && node.names.length > 0) return node.names.join('\n');
|
||||||
|
if (node.interfaces && node.interfaces.length > 0) {
|
||||||
|
const ips = [];
|
||||||
|
node.interfaces.forEach(iface => {
|
||||||
|
if (iface.ips) iface.ips.forEach(ip => ips.push(ip));
|
||||||
|
});
|
||||||
|
if (ips.length > 0) return ips.join('\n');
|
||||||
|
const macs = [];
|
||||||
|
node.interfaces.forEach(iface => {
|
||||||
|
if (iface.mac) macs.push(iface.mac);
|
||||||
|
});
|
||||||
|
if (macs.length > 0) return macs.join('\n');
|
||||||
|
}
|
||||||
|
return '??';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShortLabel(node) {
|
||||||
|
if (node.names && node.names.length > 0) return node.names.join('\n');
|
||||||
|
if (node.interfaces && node.interfaces.length > 0) {
|
||||||
|
const ips = [];
|
||||||
|
node.interfaces.forEach(iface => {
|
||||||
|
if (iface.ips) iface.ips.forEach(ip => ips.push(ip));
|
||||||
|
});
|
||||||
|
if (ips.length > 0) return ips.join('\n');
|
||||||
|
const macs = [];
|
||||||
|
node.interfaces.forEach(iface => {
|
||||||
|
if (iface.mac) macs.push(iface.mac);
|
||||||
|
});
|
||||||
|
if (macs.length > 0) return macs.join('\n');
|
||||||
|
}
|
||||||
|
return '??';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNodeIdentifiers(node) {
|
||||||
|
const ids = [];
|
||||||
|
if (node.names) {
|
||||||
|
node.names.forEach(n => ids.push(n.toLowerCase()));
|
||||||
|
}
|
||||||
|
if (node.interfaces) {
|
||||||
|
node.interfaces.forEach(iface => {
|
||||||
|
if (iface.mac) ids.push(iface.mac.toLowerCase());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSwitch(node) {
|
||||||
|
return !!(node.poe_budget);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSpeedClass(speed) {
|
||||||
|
if (!speed || speed === 0) return '';
|
||||||
|
if (speed >= 10000000000) return 'speed-10g';
|
||||||
|
if (speed >= 1000000000) return 'speed-1g';
|
||||||
|
if (speed >= 100000000) return 'speed-100m';
|
||||||
|
return 'speed-slow';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findInterface(node, ifaceName) {
|
||||||
|
if (!node || !node.interfaces) return null;
|
||||||
|
return node.interfaces.find(i => i.name === ifaceName) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInterfaceSpeed(node, ifaceName) {
|
||||||
|
const iface = findInterface(node, ifaceName);
|
||||||
|
return iface?.stats?.speed || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInterfaceErrors(node, ifaceName) {
|
||||||
|
const iface = findInterface(node, ifaceName);
|
||||||
|
if (!iface?.stats) return null;
|
||||||
|
const inErr = iface.stats.in_errors || 0;
|
||||||
|
const outErr = iface.stats.out_errors || 0;
|
||||||
|
if (inErr === 0 && outErr === 0) return null;
|
||||||
|
return { in: inErr, out: outErr };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInterfaceRates(node, ifaceName) {
|
||||||
|
const iface = findInterface(node, ifaceName);
|
||||||
|
if (!iface?.stats) return null;
|
||||||
|
return {
|
||||||
|
inPkts: iface.stats.in_pkts_rate || 0,
|
||||||
|
outPkts: iface.stats.out_pkts_rate || 0,
|
||||||
|
inBytes: iface.stats.in_bytes_rate || 0,
|
||||||
|
outBytes: iface.stats.out_bytes_rate || 0
|
||||||
|
};
|
||||||
|
}
|
||||||
381
static/js/render.js
Normal file
381
static/js/render.js
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
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 getSacnInputsFromMulticast(node) {
|
||||||
|
const groups = node.multicast_groups || [];
|
||||||
|
const inputs = [];
|
||||||
|
groups.forEach(g => {
|
||||||
|
if (typeof g === 'string' && g.startsWith('sacn:')) {
|
||||||
|
const u = parseInt(g.substring(5), 10);
|
||||||
|
if (!isNaN(u)) inputs.push(u);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return inputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.forEach(node => {
|
||||||
|
const name = getShortLabel(node);
|
||||||
|
getSacnInputsFromMulticast(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 = getSacnInputsFromMulticast(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));
|
||||||
|
}
|
||||||
|
}
|
||||||
61
static/js/state.js
Normal file
61
static/js/state.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
export const nodeElements = new Map();
|
||||||
|
export const locationElements = new Map();
|
||||||
|
export let usedNodeIds = new Set();
|
||||||
|
export let usedLocationIds = new Set();
|
||||||
|
export let anonCounter = 0;
|
||||||
|
export let portErrors = [];
|
||||||
|
export let errorPanelCollapsed = false;
|
||||||
|
export let currentConfig = null;
|
||||||
|
export let currentMode = 'network';
|
||||||
|
export let currentView = 'map';
|
||||||
|
export let tableData = null;
|
||||||
|
export let tableSortKeys = [];
|
||||||
|
export let flowViewData = null;
|
||||||
|
|
||||||
|
export function resetAnonCounter() {
|
||||||
|
anonCounter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function incrementAnonCounter() {
|
||||||
|
return anonCounter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setUsedNodeIds(ids) {
|
||||||
|
usedNodeIds = ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setUsedLocationIds(ids) {
|
||||||
|
usedLocationIds = ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setPortErrors(errors) {
|
||||||
|
portErrors = errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setErrorPanelCollapsed(collapsed) {
|
||||||
|
errorPanelCollapsed = collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCurrentConfig(config) {
|
||||||
|
currentConfig = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCurrentMode(mode) {
|
||||||
|
currentMode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCurrentView(view) {
|
||||||
|
currentView = view;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTableData(data) {
|
||||||
|
tableData = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTableSortKeys(keys) {
|
||||||
|
tableSortKeys = keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setFlowViewData(data) {
|
||||||
|
flowViewData = data;
|
||||||
|
}
|
||||||
376
static/js/table.js
Normal file
376
static/js/table.js
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
import { getLabel, isSwitch, getInterfaceSpeed, getInterfaceErrors, getInterfaceRates } 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 html = '';
|
||||||
|
if (mode === 'network') {
|
||||||
|
html = renderNetworkTable();
|
||||||
|
} else if (mode === 'dante') {
|
||||||
|
html = renderDanteTable();
|
||||||
|
} else if (mode === 'artnet') {
|
||||||
|
html = renderArtnetTable();
|
||||||
|
} else if (mode === 'sacn') {
|
||||||
|
html = renderSacnTable();
|
||||||
|
}
|
||||||
|
container.innerHTML = html;
|
||||||
|
|
||||||
|
container.querySelectorAll('th[data-sort]').forEach(th => {
|
||||||
|
th.addEventListener('click', () => {
|
||||||
|
sortTable(th.dataset.sort);
|
||||||
|
renderTable();
|
||||||
|
});
|
||||||
|
const sortKey = tableSortKeys.find(k => k.column === th.dataset.sort);
|
||||||
|
if (sortKey) {
|
||||||
|
th.classList.add(sortKey.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),
|
||||||
|
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),
|
||||||
|
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)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
isLocalPort: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatMbpsLocal = (bytesPerSec) => {
|
||||||
|
const mbps = (bytesPerSec * 8) / 1000000;
|
||||||
|
return mbps.toFixed(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 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');
|
||||||
|
|
||||||
|
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: rates == null ? null : (useLocalPerspective ? rates.inBytes : rates.outBytes),
|
||||||
|
outRate: rates == null ? null : (useLocalPerspective ? rates.outBytes : rates.inBytes),
|
||||||
|
status: isUnreachable ? 'unreachable' : (errors && (errors.in + errors.out) > 0 ? 'errors' : 'ok')
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
rows = sortRows(rows, tableSortKeys);
|
||||||
|
|
||||||
|
let html = '<table class="data-table"><thead><tr>';
|
||||||
|
html += '<th data-sort="name">Name</th>';
|
||||||
|
html += '<th data-sort="ip">IP</th>';
|
||||||
|
html += '<th data-sort="upstream">Upstream</th>';
|
||||||
|
html += '<th data-sort="speed">Speed</th>';
|
||||||
|
html += '<th data-sort="inErrors">In Err</th>';
|
||||||
|
html += '<th data-sort="outErrors">Out Err</th>';
|
||||||
|
html += '<th data-sort="inRate">In Mbit/s</th>';
|
||||||
|
html += '<th data-sort="outRate">Out Mbit/s</th>';
|
||||||
|
html += '<th data-sort="status">Status</th>';
|
||||||
|
html += '</tr></thead><tbody>';
|
||||||
|
|
||||||
|
rows.forEach(r => {
|
||||||
|
const statusClass = r.status === 'unreachable' ? 'status-error' : r.status === 'errors' ? 'status-warn' : 'status-ok';
|
||||||
|
html += '<tr>';
|
||||||
|
html += '<td>' + escapeHtml(r.name) + '</td>';
|
||||||
|
html += '<td>' + escapeHtml(r.ip) + '</td>';
|
||||||
|
html += '<td>' + escapeHtml(r.upstream) + '</td>';
|
||||||
|
html += '<td class="numeric">' + r.speedStr + '</td>';
|
||||||
|
html += '<td class="numeric">' + (r.inErrors == null ? '' : r.inErrors) + '</td>';
|
||||||
|
html += '<td class="numeric">' + (r.outErrors == null ? '' : r.outErrors) + '</td>';
|
||||||
|
html += '<td class="numeric">' + (r.inRate == null ? '' : formatMbpsLocal(r.inRate)) + '</td>';
|
||||||
|
html += '<td class="numeric">' + (r.outRate == null ? '' : formatMbpsLocal(r.outRate)) + '</td>';
|
||||||
|
html += '<td class="' + statusClass + '">' + r.status + '</td>';
|
||||||
|
html += '</tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
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 = getLabel(node);
|
||||||
|
const tx = node.dante_flows?.tx || [];
|
||||||
|
tx.forEach(peer => {
|
||||||
|
const peerNode = nodesByTypeId.get(peer.node_id);
|
||||||
|
const peerName = peerNode ? getLabel(peerNode) : '??';
|
||||||
|
(peer.channels || []).forEach(ch => {
|
||||||
|
rows.push({
|
||||||
|
source: name,
|
||||||
|
dest: peerName,
|
||||||
|
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, dest: peerName, txChannel: '', rxChannel: 0, type: '', status: 'active' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
rows = sortRows(rows, tableSortKeys);
|
||||||
|
|
||||||
|
let html = '<table class="data-table"><thead><tr>';
|
||||||
|
html += '<th data-sort="source">Source</th>';
|
||||||
|
html += '<th data-sort="txChannel">TX Channel</th>';
|
||||||
|
html += '<th data-sort="dest">Destination</th>';
|
||||||
|
html += '<th data-sort="rxChannel">RX Channel</th>';
|
||||||
|
html += '<th data-sort="type">Type</th>';
|
||||||
|
html += '<th data-sort="status">Status</th>';
|
||||||
|
html += '</tr></thead><tbody>';
|
||||||
|
|
||||||
|
rows.forEach(r => {
|
||||||
|
const statusClass = r.status === 'no-source' ? 'status-warn' : 'status-ok';
|
||||||
|
html += '<tr>';
|
||||||
|
html += '<td>' + escapeHtml(r.source) + '</td>';
|
||||||
|
html += '<td>' + escapeHtml(r.txChannel) + '</td>';
|
||||||
|
html += '<td>' + escapeHtml(r.dest) + '</td>';
|
||||||
|
html += '<td class="numeric">' + (r.rxChannel || '') + '</td>';
|
||||||
|
html += '<td>' + escapeHtml(r.type) + '</td>';
|
||||||
|
html += '<td class="' + statusClass + '">' + escapeHtml(r.status) + '</td>';
|
||||||
|
html += '</tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderArtnetTable() {
|
||||||
|
const nodes = tableData.nodes || [];
|
||||||
|
const txByUniverse = new Map();
|
||||||
|
const rxByUniverse = new Map();
|
||||||
|
|
||||||
|
nodes.forEach(node => {
|
||||||
|
const name = getLabel(node);
|
||||||
|
(node.artnet_inputs || []).forEach(u => {
|
||||||
|
if (!txByUniverse.has(u)) txByUniverse.set(u, []);
|
||||||
|
txByUniverse.get(u).push(name);
|
||||||
|
});
|
||||||
|
(node.artnet_outputs || []).forEach(u => {
|
||||||
|
if (!rxByUniverse.has(u)) rxByUniverse.set(u, []);
|
||||||
|
rxByUniverse.get(u).push(name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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] || '',
|
||||||
|
rx: rxNodes[i] || ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rows = sortRows(rows, tableSortKeys);
|
||||||
|
|
||||||
|
let html = '<table class="data-table"><thead><tr>';
|
||||||
|
html += '<th data-sort="tx">TX</th>';
|
||||||
|
html += '<th data-sort="universe">Universe</th>';
|
||||||
|
html += '<th data-sort="rx">RX</th>';
|
||||||
|
html += '</tr></thead><tbody>';
|
||||||
|
|
||||||
|
rows.forEach(r => {
|
||||||
|
html += '<tr>';
|
||||||
|
html += '<td>' + escapeHtml(r.tx) + '</td>';
|
||||||
|
html += '<td>' + r.universeStr + '</td>';
|
||||||
|
html += '<td>' + escapeHtml(r.rx) + '</td>';
|
||||||
|
html += '</tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderSacnTable() {
|
||||||
|
const nodes = tableData.nodes || [];
|
||||||
|
const txByUniverse = new Map();
|
||||||
|
const rxByUniverse = new Map();
|
||||||
|
|
||||||
|
nodes.forEach(node => {
|
||||||
|
const name = getLabel(node);
|
||||||
|
(node.sacn_outputs || []).forEach(u => {
|
||||||
|
if (!txByUniverse.has(u)) txByUniverse.set(u, []);
|
||||||
|
txByUniverse.get(u).push(name);
|
||||||
|
});
|
||||||
|
(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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] || '',
|
||||||
|
rx: rxNodes[i] || ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rows = sortRows(rows, tableSortKeys);
|
||||||
|
|
||||||
|
let html = '<table class="data-table"><thead><tr>';
|
||||||
|
html += '<th data-sort="tx">TX</th>';
|
||||||
|
html += '<th data-sort="universe">Universe</th>';
|
||||||
|
html += '<th data-sort="rx">RX</th>';
|
||||||
|
html += '</tr></thead><tbody>';
|
||||||
|
|
||||||
|
rows.forEach(r => {
|
||||||
|
html += '<tr>';
|
||||||
|
html += '<td>' + escapeHtml(r.tx) + '</td>';
|
||||||
|
html += '<td class="numeric">' + r.universe + '</td>';
|
||||||
|
html += '<td>' + escapeHtml(r.rx) + '</td>';
|
||||||
|
html += '</tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
162
static/js/topology.js
Normal file
162
static/js/topology.js
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { getLabel, getNodeIdentifiers, isSwitch } from './nodes.js';
|
||||||
|
import { incrementAnonCounter } from './state.js';
|
||||||
|
|
||||||
|
export function buildSwitchUplinks(allSwitches, switchLinks) {
|
||||||
|
const uplinks = new Map();
|
||||||
|
if (allSwitches.length === 0 || switchLinks.length === 0) return uplinks;
|
||||||
|
|
||||||
|
const adjacency = new Map();
|
||||||
|
allSwitches.forEach(sw => adjacency.set(sw.id, []));
|
||||||
|
|
||||||
|
switchLinks.forEach(link => {
|
||||||
|
adjacency.get(link.switchA.id).push({
|
||||||
|
neighbor: link.switchB,
|
||||||
|
localPort: link.portA,
|
||||||
|
remotePort: link.portB,
|
||||||
|
localSpeed: link.speedA,
|
||||||
|
localErrors: link.errorsA,
|
||||||
|
localRates: link.ratesA
|
||||||
|
});
|
||||||
|
adjacency.get(link.switchB.id).push({
|
||||||
|
neighbor: link.switchA,
|
||||||
|
localPort: link.portB,
|
||||||
|
remotePort: link.portA,
|
||||||
|
localSpeed: link.speedB,
|
||||||
|
localErrors: link.errorsB,
|
||||||
|
localRates: link.ratesB
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const edges of adjacency.values()) {
|
||||||
|
edges.sort((a, b) => getLabel(a.neighbor).localeCompare(getLabel(b.neighbor)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedSwitches = [...allSwitches].sort((a, b) =>
|
||||||
|
getLabel(a).localeCompare(getLabel(b)));
|
||||||
|
|
||||||
|
let bestRoot = sortedSwitches[0];
|
||||||
|
let bestReachable = 0;
|
||||||
|
let bestMaxDepth = Infinity;
|
||||||
|
|
||||||
|
for (const candidate of sortedSwitches) {
|
||||||
|
const visited = new Set([candidate.id]);
|
||||||
|
const queue = [{ sw: candidate, depth: 0 }];
|
||||||
|
let maxDepth = 0;
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const { sw, depth } = queue.shift();
|
||||||
|
maxDepth = Math.max(maxDepth, depth);
|
||||||
|
for (const edge of adjacency.get(sw.id) || []) {
|
||||||
|
if (!visited.has(edge.neighbor.id)) {
|
||||||
|
visited.add(edge.neighbor.id);
|
||||||
|
queue.push({ sw: edge.neighbor, depth: depth + 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reachable = visited.size;
|
||||||
|
if (reachable > bestReachable ||
|
||||||
|
(reachable === bestReachable && maxDepth < bestMaxDepth)) {
|
||||||
|
bestReachable = reachable;
|
||||||
|
bestMaxDepth = maxDepth;
|
||||||
|
bestRoot = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uplinks.set(bestRoot.id, 'ROOT');
|
||||||
|
|
||||||
|
const visited = new Set([bestRoot.id]);
|
||||||
|
const queue = [bestRoot];
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const current = queue.shift();
|
||||||
|
for (const edge of adjacency.get(current.id) || []) {
|
||||||
|
if (!visited.has(edge.neighbor.id)) {
|
||||||
|
visited.add(edge.neighbor.id);
|
||||||
|
const reverseEdge = adjacency.get(edge.neighbor.id).find(e => e.neighbor.id === current.id);
|
||||||
|
uplinks.set(edge.neighbor.id, {
|
||||||
|
localPort: reverseEdge?.localPort || '?',
|
||||||
|
remotePort: reverseEdge?.remotePort || '?',
|
||||||
|
parentNode: current,
|
||||||
|
parentName: getLabel(current),
|
||||||
|
speed: reverseEdge?.localSpeed || 0,
|
||||||
|
errors: reverseEdge?.localErrors || null,
|
||||||
|
rates: reverseEdge?.localRates || null
|
||||||
|
});
|
||||||
|
queue.push(edge.neighbor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return uplinks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLocationTree(locations, parent) {
|
||||||
|
if (!locations) return [];
|
||||||
|
return locations.map((loc, idx) => {
|
||||||
|
let locId;
|
||||||
|
let anonymous = false;
|
||||||
|
if (loc.name) {
|
||||||
|
locId = 'loc_' + loc.name.replace(/[^a-zA-Z0-9]/g, '_');
|
||||||
|
} else {
|
||||||
|
locId = 'loc_anon_' + incrementAnonCounter();
|
||||||
|
anonymous = true;
|
||||||
|
}
|
||||||
|
const locObj = {
|
||||||
|
id: locId,
|
||||||
|
name: loc.name || '',
|
||||||
|
anonymous: anonymous,
|
||||||
|
direction: loc.direction || 'horizontal',
|
||||||
|
nodeRefs: (loc.nodes || []).map(n => n.toLowerCase()),
|
||||||
|
parent: parent,
|
||||||
|
children: []
|
||||||
|
};
|
||||||
|
locObj.children = buildLocationTree(loc.children, locObj);
|
||||||
|
return locObj;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSwitchesInLocation(loc, assignedNodes) {
|
||||||
|
const switches = [];
|
||||||
|
const nodes = assignedNodes.get(loc) || [];
|
||||||
|
nodes.forEach(n => {
|
||||||
|
if (isSwitch(n)) switches.push(n);
|
||||||
|
});
|
||||||
|
loc.children.forEach(child => {
|
||||||
|
if (child.anonymous) {
|
||||||
|
switches.push(...getSwitchesInLocation(child, assignedNodes));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return switches;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findEffectiveSwitch(loc, assignedNodes) {
|
||||||
|
if (!loc) return null;
|
||||||
|
const switches = getSwitchesInLocation(loc, assignedNodes);
|
||||||
|
if (switches.length === 1) {
|
||||||
|
return switches[0];
|
||||||
|
}
|
||||||
|
if (loc.parent) {
|
||||||
|
return findEffectiveSwitch(loc.parent, assignedNodes);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildNodeIndex(locations, index) {
|
||||||
|
locations.forEach(loc => {
|
||||||
|
loc.nodeRefs.forEach(ref => {
|
||||||
|
index.set(ref, loc);
|
||||||
|
});
|
||||||
|
buildNodeIndex(loc.children, index);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findLocationForNode(node, nodeIndex) {
|
||||||
|
const identifiers = getNodeIdentifiers(node);
|
||||||
|
for (const ident of identifiers) {
|
||||||
|
if (nodeIndex.has(ident)) {
|
||||||
|
return nodeIndex.get(ident);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
266
static/js/ui.js
Normal file
266
static/js/ui.js
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import { formatBytes, formatPackets, formatMbps, formatPps, formatLinkSpeed } from './format.js';
|
||||||
|
import { openFlowHash } from './flow.js';
|
||||||
|
import { portErrors, setErrorPanelCollapsed, errorPanelCollapsed } from './state.js';
|
||||||
|
|
||||||
|
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) {
|
||||||
|
openFlowHash(flowInfo.protocol, flowInfo.universes[idx], flowInfo.nodeId);
|
||||||
|
} 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'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLinkStats(container, portLabel, speed, errIn, errOut, rates) {
|
||||||
|
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) {
|
||||||
|
container.appendChild(document.createTextNode('\n'));
|
||||||
|
addClickableValue(container, 'RX', formatMbps(rates.rxBytes) + ' (' + formatPps(rates.rxPkts) + ')', plainLines);
|
||||||
|
container.appendChild(document.createTextNode('\n'));
|
||||||
|
addClickableValue(container, 'TX', formatMbps(rates.txBytes) + ' (' + formatPps(rates.txPkts) + ')', plainLines);
|
||||||
|
}
|
||||||
|
container.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigator.clipboard.writeText(plainLines.join('\n'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDanteDetail(container, entries, arrow, sourceNodeId, peerNodeIds) {
|
||||||
|
const plainLines = [];
|
||||||
|
entries.forEach((entry, entryIdx) => {
|
||||||
|
const peerNodeId = peerNodeIds ? peerNodeIds[entryIdx] : null;
|
||||||
|
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(' ')) {
|
||||||
|
container.appendChild(document.createTextNode(' ' + line.trim()));
|
||||||
|
plainLines.push(' ' + line.trim());
|
||||||
|
} 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();
|
||||||
|
if (sourceNodeId && peerNodeId) {
|
||||||
|
const src = arrow === '→' ? sourceNodeId : peerNodeId;
|
||||||
|
const dst = arrow === '→' ? peerNodeId : sourceNodeId;
|
||||||
|
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) {
|
||||||
|
await fetch('/api/errors/clear?id=' + encodeURIComponent(id), { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearAllErrors() {
|
||||||
|
await fetch('/api/errors/clear?all=true', { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
} 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';
|
||||||
|
countsEl.textContent = 'rx: ' + err.in_errors + ' (+' + (err.in_delta || 0) + ') / tx: ' + err.out_errors + ' (+' + (err.out_delta || 0) + ')';
|
||||||
|
item.appendChild(countsEl);
|
||||||
|
|
||||||
|
const typeEl = document.createElement('div');
|
||||||
|
typeEl.className = 'error-type';
|
||||||
|
typeEl.textContent = 'New errors detected';
|
||||||
|
item.appendChild(typeEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dismissBtn = document.createElement('button');
|
||||||
|
dismissBtn.textContent = 'Dismiss';
|
||||||
|
dismissBtn.addEventListener('click', () => clearError(err.id));
|
||||||
|
item.appendChild(dismissBtn);
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
1095
static/style.css
Normal file
1095
static/style.css
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user