Files
jaredandClaude Opus 5.5 e32499b7f6
Lint / Python (flake8) (pull_request) Successful in 2m6s
Lint / JS (eslint) (pull_request) Successful in 18s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 2m11s
Test / Python Tests (pytest) (pull_request) Successful in 2m20s
Lint / JS (eslint) (push) Successful in 10s
Lint / Python (flake8) (push) Successful in 1m58s
Lint / Notify on failure (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m58s
Lint / Deploy (push) Successful in 4s
Test / Python Tests (pytest) (push) Successful in 58s
Add AT&T modem monitoring and device pages; replace Link Debug
Modem and WAN:
- Poll the BGW320 status pages read-only for light levels, PON and WAN
  state, with a WAN / Fiber panel, 7-day Rx trend, and alerts on WAN down,
  optical limits, and Rx drift
- Read WAN outages and Smart Power restarts from the UniFi system log,
  since outages can be over between 5 minute polls

Device pages:
- Add device search to the dashboard and a page per host, switch, router,
  AP, PDU, or modem
- Move all Link Debug data into expandable rows there; /links redirects
- Collect UDM ports and UniFi device details (firmware, load, WAN, outlets)
- Reorder the dashboard

Monitoring fixes:
- Alert when a configured host stops reporting: its series disappear
  rather than reporting down, so a dead host never raised an alert
- Alert on Prometheus outages instead of blanking the dashboard
- Treat a failed Pulse ping as unknown rather than host unreachable
- Stop new switch ports reporting lifetime bytes as one interval's rate
- Run database sessions in UTC: other server time zones shifted displayed
  ages and timed suppression expiries

Also log every avatar 404 reason, add a favicon, and correct the WAN link
labels (1G on Port 9, not 10G SFP+).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:40:17 -05:00

455 lines
21 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* global DEVICE_NAME, deviceUrl, refreshModem, _toIso */
// Per-device page: overview, alerts, and a compact interface/port table whose
// rows expand to show everything the old Link Debug page showed.
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 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}`;
}
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]) => {
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,
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);
}
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 = _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 || ''))
: '–';
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>
<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="${isPorts ? 8 : 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">&#x255A;</span><span class="lt-frame-br">&#x255D;</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>#${esc(u.port_idx)} ${esc(u.port)}</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) wrap.innerHTML = renderTable(currentRows());
}
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 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 = `
${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');
renderInterfaces();
if (active) {
const box = document.getElementById('device-table-search');
box.focus();
box.setSelectionRange(caret, caret);
}
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 => {
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);
};