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

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:
2026-11-05 10:11:11 -05:00
co-authored by Claude Opus 5.5
parent e32499b7f6
commit bdcfb76c25
13 changed files with 484 additions and 226 deletions
+33 -71
View File
@@ -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;