455 lines
21 KiB
JavaScript
455 lines
21 KiB
JavaScript
'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">╚</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>#${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);
|
|||
|
|
};
|