Add AT&T modem monitoring and device pages; replace Link Debug
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

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>
This commit was merged in pull request #4.
This commit is contained in:
2026-11-04 23:40:17 -05:00
co-authored by Claude Opus 5.5
parent e1716d5228
commit e32499b7f6
25 changed files with 3120 additions and 821 deletions
+261 -1
View File
@@ -1,4 +1,6 @@
'use strict';
/* exported initDeviceSearch */
/* global DEVICE_NAME */
// ── Auto-redirect on auth timeout ─────────────────────────────────────
// Wraps fetch so a 401 (Authelia session expired) forces a full reload.
@@ -40,6 +42,7 @@ async function refreshAll() {
const [netResult, statusResult] = await Promise.allSettled([
lt.api.get('/api/network'),
lt.api.get('/api/status'),
refreshModem(),
]);
if (netResult.status === 'fulfilled') {
const net = netResult.value;
@@ -170,6 +173,263 @@ function updateTopology(hosts) {
});
}
// ── Device search ─────────────────────────────────────────────────────
function deviceUrl(name) {
return `/device/${encodeURIComponent(name)}`;
}
// All words must match somewhere; the device's name matching ranks first.
function rankDevices(devices, query) {
const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
if (!words.length) return [];
const scored = [];
for (const d of devices) {
if (!words.every(w => d.search.includes(w))) continue;
const name = d.name.toLowerCase();
let score = 3;
if (name === words[0]) score = 0;
else if (name.startsWith(words[0])) score = 1;
else if (name.includes(words[0]) || (d.ip || '').startsWith(words[0])) score = 2;
scored.push({ d, score });
}
return scored
.sort((a, b) => a.score - b.score || a.d.name.localeCompare(b.d.name))
.slice(0, 12)
.map(s => s.d);
}
function initDeviceSearch() {
const input = document.getElementById('device-search-input');
const results = document.getElementById('device-search-results');
if (!input || !results) return;
let devices = [];
let matches = [];
let active = 0;
async function loadDevices() {
try {
devices = (await lt.api.get('/api/devices')).devices || [];
} catch (err) {
console.warn('Device list unavailable', err);
}
}
function close() {
results.hidden = true;
input.setAttribute('aria-expanded', 'false');
input.removeAttribute('aria-activedescendant');
}
function render() {
matches = rankDevices(devices, input.value);
if (!input.value.trim()) { close(); return; }
active = Math.min(active, Math.max(matches.length - 1, 0));
results.hidden = false;
input.setAttribute('aria-expanded', 'true');
if (!matches.length) {
results.innerHTML = '<div class="device-search-empty">No devices match.</div>';
input.removeAttribute('aria-activedescendant');
return;
}
results.innerHTML = matches.map((d, i) => `
<a class="device-search-row${i === active ? ' is-active' : ''}" id="device-opt-${i}" role="option"
aria-selected="${i === active}" href="${lt.escHtml(deviceUrl(d.name))}">
<span class="dot-${lt.escHtml(d.status)}"></span>
<span class="device-search-name">${lt.escHtml(d.name)}</span>
<span class="device-search-kind">${lt.escHtml(d.kind)}</span>
<span class="device-search-meta">${lt.escHtml(d.ip || '')}</span>
<span class="device-search-meta">${lt.escHtml(d.model || '')}</span>
</a>`).join('');
input.setAttribute('aria-activedescendant', `device-opt-${active}`);
}
input.addEventListener('input', () => { active = 0; render(); });
input.addEventListener('focus', () => { loadDevices().then(render); });
input.addEventListener('keydown', e => {
if (e.key === 'Escape') { close(); return; }
if (!matches.length) return;
if (e.key === 'ArrowDown') { e.preventDefault(); active = (active + 1) % matches.length; render(); }
if (e.key === 'ArrowUp') { e.preventDefault(); active = (active - 1 + matches.length) % matches.length; render(); }
if (e.key === 'Enter') { e.preventDefault(); window.location.href = deviceUrl(matches[active].name); }
});
document.addEventListener('click', e => {
if (!e.target.closest('#device-search')) close();
});
document.addEventListener('keydown', e => {
const typing = e.target.closest('input, textarea, select, [contenteditable]');
if (e.key === '/' && !typing && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
input.focus();
}
});
loadDevices();
}
// ── WAN modem / fiber panel ───────────────────────────────────────────
async function refreshModem() {
try {
updateModem(await lt.api.get('/api/modem'));
} catch (err) {
console.warn('Modem data unavailable', err);
}
}
function _modemUptime(seconds) {
if (seconds === null || seconds === undefined) return '–';
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
return d ? `${d}d ${h}h` : `${h}h ${m}m`;
}
function _modemRate(bytesPerSec) {
if (bytesPerSec === null || bytesPerSec === undefined) return '–';
const mbps = bytesPerSec * 8 / 1e6;
return mbps >= 1000 ? `${(mbps / 1000).toFixed(2)} Gbps` : `${mbps.toFixed(1)} Mbps`;
}
function _modemStat(label, value, cls, note) {
return `
<div class="modem-stat">
<span class="modem-stat-label">${lt.escHtml(label)}</span>
<span class="modem-stat-value ${cls || ''}">${lt.escHtml(String(value))}</span>
${note ? `<span class="modem-stat-note">${lt.escHtml(note)}</span>` : ''}
</div>`;
}
function _modemOptic(label, metric, status, unit) {
if (!metric || metric.value === null || metric.value === undefined) return _modemStat(label, '–');
const cls = { alarm: 'lt-text-red', warning: 'lt-text-amber', ok: 'lt-text-green' }[status] || '';
return _modemStat(label, `${metric.value} ${unit}`, cls, `warn ${metric.warn_low} to ${metric.warn_high}`);
}
function _modemChart(history) {
const points = (history || []).filter(p => p.rx_avg !== null && p.rx_avg !== undefined);
if (points.length < 2) {
return '<div class="modem-chart-empty">Rx power trend appears after a few polls.</div>';
}
const W = 600, H = 70, PAD = 4;
const values = points.map(p => p.rx_avg);
const low = Math.min(...values) - 0.5, high = Math.max(...values) + 0.5;
const t0 = points[0].ts, tSpan = (points[points.length - 1].ts - t0) || 1;
const coord = p => `${((p.ts - t0) / tSpan * W).toFixed(1)},` +
`${(PAD + (high - p.rx_avg) / (high - low) * (H - 2 * PAD)).toFixed(1)}`;
// Break the line where samples are missing rather than drawing across an outage
const gaps = points.slice(1).map((p, i) => p.ts - points[i].ts).sort((a, b) => a - b);
const maxGap = gaps[Math.floor(gaps.length / 2)] * 3;
const segments = [[]];
points.forEach((p, i) => {
if (i && p.ts - points[i - 1].ts > maxGap) segments.push([]);
segments[segments.length - 1].push(coord(p));
});
const lines = segments
.map(s => `<polyline points="${s.join(' ')}" fill="none" vector-effect="non-scaling-stroke"/>`)
.join('');
const summary = `range ${Math.min(...values)} to ${Math.max(...values)} dBm`;
return `
<div class="modem-chart">
<div class="modem-chart-title">Rx power, last 7 days <span>${lt.escHtml(summary)}</span></div>
<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img"
aria-label="Rx power trend, ${lt.escHtml(summary)}">${lines}</svg>
</div>`;
}
function _modemWhen(epochSeconds) {
return new Date(epochSeconds * 1000).toLocaleString([], { dateStyle: 'short', timeStyle: 'short' });
}
function _modemLastRestart(history) {
const restarts = (history && history.restarts) || [];
if (!restarts.length) return '';
const last = restarts[restarts.length - 1];
return `restarted ${_modemWhen(last.at)}${last.by ? ` by ${last.by}` : ''}`;
}
function _modemDuration(seconds) {
if (seconds < 60) return `${Math.round(seconds)}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
}
function _modemOutages(history) {
const outages = ((history && history.outages) || []).slice(-5).reverse();
if (!outages.length) return '';
const rows = outages.map(o => {
const length = o.restored_at === null || o.restored_at === undefined
? '<span class="lt-text-red">ongoing</span>'
: lt.escHtml(_modemDuration(o.restored_at - o.failed_at));
return `<li><span>${lt.escHtml(_modemWhen(o.failed_at))}</span><span>${length}</span></li>`;
}).join('');
return `
<div class="modem-outages">
<div class="modem-chart-title">Recent WAN outages</div>
<ul>${rows}</ul>
</div>`;
}
function updateModem(data) {
const section = document.getElementById('modem-section');
const body = document.getElementById('modem-body');
if (!section || !body) return;
section.hidden = !data.configured;
if (!data.configured) return;
// On the dashboard, link to the modem's page. On that page, it would link to itself.
const detailsLink = document.getElementById('modem-device-link');
if (detailsLink && typeof DEVICE_NAME === 'undefined' && data.name) {
detailsLink.href = deviceUrl(data.name);
detailsLink.hidden = false;
}
const rxLabel = document.getElementById('topo-modem-rx');
const topo = document.getElementById('topo-modem');
const title = document.getElementById('modem-title');
if (!data.reachable) {
if (rxLabel) rxLabel.textContent = 'unreachable';
if (topo) topo.classList.add('topo-v2-status-down');
body.innerHTML = `
<div class="modem-error">Modem status unavailable: ${lt.escHtml(data.error || 'unknown error')}
<span class="modem-stat-note">Last attempt ${lt.escHtml(data.updated || '–')}</span></div>
${_modemChart(data.history)}`;
return;
}
const problems = data.wan_problems || [];
const optics = data.optics || {};
const status = data.optics_status || {};
const counters = data.counters || {};
const rates = data.rates || {};
const rx = optics.rx_power ? optics.rx_power.value : null;
if (topo) topo.classList.toggle('topo-v2-status-down', problems.length > 0);
if (rxLabel) rxLabel.textContent = (rx === null || rx === undefined) ? 'Rx –' : `Rx ${rx} dBm`;
if (title) {
title.textContent = [data.model || 'AT&T Gateway', data.firmware && `fw ${data.firmware}`]
.filter(Boolean).join(' · ');
}
const errs = (counters.rx_errors || 0) + (counters.tx_errors || 0);
const drops = (counters.rx_drops || 0) + (counters.tx_drops || 0);
body.innerHTML = `
${problems.length ? `<div class="modem-error">WAN down: ${lt.escHtml(problems.join('; '))}</div>` : ''}
<div class="modem-stats">
${_modemStat('WAN', problems.length ? 'DOWN' : 'UP', problems.length ? 'lt-text-red' : 'lt-text-green',
data.wan_ip || '')}
${_modemStat('PON', data.pon_state || '–', '', data.wavelength || '')}
${_modemOptic('Rx power', optics.rx_power, status.rx_power, 'dBm')}
${_modemOptic('Tx power', optics.tx_power, status.tx_power, 'dBm')}
${_modemOptic('Optics temp', optics.temperature, status.temperature, '°C')}
${_modemStat('Throughput', `↓ ${_modemRate(rates.rx_bytes)}`, '', `↑ ${_modemRate(rates.tx_bytes)}`)}
${_modemStat('Errors / drops', `${errs} / ${drops}`, errs ? 'lt-text-amber' : '', 'since modem reboot')}
${_modemStat('Uptime', _modemUptime(data.uptime_s), '', _modemLastRestart(data.wan_history))}
</div>
${_modemChart(data.history)}
${_modemOutages(data.wan_history)}
<div class="modem-stat-note modem-updated">Updated ${lt.escHtml(data.updated || '–')}</div>`;
}
function updateUnifiTable(devices) {
const tbody = document.querySelector('#unifi-table tbody');
if (!tbody || !devices.length) return;
@@ -188,7 +448,7 @@ function updateUnifiTable(devices) {
return `
<tr class="${statusClass}">
<td><span class="${dotClass}"></span> ${statusText}</td>
<td><strong>${lt.escHtml(d.name)}</strong></td>
<td><a class="device-link" href="${lt.escHtml(deviceUrl(d.name))}"><strong>${lt.escHtml(d.name)}</strong></a></td>
<td>${lt.escHtml(d.type)}</td>
<td>${lt.escHtml(d.model)}</td>
<td>${lt.escHtml(d.ip)}</td>
+454
View File
@@ -0,0 +1,454 @@
'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);
};
+85
View File
@@ -1064,3 +1064,88 @@
.inspector-panel.open { width: 100%; }
.inspector-panel-inner{ width: 100%; }
}
/* ── WAN modem / fiber panel ──────────────────────────────────────── */
.modem-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
padding: 12px;
}
@media (max-width: 700px) {
.modem-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.modem-stat { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.modem-stat-label { font-size: .68em; color: var(--text-muted); text-transform: uppercase; letter-spacing: .05em; }
.modem-stat-value { font-size: 1.05em; font-weight: bold; overflow-wrap: anywhere; }
.modem-stat-note { font-size: .7em; color: var(--text-muted); display: block; }
.modem-error { margin: 12px; padding: 8px 10px; border: 1px solid var(--terminal-red, #f44); color: var(--terminal-red, #f44); }
.modem-chart { padding: 0 12px 8px; }
.modem-chart-title { font-size: .72em; color: var(--text-muted); margin-bottom: 4px; }
.modem-chart-title span { margin-left: 8px; opacity: .8; }
.modem-chart svg { display: block; width: 100%; height: 70px; border: 1px solid var(--border-color); }
.modem-chart svg polyline { stroke: var(--terminal-cyan, #0ff); stroke-width: 1.5; }
.modem-chart-empty { padding: 0 12px 8px; font-size: .72em; color: var(--text-muted); }
.modem-updated { padding: 0 12px 10px; }
/* ── Device search (home page) ────────────────────────────────────── */
.device-search { position: relative; }
.device-search label { display: block; margin-bottom: 6px; }
.device-search .lt-search { width: 100%; }
.device-search-input { width: 100%; font-size: 1em; padding-top: 10px; padding-bottom: 10px; }
.device-search-results {
position: absolute; left: 0; right: 0; z-index: 50; margin-top: 4px;
background: var(--bg-card, var(--bg-secondary)); border: 1px solid var(--border-color);
max-height: 420px; overflow-y: auto;
}
.device-search-results[hidden] { display: none; }
.device-search-row {
display: grid; grid-template-columns: 14px minmax(0, 1.4fr) 70px minmax(0, 1fr) minmax(0, 1fr);
gap: 10px; align-items: center; padding: 9px 12px; color: inherit; text-decoration: none;
border-bottom: 1px solid var(--border-color);
}
.device-search-row:last-child { border-bottom: 0; }
.device-search-row:hover, .device-search-row.is-active { background: var(--bg-tertiary); }
.device-search-name { font-weight: bold; overflow-wrap: anywhere; }
.device-search-kind { font-size: .72em; color: var(--text-muted); text-transform: uppercase; }
.device-search-meta { font-size: .8em; color: var(--text-muted); overflow-wrap: anywhere; }
.device-search-empty { padding: 12px; color: var(--text-muted); }
@media (max-width: 700px) {
.device-search-row { grid-template-columns: 14px minmax(0, 1fr) auto; }
.device-search-row .device-search-meta { grid-column: 2 / -1; }
}
/* ── Device page ───────────────────────────────────────────────────── */
a.device-link { color: inherit; text-decoration: none; border-bottom: 1px dotted var(--text-muted); }
a.device-link:hover { color: var(--text-accent); border-bottom-color: currentColor; }
.device-title { display: flex; align-items: center; gap: 10px; overflow-wrap: anywhere; }
.device-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.device-toolbar { padding: 8px 12px; margin: 0; }
.device-table td { vertical-align: middle; }
.device-num { white-space: nowrap; font-variant-numeric: tabular-nums; }
.device-row--down { opacity: .55; }
.device-row-name { overflow-wrap: anywhere; }
.device-row-sub { font-size: .72em; color: var(--text-muted); }
.device-row-toggle { background: none; border: 0; color: inherit; cursor: pointer; font: inherit; padding: 2px 6px; }
.device-row-detail > td { background: var(--bg-tertiary); padding: 10px 14px; }
.topo-host[data-host] { cursor: pointer; }
/* WAN outage list in the modem panel */
.modem-outages { padding: 0 12px 8px; }
.modem-outages ul { list-style: none; margin: 0; padding: 0; border: 1px solid var(--border-color); }
.modem-outages li { display: flex; justify-content: space-between; gap: 12px; padding: 4px 8px; font-size: .8em; }
.modem-outages li + li { border-top: 1px solid var(--border-color); }
.g-section-actions[hidden] { display: none; }
/* Rows open a detail panel holding the fields that are hidden here on narrow screens */
.device-row-detail .link-stats-grid { grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); }
.device-table th:nth-child(-n+2), .device-table td:nth-child(-n+2) { width: 1%; white-space: nowrap; }
@media (max-width: 700px) {
.device-col-wide { display: none; }
.device-table th, .device-table td { padding-left: 6px; padding-right: 6px; }
}
.device-search-row.is-active { box-shadow: inset 3px 0 0 var(--terminal-cyan, #0ff); }
@media (max-width: 700px) {
/* Scroll inside the wrapper rather than squeezing every column to a few letters */
#events-table { min-width: 760px; }
}