Lint / JS (eslint) (push) Successful in 10s
Lint / Python (flake8) (push) Successful in 57s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m7s
Lint / Python (flake8) (pull_request) Successful in 59s
Test / Python Tests (pytest) (push) Successful in 1m22s
Lint / JS (eslint) (pull_request) Successful in 11s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 1m7s
Test / Python Tests (pytest) (pull_request) Successful in 1m28s
Replace the port blocks on device pages with SVG faceplates traced from reference images: Dream Machine Pro, USW Pro 24 PoE, USW Aggregation, USW Flex, USW Flex Mini, USP PDU Pro and UPS 2U Pro. Port LEDs use each model's documented colors, and ports show a cable or module when up. Remove SWITCH_LAYOUTS, which had wrong data: the aggregation switch was numbered across the rows instead of odd over even, and the USW Flex had an SFP cage it doesn't have. Models without a faceplate use plain blocks. Show PDU and UPS outlets with on/off state and auto power-cycle, and let the UPS page show its panel even though it reports no ports. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
423 lines
20 KiB
JavaScript
423 lines
20 KiB
JavaScript
'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">╚</span><span class="lt-frame-br">╝</span>
|
||
${inner}
|
||
</div>
|
||
</section>`;
|
||
}
|
||
|
||
function kvGrid(pairs) {
|
||
return `<div class="modem-stats">${pairs.filter(([, v]) => v !== null && v !== undefined && v !== '').map(([k, v]) => `
|
||
<div class="modem-stat"><span class="modem-stat-label">${esc(k)}</span><span class="modem-stat-value">${esc(v)}</span></div>`).join('')}</div>`;
|
||
}
|
||
|
||
function renderOverview(d) {
|
||
const info = d.info || {};
|
||
const pct = v => (v === null || v === undefined ? null : `${v.toFixed(0)}%`);
|
||
const temps = (info.temps || []).filter(t => t.value_c !== null).map(t => `${t.name} ${t.value_c.toFixed(0)}°C`).join(', ');
|
||
return section('Overview', kvGrid([
|
||
['Status', (d.status || 'unknown').toUpperCase()],
|
||
['Type', d.kind],
|
||
['IP', d.ip],
|
||
['MAC', d.mac],
|
||
['Model', d.model],
|
||
['Firmware', info.firmware],
|
||
['Uptime', info.uptime_s === undefined ? null : fmtUptime(info.uptime_s)],
|
||
['CPU', pct(info.cpu_pct)],
|
||
['Memory', pct(info.mem_pct)],
|
||
['Clients', info.clients],
|
||
['Temperature', temps],
|
||
['Power draw', info.power_w === null || info.power_w === undefined ? null : `${info.power_w.toFixed(0)} W`],
|
||
['Data source', d.source],
|
||
]));
|
||
}
|
||
|
||
function renderUplinks(d) {
|
||
if (!(d.uplinks || []).length) return '';
|
||
const rows = d.uplinks.map(u => `
|
||
<tr>
|
||
<td><span class="dot-${u.up ? 'up' : 'down'}"></span></td>
|
||
<td>${esc(u.host_interface || '–')}</td>
|
||
<td><a class="device-link" href="${esc(deviceUrl(u.switch))}">${esc(u.switch)}</a></td>
|
||
<td><a class="device-link" href="${esc(deviceUrl(u.switch))}#port-${esc(u.port_idx)}">#${esc(u.port_idx)} ${esc(u.port)}</a></td>
|
||
<td>${fmtSpeed(u.speed_mbps)}</td>
|
||
</tr>`).join('');
|
||
return section('Connected to', `<div class="lt-table-wrap"><table class="lt-table">
|
||
<caption class="lt-sr-only">Switch ports this host connects to, from LLDP</caption>
|
||
<thead><tr><th><span class="lt-sr-only">State</span></th><th>Interface</th><th>Switch</th><th>Port</th><th>Speed</th></tr></thead>
|
||
<tbody>${rows}</tbody></table></div>`);
|
||
}
|
||
|
||
function renderWan(d) {
|
||
const wan = (d.info && d.info.wan) || {};
|
||
const names = Object.keys(wan);
|
||
if (!names.length) return '';
|
||
const rows = names.map(n => {
|
||
const w = wan[n];
|
||
return `<tr><td><span class="dot-${w.up ? 'up' : 'down'}"></span></td><td>${esc(n.toUpperCase())}</td>
|
||
<td>${fmtSpeed(w.speed_mbps)}</td><td>${w.latency_ms === null || w.latency_ms === undefined ? '–' : `${esc(w.latency_ms)} ms`}</td>
|
||
<td>${w.availability === null || w.availability === undefined ? '–' : `${esc(w.availability)}%`}</td></tr>`;
|
||
}).join('');
|
||
return section('WAN', `<div class="lt-table-wrap"><table class="lt-table">
|
||
<caption class="lt-sr-only">WAN connections</caption>
|
||
<thead><tr><th><span class="lt-sr-only">State</span></th><th>Connection</th><th>Speed</th><th>Latency</th><th>Availability</th></tr></thead>
|
||
<tbody>${rows}</tbody></table></div>`);
|
||
}
|
||
|
||
function renderOutlets(d) {
|
||
const outlets = (d.info && d.info.outlets) || [];
|
||
if (!outlets.length) return '';
|
||
const rows = outlets.map(o => `
|
||
<tr>
|
||
<td><span class="dot-${o.on ? 'up' : 'down'}"></span></td>
|
||
<td>#${esc(o.index)}</td>
|
||
<td>${esc(o.name || '–')}</td>
|
||
<td class="device-num">${o.power_w === null || o.power_w === undefined ? '–' : `${o.power_w.toFixed(1)} W`}</td>
|
||
<td>${o.auto_power_cycle ? '<span class="lt-badge">auto power-cycle</span>' : ''}</td>
|
||
</tr>`).join('');
|
||
return section('Outlets', `<div class="lt-table-wrap"><table class="lt-table">
|
||
<caption class="lt-sr-only">PDU outlets</caption>
|
||
<thead><tr><th><span class="lt-sr-only">State</span></th><th>Outlet</th><th>Name</th><th>Power</th><th></th></tr></thead>
|
||
<tbody>${rows}</tbody></table></div>`);
|
||
}
|
||
|
||
function renderEvents(events) {
|
||
const body = !(events || []).length
|
||
? '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-icon">✔</div><div class="lt-empty-state-title">No recorded alerts</div></div>'
|
||
: `<div class="lt-table-wrap"><table class="lt-table">
|
||
<caption class="lt-sr-only">Alerts for this device</caption>
|
||
<thead><tr><th>Sev</th><th>Type</th><th>Detail</th><th>Description</th><th>First seen</th><th>State</th></tr></thead>
|
||
<tbody>${events.map(e => `
|
||
<tr class="${e.resolved_at ? '' : `row-${esc(e.severity)}`}">
|
||
<td><span class="lt-badge badge-${e.resolved_at ? 'resolved' : esc(e.severity)}">${esc(e.severity)}</span>${e.is_suppressed && !e.resolved_at ? '<span class="lt-badge badge-suppressed">🔕 sup</span>' : ''}</td>
|
||
<td>${esc(e.event_type.replace(/_/g, ' '))}</td>
|
||
<td>${esc(e.target_detail || '–')}</td>
|
||
<td class="desc-cell">${esc(e.description || '')}</td>
|
||
<td class="ts-cell" title="${esc(e.first_seen || '')}">${esc(lt.time.ago(_toIso(e.first_seen)))}</td>
|
||
<td>${e.resolved_at ? `resolved ${esc(lt.time.ago(_toIso(e.resolved_at)))}` : '<strong>active</strong>'}</td>
|
||
</tr>`).join('')}</tbody></table></div>`;
|
||
return section('Alerts', body);
|
||
}
|
||
|
||
function currentRows() {
|
||
if (!_device) return [];
|
||
if (_device.ports) return portRows(_device.ports);
|
||
if (_device.interfaces) return hostRows(_device.interfaces);
|
||
return [];
|
||
}
|
||
|
||
function renderInterfaces() {
|
||
const wrap = document.getElementById('device-table-wrap');
|
||
if (!wrap) return;
|
||
wrap.innerHTML = renderTable(currentRows());
|
||
highlightSelectedPort();
|
||
}
|
||
|
||
function updateInterfaceSection(rows, title, 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);
|
||
};
|