Files
tendrils/static/index.html
Ian Gulliver a96eb7db8c Add port error tracking with UI display
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 18:43:23 -08:00

1018 lines
34 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tendrils Network</title>
<style>
* { box-sizing: border-box; }
body {
font-family: system-ui, sans-serif;
margin: 0;
padding: 10px;
background: #111;
color: #eee;
}
#error { color: #f66; padding: 20px; }
#container {
display: flex;
flex-direction: column;
gap: 20px;
}
.location {
background: #222;
border: 1px solid #444;
border-radius: 8px;
padding: 10px;
overflow: visible;
}
.location.top-level {
width: 100%;
}
.location-name {
font-weight: bold;
font-size: 14px;
margin-bottom: 10px;
text-align: center;
}
.location.anonymous {
background: transparent;
border: none;
padding: 0;
}
.location.anonymous > .location-name {
display: none;
}
.node-row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
overflow: visible;
}
.node-row + .node-row {
margin-top: 8px;
}
.node {
position: relative;
width: 120px;
min-height: 50px;
background: #a6d;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
font-size: 11px;
padding: 4px;
cursor: pointer;
overflow: visible;
word-break: normal;
overflow-wrap: break-word;
white-space: pre-line;
margin-top: 8px;
}
.node .switch-port {
position: absolute;
top: -8px;
left: 50%;
transform: translateX(-50%);
font-size: 9px;
background: #444;
color: #ccc;
padding: 1px 6px;
border-radius: 8px;
white-space: nowrap;
}
.node .switch-port.external {
background: #633;
color: #f99;
}
.node .uplink {
position: absolute;
top: -8px;
left: 50%;
transform: translateX(-50%);
font-size: 9px;
background: #446;
color: #aaf;
padding: 1px 6px;
border-radius: 8px;
white-space: nowrap;
}
.node .root-label {
position: absolute;
top: -8px;
left: 50%;
transform: translateX(-50%);
font-size: 9px;
background: #664;
color: #ffa;
padding: 1px 6px;
border-radius: 8px;
white-space: nowrap;
}
.node:hover {
filter: brightness(1.2);
}
.node.switch {
background: #2a2;
border: 2px solid #4f4;
font-weight: bold;
}
.node.copied {
outline: 2px solid #fff;
}
.children {
display: flex;
gap: 15px;
margin-top: 10px;
overflow: visible;
}
.children.horizontal {
flex-direction: row;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-evenly;
width: 100%;
}
.children.vertical {
flex-direction: column;
align-items: center;
}
#mode-selector {
position: fixed;
top: 10px;
right: 10px;
z-index: 1000;
display: flex;
gap: 0;
background: #333;
border-radius: 6px;
overflow: hidden;
border: 1px solid #555;
}
#mode-selector button {
padding: 8px 16px;
border: none;
background: #333;
color: #aaa;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
}
#mode-selector button:hover {
background: #444;
}
#mode-selector button.active {
background: #555;
color: #fff;
}
body.dante-mode .node {
opacity: 0.3;
}
body.dante-mode .node.dante-tx {
opacity: 1;
background: #d62;
border: 2px solid #f84;
}
body.dante-mode .node.dante-rx {
opacity: 1;
background: #26d;
border: 2px solid #48f;
}
body.dante-mode .node.dante-tx.dante-rx {
background: linear-gradient(135deg, #d62 50%, #26d 50%);
}
body.dante-mode .node .switch-port,
body.dante-mode .node .uplink,
body.dante-mode .node .root-label {
display: none;
}
.node .dante-info {
display: none;
position: absolute;
top: -8px;
left: 50%;
transform: translateX(-50%);
font-size: 9px;
padding: 1px 6px;
border-radius: 8px;
white-space: nowrap;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
z-index: 10;
cursor: default;
}
.node:has(.dante-info:hover) {
z-index: 1000;
}
.node .dante-info:hover {
white-space: pre;
max-width: none;
width: max-content;
z-index: 100;
padding: 4px 8px;
}
.node .dante-info.tx-info {
background: #fca;
color: #630;
}
.node .dante-info.rx-info {
background: #acf;
color: #036;
}
body.dante-mode .node.dante-tx .dante-info,
body.dante-mode .node.dante-rx .dante-info {
display: block;
}
body.dante-mode .node.dante-tx.dante-rx .dante-info.tx-info {
top: -8px;
}
body.dante-mode .node.dante-tx.dante-rx .dante-info.rx-info {
top: auto;
bottom: -8px;
}
.node.has-error {
box-shadow: 0 0 0 3px #f66;
animation: error-pulse 2s infinite;
}
@keyframes error-pulse {
0%, 100% { box-shadow: 0 0 0 3px #f66; }
50% { box-shadow: 0 0 0 3px #f00; }
}
#error-panel {
position: fixed;
top: 50px;
right: 10px;
z-index: 1000;
background: #2a1a1a;
border: 1px solid #f66;
border-radius: 6px;
max-width: 500px;
max-height: 400px;
overflow: hidden;
display: none;
}
#error-panel.has-errors {
display: block;
}
#error-panel.collapsed #error-list {
display: none;
}
#error-header {
padding: 8px 12px;
background: #3a2a2a;
display: flex;
gap: 8px;
align-items: center;
}
#error-count {
flex: 1;
color: #f99;
font-weight: bold;
}
#error-header button {
padding: 4px 8px;
border: none;
background: #444;
color: #ccc;
cursor: pointer;
border-radius: 4px;
font-size: 11px;
}
#error-header button:hover {
background: #555;
}
#error-list {
max-height: 300px;
overflow-y: auto;
padding: 8px;
}
.error-item {
background: #3a2a2a;
border-radius: 4px;
padding: 8px;
margin-bottom: 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.error-item .error-node {
color: #faa;
font-weight: bold;
cursor: pointer;
}
.error-item .error-node:hover {
text-decoration: underline;
}
.error-item .error-port {
color: #ccc;
font-size: 11px;
}
.error-item .error-counts {
color: #f66;
font-size: 11px;
}
.error-item .error-type {
font-size: 9px;
color: #888;
}
.error-item button {
align-self: flex-end;
padding: 2px 6px;
border: none;
background: #555;
color: #ccc;
cursor: pointer;
border-radius: 3px;
font-size: 10px;
}
.error-item button:hover {
background: #666;
}
.node.scroll-highlight {
outline: 3px solid white;
}
</style>
</head>
<body>
<div id="mode-selector">
<button id="mode-network" class="active">Network</button>
<button id="mode-dante">Dante</button>
</div>
<div id="error-panel">
<div id="error-header">
<span id="error-count">0 Errors</span>
<button id="clear-all-errors">Clear All</button>
<button id="toggle-errors">Hide</button>
</div>
<div id="error-list"></div>
</div>
<div id="error"></div>
<div id="container"></div>
<script>
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 '??';
}
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 '??';
}
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;
}
function isSwitch(node) {
return !!(node.poe_budget);
}
let anonCounter = 0;
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_' + (anonCounter++);
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;
});
}
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;
}
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;
}
function buildNodeIndex(locations, index) {
locations.forEach(loc => {
loc.nodeRefs.forEach(ref => {
index.set(ref, loc);
});
buildNodeIndex(loc.children, index);
});
}
function findLocationForNode(node, nodeIndex) {
const identifiers = getNodeIdentifiers(node);
for (const ident of identifiers) {
if (nodeIndex.has(ident)) {
return nodeIndex.get(ident);
}
}
return null;
}
function createNodeElement(node, switchConnection, nodeLocation, uplinkInfo, danteInfo, hasError) {
const div = document.createElement('div');
div.className = 'node' + (isSwitch(node) ? ' switch' : '');
div.dataset.typeid = node.typeid;
if (hasError) div.classList.add('has-error');
if (danteInfo) {
if (danteInfo.isTx) div.classList.add('dante-tx');
if (danteInfo.isRx) div.classList.add('dante-rx');
}
if (!isSwitch(node) && switchConnection) {
const portEl = document.createElement('div');
portEl.className = 'switch-port';
if (switchConnection.external) {
portEl.classList.add('external');
portEl.textContent = switchConnection.switchName + ':' + switchConnection.port;
} else {
portEl.textContent = switchConnection.port;
}
div.appendChild(portEl);
}
const labelEl = document.createElement('span');
labelEl.textContent = getLabel(node);
div.appendChild(labelEl);
if (isSwitch(node) && uplinkInfo === 'ROOT') {
const rootEl = document.createElement('div');
rootEl.className = 'root-label';
rootEl.textContent = 'ROOT';
div.appendChild(rootEl);
} else if (isSwitch(node) && uplinkInfo) {
const uplinkEl = document.createElement('div');
uplinkEl.className = 'uplink';
uplinkEl.textContent = uplinkInfo.localPort + ' → ' + uplinkInfo.parentName + ':' + uplinkInfo.remotePort;
div.appendChild(uplinkEl);
}
if (danteInfo && danteInfo.isTx) {
const txEl = document.createElement('div');
txEl.className = 'dante-info tx-info';
txEl.textContent = '→ ' + danteInfo.txTo.join('\n\n→ ');
div.appendChild(txEl);
}
if (danteInfo && danteInfo.isRx) {
const rxEl = document.createElement('div');
rxEl.className = 'dante-info rx-info';
rxEl.textContent = '← ' + danteInfo.rxFrom.join('\n\n← ');
div.appendChild(rxEl);
}
div.addEventListener('click', () => {
const json = JSON.stringify(node, null, 2);
navigator.clipboard.writeText(json).then(() => {
div.classList.add('copied');
setTimeout(() => div.classList.remove('copied'), 300);
});
});
return div;
}
function renderLocation(loc, assignedNodes, isTopLevel, switchConnections, switchUplinks, danteNodes, errorNodeIds) {
const nodes = assignedNodes.get(loc) || [];
const hasNodes = nodes.length > 0;
const childElements = loc.children
.map(child => renderLocation(child, assignedNodes, false, switchConnections, switchUplinks, danteNodes, errorNodeIds))
.filter(el => el !== null);
if (!hasNodes && childElements.length === 0) {
return null;
}
const container = document.createElement('div');
let classes = 'location';
if (loc.anonymous) classes += ' anonymous';
if (isTopLevel) classes += ' top-level';
container.className = classes;
const nameEl = document.createElement('div');
nameEl.className = 'location-name';
nameEl.textContent = loc.name;
container.appendChild(nameEl);
if (hasNodes) {
const switches = nodes.filter(n => isSwitch(n));
const nonSwitches = nodes.filter(n => !isSwitch(n));
if (switches.length > 0) {
const switchRow = document.createElement('div');
switchRow.className = 'node-row';
switches.forEach(node => {
const uplink = switchUplinks.get(node.typeid);
const danteInfo = danteNodes.get(node.typeid);
const hasError = errorNodeIds.has(node.typeid);
switchRow.appendChild(createNodeElement(node, null, loc, uplink, danteInfo, hasError));
});
container.appendChild(switchRow);
}
if (nonSwitches.length > 0) {
const nodeRow = document.createElement('div');
nodeRow.className = 'node-row';
nonSwitches.forEach(node => {
const conn = switchConnections.get(node.typeid);
const danteInfo = danteNodes.get(node.typeid);
const hasError = errorNodeIds.has(node.typeid);
nodeRow.appendChild(createNodeElement(node, conn, loc, null, danteInfo, hasError));
});
container.appendChild(nodeRow);
}
}
if (childElements.length > 0) {
const childrenContainer = document.createElement('div');
childrenContainer.className = 'children ' + loc.direction;
childElements.forEach(el => childrenContainer.appendChild(el));
container.appendChild(childrenContainer);
}
return container;
}
let portErrors = [];
let errorPanelCollapsed = false;
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_typeid;
nodeEl.addEventListener('click', () => scrollToNode(err.node_typeid));
item.appendChild(nodeEl);
const portEl = document.createElement('div');
portEl.className = 'error-port';
portEl.textContent = 'Port: ' + err.port_name;
item.appendChild(portEl);
const countsEl = document.createElement('div');
countsEl.className = 'error-counts';
countsEl.textContent = 'In: ' + err.in_errors + ' (+' + (err.in_delta || 0) + ') / Out: ' + err.out_errors + ' (+' + (err.out_delta || 0) + ')';
item.appendChild(countsEl);
const typeEl = document.createElement('div');
typeEl.className = 'error-type';
typeEl.textContent = err.error_type === 'startup' ? 'Present at startup' : '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);
});
}
function scrollToNode(typeid) {
const nodeEl = document.querySelector('.node[data-typeid="' + typeid + '"]');
if (nodeEl) {
nodeEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
nodeEl.classList.add('scroll-highlight');
setTimeout(() => nodeEl.classList.remove('scroll-highlight'), 1000);
}
}
async function clearError(id) {
await fetch('/api/errors/clear?id=' + encodeURIComponent(id), { method: 'POST' });
init();
}
async function clearAllErrors() {
await fetch('/api/errors/clear?all=true', { method: 'POST' });
init();
}
async function init() {
anonCounter = 0;
const [statusResp, configResp] = await Promise.all([
fetch('/api/status'),
fetch('/api/config')
]);
const data = await statusResp.json();
const config = await configResp.json();
const nodes = data.nodes || [];
const links = data.links || [];
portErrors = data.port_errors || [];
const errorNodeIds = new Set(portErrors.map(e => e.node_typeid));
const locationTree = buildLocationTree(config.locations || [], null);
const nodeIndex = new Map();
buildNodeIndex(locationTree, nodeIndex);
const nodesByTypeId = new Map();
nodes.forEach(node => {
nodesByTypeId.set(node.typeid, node);
});
const nodeLocations = new Map();
const assignedNodes = new Map();
const unassignedNodes = [];
nodes.forEach(node => {
const loc = findLocationForNode(node, nodeIndex);
if (loc) {
nodeLocations.set(node.typeid, 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?.typeid);
const nodeB = nodesByTypeId.get(link.node_b?.typeid);
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 || '?'
});
} else if (aIsSwitch && !bIsSwitch) {
const nodeLoc = nodeLocations.get(nodeB.typeid);
const effectiveSwitch = findEffectiveSwitch(nodeLoc, assignedNodes);
switchConnections.set(nodeB.typeid, {
port: link.interface_a || '?',
switchName: getLabel(nodeA),
external: !effectiveSwitch || effectiveSwitch.typeid !== nodeA.typeid
});
} else if (bIsSwitch && !aIsSwitch) {
const nodeLoc = nodeLocations.get(nodeA.typeid);
const effectiveSwitch = findEffectiveSwitch(nodeLoc, assignedNodes);
switchConnections.set(nodeA.typeid, {
port: link.interface_b || '?',
switchName: getLabel(nodeB),
external: !effectiveSwitch || effectiveSwitch.typeid !== nodeB.typeid
});
}
});
const danteFlows = data.dante_flows || [];
const danteNodes = new Map();
danteFlows.forEach(flow => {
const sourceId = flow.source?.typeid;
if (!sourceId) return;
if (!danteNodes.has(sourceId)) {
danteNodes.set(sourceId, { isTx: false, isRx: false, txTo: [], rxFrom: [] });
}
const sourceInfo = danteNodes.get(sourceId);
sourceInfo.isTx = true;
(flow.subscribers || []).forEach(sub => {
const subId = sub.node?.typeid;
if (!subId) return;
const subName = getShortLabel(sub.node);
const channels = sub.channels || [];
const channelSummary = channels.length > 0 ? '\n ' + channels.join('\n ') : '';
const txEntry = subName + channelSummary;
if (!sourceInfo.txTo.some(e => e.startsWith(subName))) {
sourceInfo.txTo.push(txEntry);
}
if (!danteNodes.has(subId)) {
danteNodes.set(subId, { isTx: false, isRx: false, txTo: [], rxFrom: [] });
}
const subInfo = danteNodes.get(subId);
subInfo.isRx = true;
const sourceName = getShortLabel(flow.source);
const rxChannelSummary = channels.length > 0 ? '\n ' + channels.join('\n ') : '';
const rxEntry = sourceName + rxChannelSummary;
if (!subInfo.rxFrom.some(e => e.startsWith(sourceName))) {
subInfo.rxFrom.push(rxEntry);
}
});
});
const switchUplinks = new Map();
if (allSwitches.length > 0 && switchLinks.length > 0) {
const adjacency = new Map();
allSwitches.forEach(sw => adjacency.set(sw.typeid, []));
switchLinks.forEach(link => {
adjacency.get(link.switchA.typeid).push({
neighbor: link.switchB,
localPort: link.portA,
remotePort: link.portB
});
adjacency.get(link.switchB.typeid).push({
neighbor: link.switchA,
localPort: link.portB,
remotePort: link.portA
});
});
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.typeid]);
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.typeid) || []) {
if (!visited.has(edge.neighbor.typeid)) {
visited.add(edge.neighbor.typeid);
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;
}
}
switchUplinks.set(bestRoot.typeid, 'ROOT');
const visited = new Set([bestRoot.typeid]);
const queue = [bestRoot];
while (queue.length > 0) {
const current = queue.shift();
for (const edge of adjacency.get(current.typeid) || []) {
if (!visited.has(edge.neighbor.typeid)) {
visited.add(edge.neighbor.typeid);
switchUplinks.set(edge.neighbor.typeid, {
localPort: edge.localPort,
remotePort: edge.remotePort,
parentName: getLabel(current)
});
queue.push(edge.neighbor);
}
}
}
}
const container = document.getElementById('container');
container.innerHTML = '';
locationTree.forEach(loc => {
const el = renderLocation(loc, assignedNodes, true, switchConnections, switchUplinks, danteNodes, errorNodeIds);
if (el) container.appendChild(el);
});
if (unassignedNodes.length > 0) {
const unassignedLoc = document.createElement('div');
unassignedLoc.className = 'location top-level';
const nameEl = document.createElement('div');
nameEl.className = 'location-name';
nameEl.textContent = 'Unassigned';
unassignedLoc.appendChild(nameEl);
const switches = unassignedNodes.filter(n => isSwitch(n));
const nonSwitches = unassignedNodes.filter(n => !isSwitch(n));
if (switches.length > 0) {
const switchRow = document.createElement('div');
switchRow.className = 'node-row';
switches.forEach(node => {
const uplink = switchUplinks.get(node.typeid);
const danteInfo = danteNodes.get(node.typeid);
const hasError = errorNodeIds.has(node.typeid);
switchRow.appendChild(createNodeElement(node, null, null, uplink, danteInfo, hasError));
});
unassignedLoc.appendChild(switchRow);
}
if (nonSwitches.length > 0) {
const nodeRow = document.createElement('div');
nodeRow.className = 'node-row';
nonSwitches.forEach(node => {
const conn = switchConnections.get(node.typeid);
const danteInfo = danteNodes.get(node.typeid);
const hasError = errorNodeIds.has(node.typeid);
nodeRow.appendChild(createNodeElement(node, conn, null, null, danteInfo, hasError));
});
unassignedLoc.appendChild(nodeRow);
}
container.appendChild(unassignedLoc);
}
updateErrorPanel();
}
init().catch(e => {
document.getElementById('error').textContent = e.message;
});
function setMode(mode) {
if (mode === 'dante') {
document.body.classList.add('dante-mode');
document.getElementById('mode-dante').classList.add('active');
document.getElementById('mode-network').classList.remove('active');
window.location.hash = 'dante';
} else {
document.body.classList.remove('dante-mode');
document.getElementById('mode-network').classList.add('active');
document.getElementById('mode-dante').classList.remove('active');
window.location.hash = '';
}
}
document.getElementById('mode-network').addEventListener('click', () => setMode('network'));
document.getElementById('mode-dante').addEventListener('click', () => setMode('dante'));
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');
errorPanelCollapsed = !errorPanelCollapsed;
if (errorPanelCollapsed) {
panel.classList.add('collapsed');
btn.textContent = 'Show';
} else {
panel.classList.remove('collapsed');
btn.textContent = 'Hide';
}
});
if (window.location.hash === '#dante') {
setMode('dante');
}
</script>
</body>
</html>