Files
gandalf/static/device.js
T
jaredandClaude Opus 5.5 9d561db703
Lint / JS (eslint) (push) Successful in 6s
Lint / Python (flake8) (push) Successful in 1m56s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 2m3s
Test / Python Tests (pytest) (push) Successful in 1m0s
Lint / Python (flake8) (pull_request) Successful in 48s
Lint / JS (eslint) (pull_request) Successful in 6s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 2m7s
Test / Python Tests (pytest) (pull_request) Successful in 2m6s
Add an alert history page and tie /health staleness to the poll interval
/events lists every alert from the last 24 hours to 90 days, open or
cleared, with how long each lasted, failed checks and its ticket. Filters
(device, type, severity, state, range) live in the URL, so device pages
and tickets link straight to a device's history. A "Recurring" table
lists alerts that fired more than once on the same target. On phones the
rows become cards.

/api/events/history backs it (100 per page). The existing /api/events is
unchanged.

/health and the dashboard's monitor indicator now call the monitor
stalled after five missed polls (at least 10 minutes) instead of a fixed
20, and /health reports the monitor's age.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:29:09 -05:00

424 lines
20 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, fmtRate, fmtErrors, renderPortInspector, selectPortByIdx, highlightSelectedPort, FACEPLATES */
// 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">&#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><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>`;
const more = `<p class="g-section-more"><a href="/events?target=${encodeURIComponent(DEVICE_NAME)}&amp;range=90d">Full alert history for ${esc(DEVICE_NAME)} →</a></p>`;
return section('Alerts', body + more);
}
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, hasChassis) {
const section = document.getElementById('device-ports');
section.hidden = !rows.length && !hasChassis;
document.querySelector('.device-port-table').hidden = !rows.length;
if (!rows.length) {
document.getElementById('device-ports-title').textContent = 'Front panel';
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);
// A device like a UPS has a faceplate worth showing but no ports to list
const hasChassis = hasPorts || !!FACEPLATES[d.model];
document.getElementById('device-inspector').classList.toggle('has-chassis', hasChassis);
updateInterfaceSection(currentRows(), d.ports ? 'Ports' : 'Interfaces', hasChassis);
if (hasChassis) 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);
};