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>
417 lines
19 KiB
JavaScript
417 lines
19 KiB
JavaScript
'use strict';
|
||
/* global DEVICE_NAME, deviceUrl, refreshModem, _toIso, fmtRate, fmtErrors, renderPortInspector, selectPortByIdx, highlightSelectedPort */
|
||
|
||
// 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));
|
||
|
||
const _openRows = new Set(); // survives auto-refresh
|
||
let _filter = 'all';
|
||
let _query = '';
|
||
let _device = null;
|
||
|
||
// ── Formatting ───────────────────────────────────────────────────────
|
||
function fmtSpeed(mbps) {
|
||
if (!mbps) return '–';
|
||
return mbps >= 1000 ? (mbps / 1000).toFixed(0) + ' Gbps' : mbps + ' Mbps';
|
||
}
|
||
|
||
function fmtUnit(value, unit, digits) {
|
||
return value === null || value === undefined ? '–' : `${value.toFixed(digits)} ${unit}`;
|
||
}
|
||
|
||
function fmtUptime(seconds) {
|
||
if (seconds === null || seconds === undefined) return '–';
|
||
const d = Math.floor(seconds / 86400), h = Math.floor((seconds % 86400) / 3600);
|
||
const m = Math.floor((seconds % 3600) / 60);
|
||
return d ? `${d}d ${h}h` : `${h}h ${m}m`;
|
||
}
|
||
|
||
function ratePct(bytesPerSec, mbps) {
|
||
if (!mbps || !bytesPerSec) return 0;
|
||
return Math.min(100, (bytesPerSec * 8 / 1e6 / mbps) * 100);
|
||
}
|
||
|
||
function barClass(pct, isTx) {
|
||
if (pct > 85) return 'lt-progress--red';
|
||
if (pct > 65) return 'lt-progress--amber';
|
||
return isTx ? '' : 'lt-progress--cyan';
|
||
}
|
||
|
||
// ── SFP/DOM value classification ─────────────────────────────────────
|
||
function rxClass(dbm) {
|
||
if (dbm === null || dbm === undefined) return 'val-neutral';
|
||
if (dbm < -15) return 'val-crit';
|
||
return dbm < -10 ? 'val-warn' : 'val-good';
|
||
}
|
||
function txClass(dbm) {
|
||
if (dbm === null || dbm === undefined) return 'val-neutral';
|
||
return dbm < -5 ? 'val-crit' : 'val-good';
|
||
}
|
||
function tempClass(c) {
|
||
if (c === null || c === undefined) return 'val-neutral';
|
||
if (c > 80) return 'val-crit';
|
||
return c > 70 ? 'val-warn' : 'val-good';
|
||
}
|
||
function voltClass(v) {
|
||
if (v === null || v === undefined) return 'val-neutral';
|
||
if (v < 3.0 || v > 3.6) return 'val-crit';
|
||
return (v < 3.1 || v > 3.5) ? 'val-warn' : 'val-good';
|
||
}
|
||
|
||
function powerFill(cls) {
|
||
return cls === 'val-good' ? 'power-ok' : cls === 'val-warn' ? 'power-warn' : 'power-crit';
|
||
}
|
||
|
||
function badges(d) {
|
||
const out = [];
|
||
if ((d.tx_errs_rate || 0) > 0.001 || (d.rx_errs_rate || 0) > 0.001) out.push('<span class="link-alert-badge">ERR</span>');
|
||
if ((d.tx_drops_rate || 0) > 0.001 || (d.rx_drops_rate || 0) > 0.001) out.push('<span class="link-alert-badge link-alert-amber">DROP</span>');
|
||
if ((d.carrier_changes || 0) > 3) out.push('<span class="link-alert-badge link-alert-amber">FLAP</span>');
|
||
return out.join('');
|
||
}
|
||
|
||
function stat(label, valueHtml, tooltip) {
|
||
const tip = tooltip ? ` data-tooltip="${esc(tooltip)}"` : '';
|
||
return `<div class="link-stat"><span class="link-stat-label"${tip}>${esc(label)}</span>` +
|
||
`<span class="link-stat-value">${valueHtml}</span></div>`;
|
||
}
|
||
|
||
function trafficBars(d) {
|
||
const tx = ratePct(d.tx_bytes_rate, d.speed_mbps), rx = ratePct(d.rx_bytes_rate, d.speed_mbps);
|
||
const row = (label, pct, rate, isTx) => `
|
||
<div class="traffic-row">
|
||
<span class="traffic-label">${label}</span>
|
||
<div class="lt-progress ${barClass(pct, isTx)}"><div class="lt-progress-bar" style="width:${pct}%"></div></div>
|
||
<span class="traffic-value">${fmtRate(rate)}</span>
|
||
</div>`;
|
||
return `<div class="traffic-section">${row('TX', tx, d.tx_bytes_rate, true)}${row('RX', rx, d.rx_bytes_rate, false)}</div>`;
|
||
}
|
||
|
||
function sfpPanel(s) {
|
||
if (!s || !Object.keys(s).length) return '';
|
||
const rx = rxClass(s.rx_power_dbm), tx = txClass(s.tx_power_dbm);
|
||
const has = v => v !== null && v !== undefined;
|
||
const rxPct = has(s.rx_power_dbm) ? Math.min(100, Math.max(0, (s.rx_power_dbm + 20) / 15 * 100)) : 0;
|
||
const txPct = has(s.tx_power_dbm) ? Math.min(100, Math.max(0, (s.tx_power_dbm + 10) / 8 * 100)) : 0;
|
||
const item = (label, cls, value, tip, pct) => `
|
||
<div class="sfp-stat">
|
||
<span class="sfp-stat-label" data-tooltip="${esc(tip)}">${label}</span>
|
||
<span class="sfp-stat-value ${cls}">${value}</span>
|
||
${pct === undefined ? '' : `<div class="power-row"><div class="power-track"><div class="power-fill ${powerFill(cls)}" style="width:${pct}%"></div></div></div>`}
|
||
</div>`;
|
||
const vendor = [s.vendor, s.part_no, s.sfp_type, s.connector, s.wavelength_nm && `${s.wavelength_nm} nm`]
|
||
.filter(Boolean).map(esc).join(' / ');
|
||
return `
|
||
<div class="sfp-panel">
|
||
<div class="sfp-vendor-row">${vendor}</div>
|
||
<div class="sfp-grid">
|
||
${item('Temp', tempClass(s.temp_c), fmtUnit(s.temp_c, '°C', 1), 'Module temperature. Normal: below 70°C.')}
|
||
${item('Voltage', voltClass(s.voltage_v), fmtUnit(s.voltage_v, 'V', 2), 'Supply voltage. Normal: 3.1–3.5 V.')}
|
||
${item('Bias', '', fmtUnit(s.bias_ma, 'mA', 2), 'Laser bias current. High values may mean an aging laser.')}
|
||
${item('TX Power', tx, fmtUnit(s.tx_power_dbm, 'dBm', 2), 'Optical transmit power.', txPct)}
|
||
${item('RX Power', rx, fmtUnit(s.rx_power_dbm, 'dBm', 2), 'Optical receive power. Very low values may mean dirty or damaged fiber.', rxPct)}
|
||
${has(s.rx_power_dbm) && has(s.tx_power_dbm) ? item('RX−TX Δ', '', `${(s.rx_power_dbm - s.tx_power_dbm).toFixed(2)} dBm`, 'Difference between receive and transmit power.') : ''}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// ── Normalized rows, so hosts and switches share one table ───────────
|
||
function hostRows(interfaces) {
|
||
return Object.entries(interfaces)
|
||
.sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true }))
|
||
.map(([name, d]) => {
|
||
const up = d.state ? d.state === 'up' : d.link_detected !== false;
|
||
return {
|
||
id: name, label: name, up, data: d, sub: d.port_type || '',
|
||
detail: `
|
||
<div class="link-stats-grid">
|
||
${stat('Duplex', esc(d.duplex || '–'), 'Full = both directions at once. Half can cause collisions.')}
|
||
${stat('Auto-neg', d.auto_neg === null || d.auto_neg === undefined ? '–' : d.auto_neg ? 'On' : 'Off')}
|
||
${stat('Carrier Δ', d.carrier_changes === null || d.carrier_changes === undefined ? '–' : esc(d.carrier_changes), 'Times the link went up or down. High values mean flapping.')}
|
||
${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))}
|
||
</div>
|
||
${trafficBars(d)}
|
||
${sfpPanel(d.sfp)}`,
|
||
};
|
||
});
|
||
}
|
||
|
||
function portRows(ports) {
|
||
return Object.entries(ports)
|
||
.sort(([, a], [, b]) => (a.port_idx || 0) - (b.port_idx || 0))
|
||
.map(([name, d]) => {
|
||
return {
|
||
id: name, label: name, num: d.port_idx, up: !!d.up, data: d,
|
||
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(' · '),
|
||
};
|
||
});
|
||
}
|
||
|
||
function rowHasProblem(r) {
|
||
return !!badges(r.data);
|
||
}
|
||
|
||
function renderTable(rows) {
|
||
const q = _query.trim().toLowerCase();
|
||
const shown = rows.filter(r => {
|
||
if (_filter === 'up' && !r.up) return false;
|
||
if (_filter === 'problems' && !rowHasProblem(r)) return false;
|
||
return !q || `${r.label} ${r.num || ''} ${r.sub} ${(r.neighbor && r.neighbor.name) || ''}`.toLowerCase().includes(q);
|
||
});
|
||
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 = !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.chassis || r.neighbor.port || ''))
|
||
: '–';
|
||
return `
|
||
<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>
|
||
<td class="device-num device-col-wide">${fmtRate(d.rx_bytes_rate)}</td>
|
||
<td class="device-num device-col-wide">${fmtRate(d.tx_bytes_rate)}</td>
|
||
${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="7">${r.detail}</td></tr>` : ''}`;
|
||
}).join('');
|
||
return `
|
||
<div class="lt-table-wrap">
|
||
<table class="lt-table device-table">
|
||
<caption class="lt-sr-only">Interfaces</caption>
|
||
<thead><tr>
|
||
<th><span class="lt-sr-only">Expand</span></th><th><span class="lt-sr-only">State</span></th>
|
||
<th>${isPorts ? 'Port' : 'Interface'}</th><th>Speed</th><th class="device-col-wide">RX</th><th class="device-col-wide">TX</th>
|
||
${isPorts ? '<th class="device-col-wide">Neighbor</th>' : ''}<th>Health</th>
|
||
</tr></thead>
|
||
<tbody>${body}</tbody>
|
||
</table>
|
||
</div>`;
|
||
}
|
||
|
||
function section(title, inner, id) {
|
||
return `
|
||
<section class="g-section"${id ? ` id="${id}"` : ''}>
|
||
<div class="g-section-header"><h2 class="g-section-title">${esc(title)}</h2></div>
|
||
<div class="lt-frame">
|
||
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||
${inner}
|
||
</div>
|
||
</section>`;
|
||
}
|
||
|
||
function kvGrid(pairs) {
|
||
return `<div class="modem-stats">${pairs.filter(([, v]) => v !== null && v !== undefined && v !== '').map(([k, v]) => `
|
||
<div class="modem-stat"><span class="modem-stat-label">${esc(k)}</span><span class="modem-stat-value">${esc(v)}</span></div>`).join('')}</div>`;
|
||
}
|
||
|
||
function renderOverview(d) {
|
||
const info = d.info || {};
|
||
const pct = v => (v === null || v === undefined ? null : `${v.toFixed(0)}%`);
|
||
const temps = (info.temps || []).filter(t => t.value_c !== null).map(t => `${t.name} ${t.value_c.toFixed(0)}°C`).join(', ');
|
||
return section('Overview', kvGrid([
|
||
['Status', (d.status || 'unknown').toUpperCase()],
|
||
['Type', d.kind],
|
||
['IP', d.ip],
|
||
['MAC', d.mac],
|
||
['Model', d.model],
|
||
['Firmware', info.firmware],
|
||
['Uptime', info.uptime_s === undefined ? null : fmtUptime(info.uptime_s)],
|
||
['CPU', pct(info.cpu_pct)],
|
||
['Memory', pct(info.mem_pct)],
|
||
['Clients', info.clients],
|
||
['Temperature', temps],
|
||
['Power draw', info.power_w === null || info.power_w === undefined ? null : `${info.power_w.toFixed(0)} W`],
|
||
['Data source', d.source],
|
||
]));
|
||
}
|
||
|
||
function renderUplinks(d) {
|
||
if (!(d.uplinks || []).length) return '';
|
||
const rows = d.uplinks.map(u => `
|
||
<tr>
|
||
<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><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">
|
||
<caption class="lt-sr-only">Switch ports this host connects to, from LLDP</caption>
|
||
<thead><tr><th><span class="lt-sr-only">State</span></th><th>Interface</th><th>Switch</th><th>Port</th><th>Speed</th></tr></thead>
|
||
<tbody>${rows}</tbody></table></div>`);
|
||
}
|
||
|
||
function renderWan(d) {
|
||
const wan = (d.info && d.info.wan) || {};
|
||
const names = Object.keys(wan);
|
||
if (!names.length) return '';
|
||
const rows = names.map(n => {
|
||
const w = wan[n];
|
||
return `<tr><td><span class="dot-${w.up ? 'up' : 'down'}"></span></td><td>${esc(n.toUpperCase())}</td>
|
||
<td>${fmtSpeed(w.speed_mbps)}</td><td>${w.latency_ms === null || w.latency_ms === undefined ? '–' : `${esc(w.latency_ms)} ms`}</td>
|
||
<td>${w.availability === null || w.availability === undefined ? '–' : `${esc(w.availability)}%`}</td></tr>`;
|
||
}).join('');
|
||
return section('WAN', `<div class="lt-table-wrap"><table class="lt-table">
|
||
<caption class="lt-sr-only">WAN connections</caption>
|
||
<thead><tr><th><span class="lt-sr-only">State</span></th><th>Connection</th><th>Speed</th><th>Latency</th><th>Availability</th></tr></thead>
|
||
<tbody>${rows}</tbody></table></div>`);
|
||
}
|
||
|
||
function renderOutlets(d) {
|
||
const outlets = (d.info && d.info.outlets) || [];
|
||
if (!outlets.length) return '';
|
||
const rows = outlets.map(o => `
|
||
<tr>
|
||
<td><span class="dot-${o.on ? 'up' : 'down'}"></span></td>
|
||
<td>#${esc(o.index)}</td>
|
||
<td>${esc(o.name || '–')}</td>
|
||
<td class="device-num">${o.power_w === null || o.power_w === undefined ? '–' : `${o.power_w.toFixed(1)} W`}</td>
|
||
<td>${o.auto_power_cycle ? '<span class="lt-badge">auto power-cycle</span>' : ''}</td>
|
||
</tr>`).join('');
|
||
return section('Outlets', `<div class="lt-table-wrap"><table class="lt-table">
|
||
<caption class="lt-sr-only">PDU outlets</caption>
|
||
<thead><tr><th><span class="lt-sr-only">State</span></th><th>Outlet</th><th>Name</th><th>Power</th><th></th></tr></thead>
|
||
<tbody>${rows}</tbody></table></div>`);
|
||
}
|
||
|
||
function renderEvents(events) {
|
||
const body = !(events || []).length
|
||
? '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-icon">✔</div><div class="lt-empty-state-title">No recorded alerts</div></div>'
|
||
: `<div class="lt-table-wrap"><table class="lt-table">
|
||
<caption class="lt-sr-only">Alerts for this device</caption>
|
||
<thead><tr><th>Sev</th><th>Type</th><th>Detail</th><th>Description</th><th>First seen</th><th>State</th></tr></thead>
|
||
<tbody>${events.map(e => `
|
||
<tr class="${e.resolved_at ? '' : `row-${esc(e.severity)}`}">
|
||
<td><span class="lt-badge badge-${e.resolved_at ? 'resolved' : esc(e.severity)}">${esc(e.severity)}</span>${e.is_suppressed && !e.resolved_at ? '<span class="lt-badge badge-suppressed">🔕 sup</span>' : ''}</td>
|
||
<td>${esc(e.event_type.replace(/_/g, ' '))}</td>
|
||
<td>${esc(e.target_detail || '–')}</td>
|
||
<td class="desc-cell">${esc(e.description || '')}</td>
|
||
<td class="ts-cell" title="${esc(e.first_seen || '')}">${esc(lt.time.ago(_toIso(e.first_seen)))}</td>
|
||
<td>${e.resolved_at ? `resolved ${esc(lt.time.ago(_toIso(e.resolved_at)))}` : '<strong>active</strong>'}</td>
|
||
</tr>`).join('')}</tbody></table></div>`;
|
||
return section('Alerts', body);
|
||
}
|
||
|
||
function currentRows() {
|
||
if (!_device) return [];
|
||
if (_device.ports) return portRows(_device.ports);
|
||
if (_device.interfaces) return hostRows(_device.interfaces);
|
||
return [];
|
||
}
|
||
|
||
function renderInterfaces() {
|
||
const wrap = document.getElementById('device-table-wrap');
|
||
if (!wrap) return;
|
||
wrap.innerHTML = renderTable(currentRows());
|
||
highlightSelectedPort();
|
||
}
|
||
|
||
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) {
|
||
_device = d;
|
||
document.getElementById('device-dot').className = `dot-${d.status || 'unknown'}`;
|
||
document.getElementById('device-subtitle').textContent =
|
||
[d.kind, d.ip, d.model].filter(Boolean).join(' · ') +
|
||
(d.updated ? ` · updated ${new Date(_toIso(d.updated)).toLocaleTimeString()}` : '');
|
||
|
||
const unifi = d.source === 'unifi';
|
||
document.getElementById('device-actions').innerHTML = `
|
||
<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>`;
|
||
|
||
document.getElementById('device-root').innerHTML =
|
||
renderOverview(d) +
|
||
renderEvents(d.events) +
|
||
renderWan(d) +
|
||
renderUplinks(d) +
|
||
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 (d.kind === 'modem') refreshModem();
|
||
}
|
||
|
||
async function loadDevice() {
|
||
try {
|
||
render(await lt.api.get(`/api/device/${encodeURIComponent(DEVICE_NAME)}`));
|
||
} catch (err) {
|
||
if (_device) {
|
||
lt.toast.error('Could not refresh device data');
|
||
return;
|
||
}
|
||
const notFound = /404|No device/.test(err.message || '');
|
||
document.getElementById('device-root').innerHTML = `
|
||
<div class="lt-empty-state">
|
||
<div class="lt-empty-state-icon">✕</div>
|
||
<div class="lt-empty-state-title">${notFound ? 'No device with this name' : 'Could not load this device'}</div>
|
||
<div class="lt-empty-state-body">${notFound ? 'It may have been renamed, or the monitor may not have seen it yet.' : 'Check that the Gandalf service is healthy, then refresh.'}</div>
|
||
</div>`;
|
||
}
|
||
}
|
||
|
||
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;
|
||
if (_openRows.has(id)) _openRows.delete(id); else _openRows.add(id);
|
||
renderInterfaces();
|
||
return;
|
||
}
|
||
const pill = e.target.closest('[data-device-filter]');
|
||
if (pill) {
|
||
_filter = pill.dataset.deviceFilter;
|
||
document.querySelectorAll('[data-device-filter]').forEach(p => {
|
||
const on = p === pill;
|
||
p.classList.toggle('active', on);
|
||
p.setAttribute('aria-pressed', String(on));
|
||
});
|
||
renderInterfaces();
|
||
}
|
||
});
|
||
|
||
document.addEventListener('input', e => {
|
||
if (e.target.id === 'device-table-search') {
|
||
_query = e.target.value;
|
||
renderInterfaces();
|
||
}
|
||
});
|
||
|
||
loadDevice();
|
||
const _deviceInterval = window.gandalfSettings?.refreshInterval ?? 60;
|
||
if (_deviceInterval > 0) lt.autoRefresh.start(loadDevice, Math.max(_deviceInterval, 15) * 1000);
|
||
|
||
window.onGandalfSettingsChanged = function (s) {
|
||
lt.autoRefresh.stop();
|
||
if (s.refreshInterval > 0) lt.autoRefresh.start(loadDevice, Math.max(s.refreshInterval, 15) * 1000);
|
||
};
|