Move the port inspector onto device pages
Lint / JS (eslint) (push) Successful in 1m14s
Lint / Python (flake8) (push) Successful in 2m12s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m3s
Test / Python Tests (pytest) (push) Successful in 1m11s
Lint / Python (flake8) (pull_request) Successful in 1m1s
Lint / JS (eslint) (pull_request) Successful in 10s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 56s
Test / Python Tests (pytest) (pull_request) Successful in 1m5s
Lint / JS (eslint) (push) Successful in 1m14s
Lint / Python (flake8) (push) Successful in 2m12s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m3s
Test / Python Tests (pytest) (push) Successful in 1m11s
Lint / Python (flake8) (pull_request) Successful in 1m1s
Lint / JS (eslint) (pull_request) Successful in 10s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 56s
Test / Python Tests (pytest) (pull_request) Successful in 1m5s
Replace the all-switches Inspector page with a per-device front panel, port table and detail panel. /inspector now redirects to the dashboard, and /device/<name>#port-<n> links directly to a port. Restore neighbor names after UniFi's LLDP change: read local_port_idx and resolve neighbors by MAC using Prometheus NIC data, UniFi devices and the wired client table. This also brings back Run Diagnostics on server ports, which depends on a named neighbor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+33
-71
@@ -1,8 +1,8 @@
|
||||
'use strict';
|
||||
/* global DEVICE_NAME, deviceUrl, refreshModem, _toIso */
|
||||
/* global DEVICE_NAME, deviceUrl, refreshModem, _toIso, fmtRate, fmtErrors, renderPortInspector, selectPortByIdx, highlightSelectedPort */
|
||||
|
||||
// Per-device page: overview, alerts, and a compact interface/port table whose
|
||||
// rows expand to show everything the old Link Debug page showed.
|
||||
// Per-device page: overview, alerts, and a compact interface/port table. Host
|
||||
// rows expand in place; switch and router rows open the port inspector panel.
|
||||
|
||||
const esc = s => lt.escHtml(String(s === null || s === undefined ? '' : s));
|
||||
|
||||
@@ -12,26 +12,11 @@ let _query = '';
|
||||
let _device = null;
|
||||
|
||||
// ── Formatting ───────────────────────────────────────────────────────
|
||||
function fmtRate(bytesPerSec) {
|
||||
if (bytesPerSec === null || bytesPerSec === undefined) return '–';
|
||||
const bps = bytesPerSec * 8;
|
||||
if (bps < 1e3) return bps.toFixed(0) + ' bps';
|
||||
if (bps < 1e6) return (bps / 1e3).toFixed(1) + ' Kbps';
|
||||
if (bps < 1e9) return (bps / 1e6).toFixed(2) + ' Mbps';
|
||||
return (bps / 1e9).toFixed(3) + ' Gbps';
|
||||
}
|
||||
|
||||
function fmtSpeed(mbps) {
|
||||
if (!mbps) return '–';
|
||||
return mbps >= 1000 ? (mbps / 1000).toFixed(0) + ' Gbps' : mbps + ' Mbps';
|
||||
}
|
||||
|
||||
function fmtErrors(rate) {
|
||||
if (rate === null || rate === undefined) return '–';
|
||||
if (rate < 0.001) return '<span class="val-good">0 /s</span>';
|
||||
return `<span class="val-crit">${rate.toFixed(3)} /s</span>`;
|
||||
}
|
||||
|
||||
function fmtUnit(value, unit, digits) {
|
||||
return value === null || value === undefined ? '–' : `${value.toFixed(digits)} ${unit}`;
|
||||
}
|
||||
@@ -160,28 +145,14 @@ function portRows(ports) {
|
||||
return Object.entries(ports)
|
||||
.sort(([, a], [, b]) => (a.port_idx || 0) - (b.port_idx || 0))
|
||||
.map(([name, d]) => {
|
||||
const neighbor = d.lldp && d.lldp.system_name;
|
||||
return {
|
||||
id: name, label: name, num: d.port_idx, up: !!d.up, data: d,
|
||||
neighbor: d.lldp ? { name: neighbor, port: d.lldp.port_id } : null,
|
||||
neighbor: d.lldp ? { name: d.lldp.system_name, port: d.lldp.port_id, chassis: d.lldp.chassis_id } : null,
|
||||
sub: [d.is_uplink ? 'uplink' : '', d.media || '', d.poe_power ? `PoE ${d.poe_power.toFixed(1)} W` : ''].filter(Boolean).join(' · '),
|
||||
detail: `
|
||||
<div class="link-stats-grid">
|
||||
${stat('Duplex', d.full_duplex === null || d.full_duplex === undefined ? '–' : d.full_duplex ? 'Full' : 'Half')}
|
||||
${stat('Auto-neg', d.autoneg === null || d.autoneg === undefined ? '–' : d.autoneg ? 'On' : 'Off')}
|
||||
${stat('TX Err/s', fmtErrors(d.tx_errs_rate))}
|
||||
${stat('RX Err/s', fmtErrors(d.rx_errs_rate))}
|
||||
${stat('TX Drop/s', fmtErrors(d.tx_drops_rate))}
|
||||
${stat('RX Drop/s', fmtErrors(d.rx_drops_rate))}
|
||||
${d.poe_power || d.poe_max_power ? stat('PoE', `${esc(d.poe_class || '–')} · max ${fmtUnit(d.poe_max_power, 'W', 1)}`) : ''}
|
||||
</div>
|
||||
${d.lldp ? `<div class="port-lldp">LLDP: ${esc(d.lldp.system_name || '')} (${esc(d.lldp.port_id || '')})${d.lldp.chassis_id ? ` · ${esc(d.lldp.chassis_id)}` : ''}${(d.lldp.mgmt_ips || []).length ? ` · ${esc(d.lldp.mgmt_ips.join(', '))}` : ''}</div>` : ''}
|
||||
${trafficBars(d)}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Down alone isn't a problem: most down ports are simply unused.
|
||||
function rowHasProblem(r) {
|
||||
return !!badges(r.data);
|
||||
}
|
||||
@@ -196,14 +167,14 @@ function renderTable(rows) {
|
||||
if (!shown.length) return '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">Nothing matches this filter</div></div>';
|
||||
const isPorts = rows.some(r => r.num !== undefined);
|
||||
const body = shown.map(r => {
|
||||
const open = _openRows.has(r.id);
|
||||
const open = !isPorts && _openRows.has(r.id);
|
||||
const d = r.data;
|
||||
const neighbor = r.neighbor
|
||||
? (r.neighbor.name ? `<a class="device-link" href="${esc(deviceUrl(r.neighbor.name))}">${esc(r.neighbor.name)}</a>` : esc(r.neighbor.port || ''))
|
||||
? (r.neighbor.name ? `<a class="device-link" href="${esc(deviceUrl(r.neighbor.name))}">${esc(r.neighbor.name)}</a>` : esc(r.neighbor.chassis || r.neighbor.port || ''))
|
||||
: '–';
|
||||
return `
|
||||
<tr class="device-row${r.up ? '' : ' device-row--down'}">
|
||||
<td><button type="button" class="device-row-toggle" data-row="${esc(r.id)}" aria-expanded="${open}" aria-label="Details for ${esc(r.label)}">${open ? '▾' : '▸'}</button></td>
|
||||
<tr class="device-row${r.up ? '' : ' device-row--down'}"${isPorts ? ` data-port-idx="${esc(r.num)}"` : ''}>
|
||||
<td><button type="button" class="device-row-toggle" data-row="${esc(r.id)}"${isPorts ? '' : ` aria-expanded="${open}"`} aria-label="Details for ${esc(r.label)}">${open ? '▾' : '▸'}</button></td>
|
||||
<td><span class="dot-${r.up ? 'up' : 'down'}"></span></td>
|
||||
<td class="device-row-name">${r.num !== undefined ? `<span class="port-badge port-badge-num">#${esc(r.num)}</span> ` : ''}${esc(r.label)}<div class="device-row-sub">${esc(r.sub)}</div></td>
|
||||
<td class="device-num">${r.up ? fmtSpeed(d.speed_mbps) : '–'}</td>
|
||||
@@ -212,7 +183,7 @@ function renderTable(rows) {
|
||||
${isPorts ? `<td class="device-col-wide">${neighbor}</td>` : ''}
|
||||
<td>${badges(d) || (r.up ? '<span class="val-good">OK</span>' : '<span class="device-row-sub">down</span>')}</td>
|
||||
</tr>
|
||||
${open ? `<tr class="device-row-detail"><td colspan="${isPorts ? 8 : 7}">${r.detail}</td></tr>` : ''}`;
|
||||
${open ? `<tr class="device-row-detail"><td colspan="7">${r.detail}</td></tr>` : ''}`;
|
||||
}).join('');
|
||||
return `
|
||||
<div class="lt-table-wrap">
|
||||
@@ -272,7 +243,7 @@ function renderUplinks(d) {
|
||||
<td><span class="dot-${u.up ? 'up' : 'down'}"></span></td>
|
||||
<td>${esc(u.host_interface || '–')}</td>
|
||||
<td><a class="device-link" href="${esc(deviceUrl(u.switch))}">${esc(u.switch)}</a></td>
|
||||
<td>#${esc(u.port_idx)} ${esc(u.port)}</td>
|
||||
<td><a class="device-link" href="${esc(deviceUrl(u.switch))}#port-${esc(u.port_idx)}">#${esc(u.port_idx)} ${esc(u.port)}</a></td>
|
||||
<td>${fmtSpeed(u.speed_mbps)}</td>
|
||||
</tr>`).join('');
|
||||
return section('Connected to', `<div class="lt-table-wrap"><table class="lt-table">
|
||||
@@ -341,27 +312,19 @@ function currentRows() {
|
||||
|
||||
function renderInterfaces() {
|
||||
const wrap = document.getElementById('device-table-wrap');
|
||||
if (wrap) wrap.innerHTML = renderTable(currentRows());
|
||||
if (!wrap) return;
|
||||
wrap.innerHTML = renderTable(currentRows());
|
||||
highlightSelectedPort();
|
||||
}
|
||||
|
||||
function renderInterfaceSection(rows, title) {
|
||||
if (!rows.length) return '';
|
||||
const up = rows.filter(r => r.up).length;
|
||||
const problems = rows.filter(rowHasProblem).length;
|
||||
const pill = (value, label) => `<button type="button" class="pill${_filter === value ? ' active' : ''}" data-device-filter="${value}" aria-pressed="${_filter === value}">${label}</button>`;
|
||||
return section(`${title} · ${up} of ${rows.length} up`, `
|
||||
<div class="lt-toolbar device-toolbar">
|
||||
<div class="lt-toolbar-left">
|
||||
<div class="lt-search">
|
||||
<input type="search" class="lt-input lt-search-input lt-search-input--sm" id="device-table-search"
|
||||
placeholder="Filter…" autocomplete="off" aria-label="Filter ${esc(title.toLowerCase())}" value="${esc(_query)}">
|
||||
</div>
|
||||
<div class="sev-pills" role="group" aria-label="Filter by state">
|
||||
${pill('all', 'All')}${pill('up', 'Up')}${pill('problems', `Errors (${problems})`)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="device-table-wrap"></div>`);
|
||||
function updateInterfaceSection(rows, title) {
|
||||
const section = document.getElementById('device-ports');
|
||||
section.hidden = !rows.length;
|
||||
if (!rows.length) return;
|
||||
document.getElementById('device-ports-title').textContent =
|
||||
`${title} · ${rows.filter(r => r.up).length} of ${rows.length} up`;
|
||||
document.getElementById('device-table-search').setAttribute('aria-label', `Filter ${title.toLowerCase()}`);
|
||||
document.getElementById('device-error-count').textContent = rows.filter(rowHasProblem).length;
|
||||
}
|
||||
|
||||
function render(d) {
|
||||
@@ -373,29 +336,22 @@ function render(d) {
|
||||
|
||||
const unifi = d.source === 'unifi';
|
||||
document.getElementById('device-actions').innerHTML = `
|
||||
${d.ports && Object.keys(d.ports).length ? `<a class="lt-btn lt-btn-secondary lt-btn-sm" href="/inspector">⬡ Inspector</a>` : ''}
|
||||
<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress" data-sup-type="${unifi ? 'unifi_device' : 'host'}"
|
||||
data-sup-name="${esc(d.name)}" data-sup-detail="" aria-label="Suppress alerts for ${esc(d.name)}">🔕 Suppress</button>`;
|
||||
|
||||
// Keep focus and caret in the filter box across auto-refresh
|
||||
const active = document.activeElement && document.activeElement.id === 'device-table-search';
|
||||
const caret = active ? document.activeElement.selectionStart : 0;
|
||||
|
||||
const rows = currentRows();
|
||||
document.getElementById('device-root').innerHTML =
|
||||
renderOverview(d) +
|
||||
renderEvents(d.events) +
|
||||
renderWan(d) +
|
||||
renderUplinks(d) +
|
||||
renderOutlets(d) +
|
||||
renderInterfaceSection(rows, d.ports ? 'Ports' : 'Interfaces');
|
||||
renderOutlets(d);
|
||||
|
||||
const hasPorts = !!(d.ports && Object.keys(d.ports).length);
|
||||
document.getElementById('device-inspector').classList.toggle('has-chassis', hasPorts);
|
||||
updateInterfaceSection(currentRows(), d.ports ? 'Ports' : 'Interfaces');
|
||||
if (hasPorts) renderPortInspector(d);
|
||||
renderInterfaces();
|
||||
|
||||
if (active) {
|
||||
const box = document.getElementById('device-table-search');
|
||||
box.focus();
|
||||
box.setSelectionRange(caret, caret);
|
||||
}
|
||||
if (d.kind === 'modem') refreshModem();
|
||||
}
|
||||
|
||||
@@ -418,6 +374,12 @@ async function loadDevice() {
|
||||
}
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
// Switch and router rows open the shared port panel, except when following a link
|
||||
const portRow = e.target.closest('tr.device-row[data-port-idx]');
|
||||
if (portRow && !e.target.closest('a')) {
|
||||
selectPortByIdx(_device.name, parseInt(portRow.dataset.portIdx, 10));
|
||||
return;
|
||||
}
|
||||
const toggle = e.target.closest('.device-row-toggle');
|
||||
if (toggle) {
|
||||
const id = toggle.dataset.row;
|
||||
|
||||
@@ -0,0 +1,801 @@
|
||||
'use strict';
|
||||
/* global deviceUrl */
|
||||
/* exported renderPortInspector, fmtRate, fmtErrors */
|
||||
|
||||
// Port inspector for a switch or router's device page: front-panel diagram,
|
||||
// port detail panel, switch/server path check, and link diagnostics.
|
||||
|
||||
const escHtml = s => lt.escHtml(s);
|
||||
|
||||
// ── Switch layout config ─────────────────────────────────────────────────
|
||||
// keys match the model field returned by the UniFi API
|
||||
// rows: array of rows, each row is an array of port_idx values
|
||||
// sfp_ports: port_idx values that are SFP cages (in rows, rendered differently)
|
||||
// sfp_section: port_idx values rendered as a separate SFP bank below the rows
|
||||
const SWITCH_LAYOUTS = {
|
||||
'USF5P': { rows: [[1,2,3,4]], sfp_section: [5] },
|
||||
'USL8A': { rows: [[1,2,3,4],[5,6,7,8]], sfp_ports: [1,2,3,4,5,6,7,8] },
|
||||
'US24PRO': {
|
||||
rows: [
|
||||
[1,3,5,7,9,11,13,15,17,19,21,23],
|
||||
[2,4,6,8,10,12,14,16,18,20,22,24],
|
||||
],
|
||||
sfp_section: [25,26],
|
||||
},
|
||||
'USPPDUP': { rows: [[1]] },
|
||||
'USMINI': { rows: [[1,2,3,4,5]] },
|
||||
'UDMPRO': { rows: [[1,2,3,4,5,6,7,8,9]], sfp_section: [10,11] },
|
||||
};
|
||||
|
||||
// ── Formatting helpers ───────────────────────────────────────────────────
|
||||
function fmtShortSpeed(mbps) {
|
||||
if (!mbps) return '–';
|
||||
if (mbps >= 1000) return (mbps / 1000).toFixed(0) + 'G';
|
||||
return mbps + 'M';
|
||||
}
|
||||
|
||||
function fmtRate(bytesPerSec) {
|
||||
if (bytesPerSec === null || bytesPerSec === undefined) return '–';
|
||||
const bps = bytesPerSec * 8;
|
||||
if (bps < 1e3) return bps.toFixed(0) + ' bps';
|
||||
if (bps < 1e6) return (bps / 1e3).toFixed(1) + ' Kbps';
|
||||
if (bps < 1e9) return (bps / 1e6).toFixed(2) + ' Mbps';
|
||||
return (bps / 1e9).toFixed(3) + ' Gbps';
|
||||
}
|
||||
|
||||
function fmtErrors(rate) {
|
||||
if (rate === null || rate === undefined) return '–';
|
||||
if (rate < 0.001) return '<span class="val-good">0 /s</span>';
|
||||
return `<span class="val-crit">${rate.toFixed(3)} /s</span>`;
|
||||
}
|
||||
|
||||
// ── Build port_idx → port data map ──────────────────────────────────────
|
||||
function buildPortIdxMap(ports) {
|
||||
const map = {};
|
||||
for (const [pname, d] of Object.entries(ports)) {
|
||||
if (d.port_idx != null) {
|
||||
map[d.port_idx] = Object.assign({ name: pname }, d);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// ── Determine port block CSS state class ────────────────────────────────
|
||||
function portBlockState(d) {
|
||||
if (!d || !d.up) return 'down';
|
||||
if (d.is_uplink) return 'uplink';
|
||||
if (d.poe_power != null && d.poe_power > 0) return 'poe-active';
|
||||
return 'up';
|
||||
}
|
||||
|
||||
// ── Speed label helper ───────────────────────────────────────────────────
|
||||
function portSpeedLabel(port) {
|
||||
if (!port || !port.up) return '';
|
||||
const spd = port.speed_mbps;
|
||||
if (!spd) return '';
|
||||
if (spd >= 10000) return '10G';
|
||||
if (spd >= 1000) return '1G';
|
||||
if (spd >= 100) return '100M';
|
||||
return spd + 'M';
|
||||
}
|
||||
|
||||
// Blocks only fit a few characters of the neighbor, so hover text carries it in full
|
||||
function portTitle(port, idx) {
|
||||
if (!port) return `Port ${idx}`;
|
||||
const neighbor = port.lldp && (port.lldp.system_name || port.lldp.chassis_id);
|
||||
return escHtml(neighbor ? `${port.name} → ${neighbor}` : port.name);
|
||||
}
|
||||
|
||||
// ── Render a single port block element ──────────────────────────────────
|
||||
function portBlockHtml(idx, port, swName, sfpBlock) {
|
||||
const state = portBlockState(port);
|
||||
const numLabel = sfpBlock ? 'SFP' : idx;
|
||||
const title = portTitle(port, idx);
|
||||
const sfpCls = sfpBlock ? ' sfp-block' : '';
|
||||
const speedTxt = portSpeedLabel(port);
|
||||
// LLDP neighbor: first 6 chars of hostname
|
||||
const lldpName = (port && port.lldp && (port.lldp.system_name || port.lldp.chassis_id))
|
||||
? escHtml((port.lldp.system_name || port.lldp.chassis_id || '').slice(0, 6))
|
||||
: '';
|
||||
const lldpHtml = lldpName ? `<span class="port-lldp">${lldpName}</span>` : '';
|
||||
const speedHtml = speedTxt ? `<span class="port-speed">${speedTxt}</span>` : '';
|
||||
return `<div class="switch-port-block ${state}${sfpCls}"
|
||||
data-switch="${escHtml(swName)}" data-port-idx="${idx}"
|
||||
title="${title}" aria-label="${title}"
|
||||
data-action="select-port" role="button" tabindex="0"><span class="port-num">${numLabel}</span>${speedHtml}${lldpHtml}</div>`;
|
||||
}
|
||||
|
||||
// ── Chassis legend HTML ──────────────────────────────────────────────────
|
||||
function chassisLegendHtml() {
|
||||
return `<div class="chassis-legend">
|
||||
<div class="chassis-legend-item"><span class="chassis-legend-swatch cls-down"></span>down</div>
|
||||
<div class="chassis-legend-item"><span class="chassis-legend-swatch cls-up"></span>up</div>
|
||||
<div class="chassis-legend-item"><span class="chassis-legend-swatch cls-poe"></span>poe active</div>
|
||||
<div class="chassis-legend-item"><span class="chassis-legend-swatch cls-uplink"></span>uplink</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Render one switch chassis ────────────────────────────────────────────
|
||||
function renderChassis(swName, sw) {
|
||||
const model = sw.model || '';
|
||||
const layout = SWITCH_LAYOUTS[model] || null;
|
||||
const portMap = buildPortIdxMap(sw.ports || {});
|
||||
|
||||
const upCount = Object.values(sw.ports || {}).filter(p => p.up).length;
|
||||
const totCount = Object.keys(sw.ports || {}).length;
|
||||
const downCount = totCount - upCount;
|
||||
const meta = [model, `${upCount}/${totCount} up`, downCount ? `${downCount} down` : ''].filter(Boolean).join(' · ');
|
||||
|
||||
// Is this a US24PRO? Used to add group-separator class
|
||||
const isUs24Pro = (model === 'US24PRO');
|
||||
|
||||
let chassisHtml = '';
|
||||
|
||||
if (layout) {
|
||||
const sfpPortSet = new Set(layout.sfp_ports || []);
|
||||
const sfpSectionSet = new Set(layout.sfp_section || []);
|
||||
|
||||
// Main port rows
|
||||
chassisHtml += '<div class="chassis-rows">';
|
||||
for (const row of layout.rows) {
|
||||
const rowCls = isUs24Pro ? ' us24pro-row' : '';
|
||||
chassisHtml += `<div class="chassis-row${rowCls}">`;
|
||||
for (const idx of row) {
|
||||
const port = portMap[idx];
|
||||
const isSfp = sfpPortSet.has(idx);
|
||||
const sfpCls = isSfp ? ' sfp-port' : '';
|
||||
const state = portBlockState(port);
|
||||
const title = portTitle(port, idx);
|
||||
const speedTxt = portSpeedLabel(port);
|
||||
const lldpName = (port && port.lldp && (port.lldp.system_name || port.lldp.chassis_id))
|
||||
? escHtml((port.lldp.system_name || port.lldp.chassis_id || '').slice(0, 6))
|
||||
: '';
|
||||
const speedHtml = speedTxt ? `<span class="port-speed">${speedTxt}</span>` : '';
|
||||
const lldpHtml = lldpName ? `<span class="port-lldp">${lldpName}</span>` : '';
|
||||
chassisHtml += `<div class="switch-port-block ${state}${sfpCls}"
|
||||
data-switch="${escHtml(swName)}" data-port-idx="${idx}"
|
||||
title="${title}" aria-label="${title}"
|
||||
data-action="select-port" role="button" tabindex="0"><span class="port-num">${idx}</span>${speedHtml}${lldpHtml}</div>`;
|
||||
}
|
||||
chassisHtml += '</div>';
|
||||
}
|
||||
chassisHtml += '</div>';
|
||||
|
||||
// Separate SFP section (if present)
|
||||
if (sfpSectionSet.size) {
|
||||
chassisHtml += '<div class="chassis-sfp-section">';
|
||||
for (const idx of layout.sfp_section) {
|
||||
chassisHtml += portBlockHtml(idx, portMap[idx], swName, true);
|
||||
}
|
||||
chassisHtml += '</div>';
|
||||
}
|
||||
} else {
|
||||
// Fallback: render all ports sorted by idx
|
||||
const allPorts = Object.entries(sw.ports || {})
|
||||
.sort(([, a], [, b]) => (a.port_idx || 0) - (b.port_idx || 0));
|
||||
chassisHtml += '<div class="chassis-rows"><div class="chassis-row">';
|
||||
for (const [pname, d] of allPorts) {
|
||||
chassisHtml += portBlockHtml(d.port_idx || 0, Object.assign({ name: pname }, d), swName, false);
|
||||
}
|
||||
chassisHtml += '</div></div>';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="inspector-chassis">
|
||||
<div class="chassis-header">
|
||||
<span class="chassis-name">Front panel</span>
|
||||
<span class="chassis-meta">${escHtml(meta)}</span>
|
||||
</div>
|
||||
<div class="chassis-body">
|
||||
<div class="chassis-ear-l"></div>
|
||||
<div class="chassis-ear-r"></div>
|
||||
${chassisHtml}
|
||||
</div>
|
||||
${chassisLegendHtml()}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── State ────────────────────────────────────────────────────────────────
|
||||
let _selectedSwitch = null;
|
||||
let _selectedIdx = null;
|
||||
let _apiData = null;
|
||||
let _diagPollTimer = null;
|
||||
|
||||
// ── Port selection ───────────────────────────────────────────────────────
|
||||
function selectPort(el) {
|
||||
selectPortByIdx(el.dataset.switch, parseInt(el.dataset.portIdx, 10));
|
||||
}
|
||||
|
||||
function selectPortByIdx(swName, idx) {
|
||||
const sw = _apiData && _apiData.unifi_switches[swName];
|
||||
if (!sw || !buildPortIdxMap(sw.ports || {})[idx]) return;
|
||||
if (_diagPollTimer) { clearInterval(_diagPollTimer); _diagPollTimer = null; }
|
||||
_selectedSwitch = swName;
|
||||
_selectedIdx = idx;
|
||||
renderPanel(swName, idx);
|
||||
highlightSelectedPort();
|
||||
history.replaceState(null, '', `#port-${idx}`);
|
||||
// On narrow screens the panel sits below the front panel, possibly off screen
|
||||
if (window.matchMedia('(max-width: 900px)').matches) {
|
||||
document.getElementById('inspector-panel').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
if (_diagPollTimer) { clearInterval(_diagPollTimer); _diagPollTimer = null; }
|
||||
document.getElementById('inspector-panel').classList.remove('open');
|
||||
_selectedSwitch = null;
|
||||
_selectedIdx = null;
|
||||
highlightSelectedPort();
|
||||
history.replaceState(null, '', window.location.pathname + window.location.search);
|
||||
}
|
||||
|
||||
// ── Render detail panel ──────────────────────────────────────────────────
|
||||
function renderPanel(swName, idx) {
|
||||
if (!_apiData) return;
|
||||
const sw = _apiData.unifi_switches && _apiData.unifi_switches[swName];
|
||||
if (!sw) return;
|
||||
const portMap = buildPortIdxMap(sw.ports || {});
|
||||
const d = portMap[idx];
|
||||
if (!d) return;
|
||||
|
||||
const upStr = d.up ? '<span class="val-good">UP</span>' : '<span class="val-crit">DOWN</span>';
|
||||
const speedStr = d.speed_mbps ? `<span class="val-cyan">${fmtShortSpeed(d.speed_mbps)}bps</span>` : '–';
|
||||
const duplexCls = d.full_duplex ? 'val-good' : (d.up ? 'val-warn' : 'val-neutral');
|
||||
const duplexStr = d.up ? `<span class="${duplexCls}">${d.full_duplex ? 'Full' : 'Half'}</span>` : '–';
|
||||
const autoneg = d.autoneg ? 'On' : 'Off';
|
||||
const mediaStr = d.media || '–';
|
||||
const isUplinkBadge = d.is_uplink ? ' <span class="port-badge port-badge-uplink">UPLINK</span>' : '';
|
||||
|
||||
// PoE section
|
||||
let poeHtml = '';
|
||||
if (d.poe_power || d.poe_max_power) {
|
||||
const poeMaxStr = d.poe_max_power != null ? ` / max ${d.poe_max_power.toFixed(1)}W` : '';
|
||||
poeHtml = `
|
||||
<div class="lt-divider"><span class="lt-divider-label">PoE</span></div>
|
||||
<div class="panel-row"><span class="panel-label">Class</span><span class="panel-val">class ${escHtml(String(d.poe_class))}${poeMaxStr}</span></div>
|
||||
${d.poe_power != null ? `<div class="panel-row"><span class="panel-label">Draw</span><span class="panel-val">${d.poe_power > 0 ? `<span class="val-amber">${d.poe_power.toFixed(1)}W</span>` : '0W'}</span></div>` : ''}
|
||||
${d.poe_mode ? `<div class="panel-row"><span class="panel-label">Mode</span><span class="panel-val">${escHtml(d.poe_mode)}</span></div>` : ''}`;
|
||||
}
|
||||
|
||||
// Traffic section
|
||||
let trafficHtml = '';
|
||||
if (d.tx_bytes_rate != null || d.rx_bytes_rate != null) {
|
||||
trafficHtml = `
|
||||
<div class="lt-divider"><span class="lt-divider-label">Traffic</span></div>
|
||||
<div class="panel-row"><span class="panel-label" data-tooltip="Transmit — outgoing from this port">TX</span><span class="panel-val">${fmtRate(d.tx_bytes_rate)}</span></div>
|
||||
<div class="panel-row"><span class="panel-label" data-tooltip="Receive — incoming to this port">RX</span><span class="panel-val">${fmtRate(d.rx_bytes_rate)}</span></div>`;
|
||||
}
|
||||
|
||||
// Errors / drops section
|
||||
let errHtml = '';
|
||||
if (d.tx_errs_rate != null || d.rx_errs_rate != null) {
|
||||
errHtml = `
|
||||
<div class="lt-divider"><span class="lt-divider-label">Errors / Drops</span></div>
|
||||
<div class="panel-row"><span class="panel-label">TX Err</span><span class="panel-val">${fmtErrors(d.tx_errs_rate)}</span></div>
|
||||
<div class="panel-row"><span class="panel-label">RX Err</span><span class="panel-val">${fmtErrors(d.rx_errs_rate)}</span></div>
|
||||
<div class="panel-row"><span class="panel-label">TX Drop</span><span class="panel-val">${fmtErrors(d.tx_drops_rate)}</span></div>
|
||||
<div class="panel-row"><span class="panel-label">RX Drop</span><span class="panel-val">${fmtErrors(d.rx_drops_rate)}</span></div>`;
|
||||
}
|
||||
|
||||
// LLDP + path debug
|
||||
let lldpHtml = '';
|
||||
let pathHtml = '';
|
||||
if (d.lldp) {
|
||||
const l = d.lldp;
|
||||
lldpHtml = `
|
||||
<div class="lt-divider"><span class="lt-divider-label">${l.source === 'unifi-clients' ? 'Neighbor (UniFi client table)' : 'LLDP Neighbor'}</span></div>
|
||||
${l.system_name ? `<div class="panel-row"><span class="panel-label">System</span><span class="panel-val val-cyan"><a class="device-link" href="${escHtml(deviceUrl(l.system_name))}">${escHtml(l.system_name)}</a></span></div>` : ''}
|
||||
${l.port_id ? `<div class="panel-row"><span class="panel-label">Port</span><span class="panel-val">${escHtml(l.port_id)}</span></div>` : ''}
|
||||
${l.port_desc ? `<div class="panel-row"><span class="panel-label">Port Desc</span><span class="panel-val">${escHtml(l.port_desc)}</span></div>` : ''}
|
||||
${l.chassis_id ? `<div class="panel-row"><span class="panel-label">Chassis</span><span class="panel-val">${escHtml(l.chassis_id)}</span></div>` : ''}
|
||||
${l.mgmt_ips && l.mgmt_ips.length ? `<div class="panel-row"><span class="panel-label">Mgmt IP</span><span class="panel-val">${escHtml(l.mgmt_ips.join(', '))}</span></div>` : ''}`;
|
||||
|
||||
// Path debug: look for matching server interface
|
||||
const hosts = _apiData.hosts || {};
|
||||
const serverIfaces = hosts[l.system_name];
|
||||
if (serverIfaces) {
|
||||
let matchedIface = l.port_id && serverIfaces[l.port_id] ? l.port_id : null;
|
||||
if (!matchedIface && l.port_id) {
|
||||
// fuzzy match
|
||||
matchedIface = Object.keys(serverIfaces).find(k => l.port_id.includes(k) || k.includes(l.port_id)) || null;
|
||||
}
|
||||
if (matchedIface) {
|
||||
pathHtml = buildPathDebug(swName, d, l.system_name, matchedIface, serverIfaces[matchedIface]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnose button (only when LLDP has an identified neighbor we can map)
|
||||
const hasDiagTarget = !!(d.lldp && d.lldp.system_name &&
|
||||
_apiData.hosts && _apiData.hosts[d.lldp.system_name]);
|
||||
const diagHtml = hasDiagTarget ? `
|
||||
<div class="diag-bar">
|
||||
<button class="btn-diag lt-btn lt-btn-secondary lt-btn-sm" data-action="run-diagnostic"
|
||||
data-sw="${escHtml(swName)}" data-idx="${idx}"
|
||||
aria-label="Run link diagnostics for port ${idx} on ${escHtml(swName)}">Run Diagnostics</button>
|
||||
<span class="diag-status" id="diag-status"></span>
|
||||
</div>
|
||||
<div class="diag-results" id="diag-results"></div>` : '';
|
||||
|
||||
// Auto-refresh re-renders this panel. A running poll holds these elements,
|
||||
// and results may be on screen, so carry them over for the same port.
|
||||
const inner = document.getElementById('inspector-panel-inner');
|
||||
const panelKey = `${swName}#${idx}`;
|
||||
const samePort = inner.dataset.port === panelKey;
|
||||
const keptStatus = samePort ? document.getElementById('diag-status') : null;
|
||||
const keptResults = samePort ? document.getElementById('diag-results') : null;
|
||||
const scrollTop = samePort ? inner.scrollTop : 0;
|
||||
inner.dataset.port = panelKey;
|
||||
inner.innerHTML = `
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<span class="panel-port-name">${escHtml(d.name)}</span>${isUplinkBadge}
|
||||
<div class="panel-meta">${escHtml(swName)} · port #${idx}</div>
|
||||
</div>
|
||||
<button class="panel-close" data-action="close-panel" aria-label="Close panel">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="lt-divider"><span class="lt-divider-label">Link</span></div>
|
||||
<div class="panel-row"><span class="panel-label">Status</span><span class="panel-val">${upStr}</span></div>
|
||||
<div class="panel-row"><span class="panel-label">Speed</span><span class="panel-val">${speedStr}</span></div>
|
||||
<div class="panel-row"><span class="panel-label" data-tooltip="Full = simultaneous send/receive. Half = one direction at a time.">Duplex</span><span class="panel-val">${duplexStr}</span></div>
|
||||
<div class="panel-row"><span class="panel-label" data-tooltip="Autonegotiation: NIC and switch automatically agree speed and duplex.">Auto-neg</span><span class="panel-val val-neutral">${autoneg}</span></div>
|
||||
<div class="panel-row"><span class="panel-label">Media</span><span class="panel-val">${escHtml(mediaStr)}</span></div>
|
||||
|
||||
${poeHtml}
|
||||
${trafficHtml}
|
||||
${errHtml}
|
||||
${lldpHtml}
|
||||
${pathHtml}
|
||||
${diagHtml}
|
||||
`;
|
||||
if (keptStatus) document.getElementById('diag-status')?.replaceWith(keptStatus);
|
||||
if (keptResults) document.getElementById('diag-results')?.replaceWith(keptResults);
|
||||
inner.scrollTop = scrollTop;
|
||||
|
||||
document.getElementById('inspector-panel').classList.add('open');
|
||||
}
|
||||
|
||||
// ── Build path debug two-column section ─────────────────────────────────
|
||||
function buildPathDebug(swName, swPort, serverName, ifaceName, svrData) {
|
||||
const isFiber = (swPort.media || '').toLowerCase().includes('sfp') ||
|
||||
(svrData.port_type || '').toLowerCase().includes('fibre') ||
|
||||
(svrData.port_type || '').toLowerCase().includes('fiber') ||
|
||||
!!svrData.sfp;
|
||||
const connType = isFiber ? 'SFP / Fiber' : 'Copper';
|
||||
|
||||
let sfpDomHtml = '';
|
||||
if (svrData.sfp && Object.keys(svrData.sfp).length) {
|
||||
const sfp = svrData.sfp;
|
||||
sfpDomHtml = '<div class="path-dom">';
|
||||
if (sfp.vendor) sfpDomHtml += `<div class="path-dom-row"><span>Vendor</span><span>${escHtml(sfp.vendor)}${sfp.part_no ? ' / ' + escHtml(sfp.part_no) : ''}</span></div>`;
|
||||
if (sfp.temp_c != null) sfpDomHtml += `<div class="path-dom-row"><span>Temp</span><span>${sfp.temp_c.toFixed(1)}°C</span></div>`;
|
||||
if (sfp.tx_power_dbm != null) sfpDomHtml += `<div class="path-dom-row"><span>TX</span><span>${sfp.tx_power_dbm.toFixed(2)} dBm</span></div>`;
|
||||
if (sfp.rx_power_dbm != null) sfpDomHtml += `<div class="path-dom-row"><span>RX</span><span>${sfp.rx_power_dbm.toFixed(2)} dBm</span></div>`;
|
||||
sfpDomHtml += '</div>';
|
||||
}
|
||||
|
||||
const svrErrTx = (svrData.tx_errs_rate > 0.001) ? 'val-crit' : 'val-good';
|
||||
const svrErrRx = (svrData.rx_errs_rate > 0.001) ? 'val-crit' : 'val-good';
|
||||
const swErrTx = (swPort.tx_errs_rate > 0.001) ? 'val-crit' : 'val-good';
|
||||
const swErrRx = (swPort.rx_errs_rate > 0.001) ? 'val-crit' : 'val-good';
|
||||
|
||||
// Detect duplex mismatch (switch full_duplex vs server duplex string)
|
||||
const swFull = swPort.full_duplex;
|
||||
const svrFull = (svrData.duplex || '').toLowerCase().includes('full');
|
||||
const duplexMismatch = swPort.up && svrData.duplex &&
|
||||
((swFull && !svrFull) || (!swFull && svrFull));
|
||||
const duplexWarnHtml = duplexMismatch
|
||||
? `<div class="path-mismatch-alert">⚠ DUPLEX MISMATCH — Switch: ${swFull ? 'Full' : 'Half'} · Server: ${escHtml(svrData.duplex)}</div>`
|
||||
: '';
|
||||
|
||||
// Detect speed mismatch
|
||||
const swSpd = swPort.speed_mbps, svrSpd = svrData.speed_mbps;
|
||||
const speedMismatch = swSpd && svrSpd && swSpd > 0 && svrSpd > 0 && swSpd !== svrSpd;
|
||||
const speedWarnHtml = speedMismatch
|
||||
? `<div class="path-mismatch-alert">⚠ SPEED MISMATCH — Switch: ${fmtShortSpeed(swSpd)} · Server: ${fmtShortSpeed(svrSpd)}</div>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="lt-divider"><span class="lt-divider-label">Path Debug · ${escHtml(connType)}</span></div>
|
||||
${duplexWarnHtml}${speedWarnHtml}
|
||||
<div class="path-debug-cols">
|
||||
<div class="path-col">
|
||||
<div class="path-col-header">Switch</div>
|
||||
<div class="path-row"><span>Port</span><span>${escHtml(swPort.name)}</span></div>
|
||||
<div class="path-row"><span>Speed</span><span>${fmtShortSpeed(swPort.speed_mbps)}bps</span></div>
|
||||
<div class="path-row"><span>TX</span><span>${fmtRate(swPort.tx_bytes_rate)}</span></div>
|
||||
<div class="path-row"><span>RX</span><span>${fmtRate(swPort.rx_bytes_rate)}</span></div>
|
||||
<div class="path-row"><span>TX Err</span><span class="${swErrTx}">${fmtErrors(swPort.tx_errs_rate)}</span></div>
|
||||
<div class="path-row"><span>RX Err</span><span class="${swErrRx}">${fmtErrors(swPort.rx_errs_rate)}</span></div>
|
||||
${(swPort.poe_power != null && swPort.poe_power > 0) ? `<div class="path-row"><span>PoE</span><span class="val-amber">${swPort.poe_power.toFixed(1)}W</span></div>` : ''}
|
||||
</div>
|
||||
<div class="path-col">
|
||||
<div class="path-col-header">Server: ${escHtml(serverName)}</div>
|
||||
<div class="path-row"><span>Iface</span><span>${escHtml(ifaceName)}</span></div>
|
||||
<div class="path-row"><span>Speed</span><span>${svrData.speed_mbps ? fmtShortSpeed(svrData.speed_mbps) + 'bps' : '–'}</span></div>
|
||||
<div class="path-row"><span>TX</span><span>${fmtRate(svrData.tx_bytes_rate)}</span></div>
|
||||
<div class="path-row"><span>RX</span><span>${fmtRate(svrData.rx_bytes_rate)}</span></div>
|
||||
<div class="path-row"><span>TX Err</span><span class="${svrErrTx}">${fmtErrors(svrData.tx_errs_rate)}</span></div>
|
||||
<div class="path-row"><span>RX Err</span><span class="${svrErrRx}">${fmtErrors(svrData.rx_errs_rate)}</span></div>
|
||||
${svrData.carrier_changes != null ? `<div class="path-row"><span>Carrier Chg</span><span class="${(svrData.carrier_changes||0)>10?'val-crit':(svrData.carrier_changes||0)>2?'val-warn':'val-good'}">${svrData.carrier_changes}</span></div>` : ''}
|
||||
${sfpDomHtml}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Page integration: device.js supplies this one device's data ──────────
|
||||
function renderPortInspector(detail) {
|
||||
_apiData = {
|
||||
unifi_switches: { [detail.name]: { ip: detail.ip, model: detail.model, ports: detail.ports || {} } },
|
||||
hosts: detail.neighbor_hosts || {},
|
||||
};
|
||||
const chassis = document.getElementById('device-chassis');
|
||||
chassis.innerHTML = renderChassis(detail.name, _apiData.unifi_switches[detail.name]);
|
||||
if (_selectedSwitch === detail.name && _selectedIdx !== null) {
|
||||
renderPanel(_selectedSwitch, _selectedIdx);
|
||||
} else if (_selectedSwitch === null) {
|
||||
openPortFromHash(detail.name);
|
||||
}
|
||||
highlightSelectedPort();
|
||||
}
|
||||
|
||||
function openPortFromHash(swName) {
|
||||
const m = /^#port-(\d+)$/.exec(window.location.hash);
|
||||
if (m) selectPortByIdx(swName, parseInt(m[1], 10));
|
||||
}
|
||||
|
||||
function highlightSelectedPort() {
|
||||
document.querySelectorAll('.switch-port-block').forEach(el => {
|
||||
el.classList.toggle('selected', parseInt(el.dataset.portIdx, 10) === _selectedIdx);
|
||||
});
|
||||
document.querySelectorAll('tr.device-row[data-port-idx]').forEach(row => {
|
||||
row.classList.toggle('is-selected', parseInt(row.dataset.portIdx, 10) === _selectedIdx);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape' && document.getElementById('inspector-panel').classList.contains('open')) {
|
||||
closePanel();
|
||||
return;
|
||||
}
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
const block = e.target.closest('[data-action="select-port"]');
|
||||
if (block) { e.preventDefault(); selectPort(block); }
|
||||
});
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
const portBlock = e.target.closest('[data-action="select-port"]');
|
||||
if (portBlock) { selectPort(portBlock); return; }
|
||||
|
||||
const closeBtn = e.target.closest('[data-action="close-panel"]');
|
||||
if (closeBtn) { closePanel(); return; }
|
||||
|
||||
const diagBtn = e.target.closest('[data-action="run-diagnostic"]');
|
||||
if (diagBtn) { runDiagnostic(diagBtn.dataset.sw, parseInt(diagBtn.dataset.idx, 10)); return; }
|
||||
|
||||
const toggleDiag = e.target.closest('[data-action="toggle-diag"]');
|
||||
if (toggleDiag) {
|
||||
const section = toggleDiag.parentElement;
|
||||
const nowOpen = section.classList.toggle('diag-open');
|
||||
const hint = toggleDiag.querySelector('.diag-toggle-hint');
|
||||
if (hint) hint.textContent = nowOpen ? '[collapse]' : '[expand]';
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Link Diagnostics ─────────────────────────────────────────────────
|
||||
|
||||
function runDiagnostic(swName, portIdx) {
|
||||
const statusEl = document.getElementById('diag-status');
|
||||
const resultsEl = document.getElementById('diag-results');
|
||||
if (!statusEl || !resultsEl) return;
|
||||
|
||||
// Clear any previous poll
|
||||
if (_diagPollTimer) { clearInterval(_diagPollTimer); _diagPollTimer = null; }
|
||||
|
||||
statusEl.textContent = 'Submitting to Pulse...';
|
||||
resultsEl.innerHTML = '';
|
||||
|
||||
lt.api.post('/api/diagnose', {switch_name: swName, port_idx: portIdx})
|
||||
.then(resp => {
|
||||
statusEl.textContent = 'Collecting diagnostics via Pulse...';
|
||||
pollDiagnostic(resp.job_id, statusEl, resultsEl);
|
||||
})
|
||||
.catch(e => {
|
||||
const msg = (e && e.status === 429)
|
||||
? 'Rate limit reached — max 5 diagnostics per minute. Please wait.'
|
||||
: 'Error: ' + (e && e.message || 'Request failed');
|
||||
statusEl.textContent = msg;
|
||||
});
|
||||
}
|
||||
|
||||
function pollDiagnostic(jobId, statusEl, resultsEl) {
|
||||
let attempts = 0;
|
||||
_diagPollTimer = setInterval(() => {
|
||||
attempts++;
|
||||
if (attempts > 120) { // 2min timeout
|
||||
clearInterval(_diagPollTimer);
|
||||
_diagPollTimer = null;
|
||||
statusEl.innerHTML = 'Timed out waiting for results. '
|
||||
+ '<button class="lt-btn lt-btn-ghost lt-btn-sm" id="diag-retry-btn">Retry</button>';
|
||||
document.getElementById('diag-retry-btn')?.addEventListener('click', () => {
|
||||
const sel = document.querySelector('.switch-port-block.selected');
|
||||
if (sel) runDiagnostic(sel.dataset.switch, parseInt(sel.dataset.portIdx));
|
||||
});
|
||||
return;
|
||||
}
|
||||
lt.api.get(`/api/diagnose/${jobId}`)
|
||||
.then(resp => {
|
||||
if (resp.status === 'done') {
|
||||
clearInterval(_diagPollTimer);
|
||||
_diagPollTimer = null;
|
||||
statusEl.textContent = '';
|
||||
renderDiagnosticResults(resp.result, resultsEl);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
clearInterval(_diagPollTimer);
|
||||
_diagPollTimer = null;
|
||||
statusEl.innerHTML = 'Error: lost connection while collecting diagnostics. '
|
||||
+ '<button class="lt-btn lt-btn-ghost lt-btn-sm" id="diag-retry-btn">Retry</button>';
|
||||
document.getElementById('diag-retry-btn')?.addEventListener('click', () => {
|
||||
const sel = document.querySelector('.switch-port-block.selected');
|
||||
if (sel) runDiagnostic(sel.dataset.switch, parseInt(sel.dataset.portIdx));
|
||||
});
|
||||
});
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function renderDiagnosticResults(d, container) {
|
||||
if (!d || d.status === 'error') {
|
||||
container.innerHTML = `<div class="diag-error">Diagnostic error: ${escHtml((d && d.error) || 'unknown')}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const health = d.health || {};
|
||||
const issues = health.issues || [];
|
||||
const warns = health.warnings || [];
|
||||
const infoArr = health.info || [];
|
||||
const secs = d.sections || {};
|
||||
const eth = secs.ethtool || {};
|
||||
const drv = secs.ethtool_driver || {};
|
||||
const pause = secs.ethtool_pause || {};
|
||||
const ring = secs.ethtool_ring || {};
|
||||
const dom = secs.ethtool_dom || {};
|
||||
const sysfs = secs.sysfs_stats || {};
|
||||
const dmesg = secs.dmesg || [];
|
||||
const lldpctl = secs.lldpctl || {};
|
||||
const nicStats = secs.ethtool_stats || {};
|
||||
const swPort = d.switch_port || {};
|
||||
|
||||
// ── Health banner ──
|
||||
let bannerHtml = '';
|
||||
if (issues.length === 0 && warns.length === 0) {
|
||||
bannerHtml = '<div class="diag-health-banner"><span class="diag-health-ok">ALL OK</span></div>';
|
||||
} else {
|
||||
const parts = [];
|
||||
if (issues.length) parts.push(`<span class="diag-health-critical">${issues.length} CRITICAL</span>`);
|
||||
if (warns.length) parts.push(`<span class="diag-health-warning">${warns.length} WARNING</span>`);
|
||||
bannerHtml = `<div class="diag-health-banner">${parts.join(' ')}</div>`;
|
||||
}
|
||||
|
||||
const issueRows = [...issues, ...warns, ...infoArr].map(item => {
|
||||
const cls = issues.includes(item) ? 'diag-val-bad' : warns.includes(item) ? 'diag-val-warn' : 'diag-val-good';
|
||||
const label = issues.includes(item) ? 'CRIT' : warns.includes(item) ? 'WARN' : 'INFO';
|
||||
return `<div class="diag-issue-row"><span class="${cls}">[${label}]</span> <span class="diag-code">${escHtml(item.code)}</span> — ${escHtml(item.message)}</div>`;
|
||||
}).join('');
|
||||
|
||||
// ── Physical layer ──
|
||||
const carrierVal = secs.carrier === '1' ? '<span class="diag-val-good">YES</span>' :
|
||||
secs.carrier === '0' ? '<span class="diag-val-bad">NO</span>' : '–';
|
||||
const operstateVal = (secs.operstate || '?').toUpperCase();
|
||||
const opstateCls = secs.operstate === 'up' ? 'diag-val-good' : secs.operstate === 'down' ? 'diag-val-bad' : 'diag-val-warn';
|
||||
const speedVal = eth.speed_mbps ? `<span class="diag-val-good">${fmtShortSpeed(eth.speed_mbps)}bps</span>` : '<span class="diag-val-warn">–</span>';
|
||||
const duplexVal = eth.duplex === 'full' ? '<span class="diag-val-good">Full</span>' :
|
||||
eth.duplex === 'half' ? '<span class="diag-val-bad">Half</span>' : '–';
|
||||
const linkDetVal = eth.link_detected === true ? '<span class="diag-val-good">Yes</span>' :
|
||||
eth.link_detected === false ? '<span class="diag-val-bad">No</span>' : '–';
|
||||
const autonegVal = eth.auto_neg === true ? '<span class="diag-val-good">On</span>' :
|
||||
eth.auto_neg === false ? '<span class="diag-val-warn">Off</span>' : '–';
|
||||
|
||||
const physHtml = `
|
||||
<div class="diag-section">
|
||||
<div class="diag-section-header">Physical Layer</div>
|
||||
<table class="diag-table">
|
||||
<tr><td>Carrier</td><td>${carrierVal}</td></tr>
|
||||
<tr><td>Oper State</td><td><span class="${opstateCls}">${escHtml(operstateVal)}</span></td></tr>
|
||||
<tr><td>Speed</td><td>${speedVal}</td></tr>
|
||||
<tr><td>Duplex</td><td>${duplexVal}</td></tr>
|
||||
<tr><td>Link Detected</td><td>${linkDetVal}</td></tr>
|
||||
<tr><td>Auto-neg</td><td>${autonegVal}</td></tr>
|
||||
${secs.carrier_changes != null ? `<tr><td>Carrier Changes</td><td><span class="${secs.carrier_changes > 20 ? 'diag-val-warn' : 'diag-val-good'}">${secs.carrier_changes}</span></td></tr>` : ''}
|
||||
</table>
|
||||
</div>`;
|
||||
|
||||
// ── SFP / DOM ──
|
||||
let domHtml = '';
|
||||
if (dom && Object.keys(dom).length > 0) {
|
||||
const rxBar = dom.rx_power_dbm != null ? renderPowerBar(dom.rx_power_dbm, -18, -25) : '';
|
||||
const txBar = dom.tx_power_dbm != null ? renderPowerBar(dom.tx_power_dbm, -10, -13) : '';
|
||||
domHtml = `
|
||||
<div class="diag-section">
|
||||
<div class="diag-section-header">SFP / DOM</div>
|
||||
<table class="diag-table">
|
||||
${dom.vendor ? `<tr><td>Vendor</td><td>${escHtml(dom.vendor)}${dom.part_no ? ' / ' + escHtml(dom.part_no) : ''}</td></tr>` : ''}
|
||||
${dom.sfp_type ? `<tr><td>Type</td><td>${escHtml(dom.sfp_type)}</td></tr>` : ''}
|
||||
${dom.connector ? `<tr><td>Connector</td><td>${escHtml(dom.connector)}</td></tr>` : ''}
|
||||
${dom.wavelength_nm != null ? `<tr><td>Wavelength</td><td>${dom.wavelength_nm} nm</td></tr>` : ''}
|
||||
${dom.temp_c != null ? `<tr><td>Temperature</td><td>${dom.temp_c.toFixed(1)} °C</td></tr>` : ''}
|
||||
${dom.voltage_v != null ? `<tr><td>Voltage</td><td>${dom.voltage_v.toFixed(4)} V</td></tr>` : ''}
|
||||
${dom.bias_ma != null ? `<tr><td>Bias Current</td><td>${dom.bias_ma.toFixed(3)} mA</td></tr>` : ''}
|
||||
${dom.tx_power_dbm != null ? `<tr><td>TX Power</td><td>${dom.tx_power_dbm.toFixed(2)} dBm ${txBar}</td></tr>` : ''}
|
||||
${dom.rx_power_dbm != null ? `<tr><td>RX Power</td><td>${dom.rx_power_dbm.toFixed(2)} dBm ${rxBar}</td></tr>` : ''}
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── NIC Error Counters ──
|
||||
const errCounters = ['rx_crc_errors','rx_frame_errors','collisions','tx_carrier_errors','rx_missed_errors','rx_fifo_errors'];
|
||||
const nonZeroCounters = errCounters.filter(k => sysfs[k] > 0);
|
||||
let errCounterHtml = '';
|
||||
if (nonZeroCounters.length > 0 || secs.carrier_changes > 0) {
|
||||
const rows = nonZeroCounters.map(k => {
|
||||
const v = sysfs[k];
|
||||
const cls = v > 100 ? 'diag-val-bad' : 'diag-val-warn';
|
||||
return `<tr><td>${escHtml(k)}</td><td class="${cls}">${v.toLocaleString()}</td></tr>`;
|
||||
}).join('');
|
||||
errCounterHtml = `
|
||||
<div class="diag-section">
|
||||
<div class="diag-section-header">NIC Error Counters</div>
|
||||
<table class="diag-table">
|
||||
${rows || '<tr><td colspan="2" class="diag-val-good">All zero</td></tr>'}
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── ethtool -S (collapsible) ──
|
||||
let nicStatHtml = '';
|
||||
if (Object.keys(nicStats).length > 0) {
|
||||
const _ERR_KEYS = /err|drop|miss|crc|frame|fifo|abort|carrier|collision|fault|discard|overflow|reset/i;
|
||||
const rows = Object.entries(nicStats).map(([k, v]) => {
|
||||
const cls = _ERR_KEYS.test(k) && v > 0 ? ' class="diag-stat-nonzero-warn"' : '';
|
||||
return `<tr${cls}><td>${escHtml(k)}</td><td>${v.toLocaleString()}</td></tr>`;
|
||||
}).join('');
|
||||
nicStatHtml = `
|
||||
<div class="diag-section diag-collapsible">
|
||||
<div class="diag-section-header diag-toggle" data-action="toggle-diag">
|
||||
ethtool -S (NIC stats) <span class="diag-toggle-hint">[expand]</span>
|
||||
</div>
|
||||
<div class="diag-section-body">
|
||||
<table class="diag-stat-table">${rows}</table>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Flow Control + Ring Buffers ──
|
||||
let flowRingHtml = '';
|
||||
const hasPause = Object.keys(pause).length > 0;
|
||||
const hasRing = Object.keys(ring).length > 0;
|
||||
if (hasPause || hasRing) {
|
||||
flowRingHtml = `
|
||||
<div class="diag-section">
|
||||
<div class="diag-section-header">Flow Control & Ring Buffers</div>
|
||||
<table class="diag-table">
|
||||
${hasPause ? `
|
||||
<tr><td>RX Pause</td><td>${pause.rx_pause ? '<span class="diag-val-good">On</span>' : 'Off'}</td></tr>
|
||||
<tr><td>TX Pause</td><td>${pause.tx_pause ? '<span class="diag-val-good">On</span>' : 'Off'}</td></tr>` : ''}
|
||||
${hasRing ? `
|
||||
<tr><td>RX Ring</td><td>${ring.rx_current != null ? ring.rx_current : '–'} / ${ring.rx_max != null ? ring.rx_max : '–'} max</td></tr>
|
||||
<tr><td>TX Ring</td><td>${ring.tx_current != null ? ring.tx_current : '–'} / ${ring.tx_max != null ? ring.tx_max : '–'} max</td></tr>` : ''}
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Driver Info ──
|
||||
let drvHtml = '';
|
||||
if (Object.keys(drv).length > 0) {
|
||||
drvHtml = `
|
||||
<div class="diag-section">
|
||||
<div class="diag-section-header">Driver Info</div>
|
||||
<table class="diag-table">
|
||||
${drv.driver ? `<tr><td>Driver</td><td>${escHtml(drv.driver)}</td></tr>` : ''}
|
||||
${drv.version ? `<tr><td>Version</td><td>${escHtml(drv.version)}</td></tr>` : ''}
|
||||
${drv.firmware_version ? `<tr><td>Firmware</td><td>${escHtml(drv.firmware_version)}</td></tr>` : ''}
|
||||
${drv.bus_info ? `<tr><td>Bus</td><td>${escHtml(drv.bus_info)}</td></tr>` : ''}
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── LLDP Validation ──
|
||||
let lldpValHtml = '';
|
||||
const swLldp = swPort.lldp || {};
|
||||
lldpValHtml = `
|
||||
<div class="diag-section">
|
||||
<div class="diag-section-header">LLDP Validation</div>
|
||||
<div class="path-debug-cols">
|
||||
<div class="path-col">
|
||||
<div class="path-col-header">Switch sees</div>
|
||||
<div class="path-row"><span>System</span><span>${escHtml(swLldp.system_name || '–')}</span></div>
|
||||
<div class="path-row"><span>Port</span><span>${escHtml(swLldp.port_id || '–')}</span></div>
|
||||
<div class="path-row"><span>Chassis</span><span>${escHtml(swLldp.chassis_id || '–')}</span></div>
|
||||
</div>
|
||||
<div class="path-col">
|
||||
<div class="path-col-header">Server lldpctl</div>
|
||||
${lldpctl.available
|
||||
? `<div class="path-row"><span>Neighbor</span><span>${escHtml(lldpctl.neighbor_system || '–')}</span></div>
|
||||
<div class="path-row"><span>Port</span><span>${escHtml(lldpctl.neighbor_port || '–')}</span></div>`
|
||||
: '<div class="path-row"><span class="diag-val-warn">lldpd not running</span></div>'}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// ── dmesg ──
|
||||
let dmesgHtml = '';
|
||||
if (dmesg.length > 0) {
|
||||
const dlines = dmesg.map(e => {
|
||||
const cls = e.severity === 'error' ? ' diag-dmesg-err' : e.severity === 'warn' ? ' diag-dmesg-warn' : '';
|
||||
const ts = e.timestamp ? `[${e.timestamp}] ` : '';
|
||||
return `<div class="diag-dmesg-line${cls}">${escHtml(ts + e.msg)}</div>`;
|
||||
}).join('');
|
||||
dmesgHtml = `
|
||||
<div class="diag-section diag-collapsible">
|
||||
<div class="diag-section-header diag-toggle" data-action="toggle-diag">
|
||||
Kernel Events (dmesg) <span class="diag-toggle-hint">[expand]</span>
|
||||
</div>
|
||||
<div class="diag-section-body">
|
||||
<div class="diag-dmesg-wrap">${dlines}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Switch Port Summary ──
|
||||
const swSummaryHtml = `
|
||||
<div class="diag-section">
|
||||
<div class="diag-section-header">Switch Port Summary</div>
|
||||
<table class="diag-table">
|
||||
<tr><td>Status</td><td>${swPort.up ? '<span class="diag-val-good">UP</span>' : '<span class="diag-val-bad">DOWN</span>'}</td></tr>
|
||||
<tr><td>Speed</td><td>${swPort.speed_mbps ? fmtShortSpeed(swPort.speed_mbps) + 'bps' : '–'}</td></tr>
|
||||
<tr><td>Duplex</td><td>${swPort.full_duplex ? 'Full' : (swPort.up ? '<span class="diag-val-bad">Half</span>' : '–')}</td></tr>
|
||||
<tr><td>TX Err</td><td>${fmtErrors(swPort.tx_errs_rate)}</td></tr>
|
||||
<tr><td>RX Err</td><td>${fmtErrors(swPort.rx_errs_rate)}</td></tr>
|
||||
${swPort.poe_power != null ? `<tr><td>PoE</td><td><span class="val-amber">${swPort.poe_power.toFixed(1)}W</span></td></tr>` : ''}
|
||||
</table>
|
||||
</div>`;
|
||||
|
||||
// ── Pulse link ──
|
||||
const pulseLink = d.pulse_url
|
||||
? `<div class="diag-pulse-link"><a href="${escHtml(d.pulse_url)}" target="_blank" rel="noopener">View raw output in Pulse ↗</a></div>`
|
||||
: '';
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="diag-results-inner">
|
||||
${bannerHtml}
|
||||
<div class="diag-issue-list">${issueRows}</div>
|
||||
${physHtml}
|
||||
${domHtml}
|
||||
${errCounterHtml}
|
||||
${nicStatHtml}
|
||||
${flowRingHtml}
|
||||
${drvHtml}
|
||||
${lldpValHtml}
|
||||
${dmesgHtml}
|
||||
${swSummaryHtml}
|
||||
${pulseLink}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// SFP power bar: range is 0 dBm (best) to -35 dBm (worst)
|
||||
function renderPowerBar(dbm, warnThreshold, critThreshold) {
|
||||
const minDbm = -35, maxDbm = 0;
|
||||
const pct = Math.max(0, Math.min(100, ((dbm - minDbm) / (maxDbm - minDbm)) * 100));
|
||||
const warnPct = ((warnThreshold - minDbm) / (maxDbm - minDbm)) * 100;
|
||||
const critPct = ((critThreshold - minDbm) / (maxDbm - minDbm)) * 100;
|
||||
const barCls = dbm < critThreshold ? 'diag-val-bad' : dbm < warnThreshold ? 'diag-val-warn' : 'diag-val-good';
|
||||
return `<span class="diag-power-bar-wrap">
|
||||
<span class="diag-power-bar ${barCls}" style="width:${pct.toFixed(1)}%"></span>
|
||||
<span class="diag-power-zone-warn" style="left:${warnPct.toFixed(1)}%"></span>
|
||||
<span class="diag-power-zone-crit" style="left:${critPct.toFixed(1)}%"></span>
|
||||
</span>`;
|
||||
}
|
||||
@@ -1149,3 +1149,26 @@ a.device-link:hover { color: var(--text-accent); border-bottom-color: current
|
||||
/* Scroll inside the wrapper rather than squeezing every column to a few letters */
|
||||
#events-table { min-width: 760px; }
|
||||
}
|
||||
|
||||
/* ── Port inspector on the device page ────────────────────────────── */
|
||||
.device-inspector { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 14px 16px; align-items: start; }
|
||||
.device-chassis { grid-column: 1; min-width: 0; }
|
||||
.device-port-table { grid-column: 1; min-width: 0; }
|
||||
.device-inspector .inspector-panel { grid-column: 2; grid-row: 1 / span 2; position: sticky; top: 70px; }
|
||||
.device-inspector:not(.has-chassis) .inspector-panel,
|
||||
.device-inspector:not(.has-chassis) .device-chassis { display: none; }
|
||||
tr.device-row[data-port-idx] { cursor: pointer; }
|
||||
tr.device-row.is-selected { background: var(--bg-tertiary); box-shadow: inset 3px 0 0 var(--terminal-cyan, #0ff); }
|
||||
.device-chassis .switch-port-block,
|
||||
.device-chassis .switch-port-block.sfp-port,
|
||||
.device-chassis .switch-port-block.sfp-block { width: 50px; height: 50px; font-size: .68em; gap: 2px; }
|
||||
.device-chassis .port-num { font-size: 1.2em; }
|
||||
.device-chassis .port-speed { font-size: .85em; }
|
||||
.device-chassis .port-lldp { font-size: .8em; max-width: 46px; margin: 0; }
|
||||
@media (max-width: 900px) {
|
||||
.device-inspector { grid-template-columns: minmax(0, 1fr); }
|
||||
.device-inspector .inspector-panel { grid-column: 1; grid-row: auto; position: static; }
|
||||
.device-inspector .inspector-panel.open { width: 100%; }
|
||||
.device-inspector .inspector-panel-inner { width: 100%; max-height: none; }
|
||||
.device-chassis .chassis-ear-l, .device-chassis .chassis-ear-r { display: none; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user