Files
gandalf/static/device.js
jaredandClaude Opus 5.5 c262e5ecc8
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (flake8) (push) Successful in 54s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m1s
Lint / JS (eslint) (pull_request) Successful in 19s
Test / Python Tests (pytest) (push) Successful in 2m6s
Security / Python Security (bandit) (pull_request) Successful in 1m18s
Lint / Python (flake8) (pull_request) Successful in 53s
Test / Python Tests (pytest) (pull_request) Successful in 1m1s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Inventory links, cutsheet marks on faceplates, and single page titles
Device pages get an Inventory button: it opens the device's HomeBox record
once an admin pastes its URL (stored in the new device_links table; only
URLs on inventory.base_url are accepted), and searches the inventory for
the model until then. Tickets link the record too.

Faceplate ports with a documented cable get a corner dot: green matches,
red mismatch, grey documented but unconfirmed. The port's tooltip names
the cable, and a legend toggle (remembered per browser) hides them.

Page titles no longer repeat GANDALF ("Alert History - GANDALF - GANDALF").

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

465 lines
22 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, CAN_EDIT, 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;
}
// ── Inventory (HomeBox) link ────────────────────────────────────────────
function inventoryActions(d) {
const inv = d.inventory || {};
const edit = CAN_EDIT
? `<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" data-action="edit-inventory"
aria-label="${inv.url ? 'Change' : 'Set'} the inventory link for ${esc(d.name)}">✎</button>` : '';
if (inv.url) {
return `<a class="lt-btn lt-btn-secondary lt-btn-sm" href="${esc(inv.url)}" target="_blank" rel="noopener">📦 Inventory ↗</a>${edit}`;
}
if (!inv.search_url) return edit;
return `<a class="lt-btn lt-btn-ghost lt-btn-sm" href="${esc(inv.search_url)}" target="_blank" rel="noopener"
title="No inventory record is linked yet">📦 Find in inventory ↗</a>${edit}`;
}
function openInventoryEditor() {
const inv = (_device && _device.inventory) || {};
const form = document.getElementById('inventory-form');
form.url.value = inv.url || '';
document.getElementById('inventory-search').href = inv.search_url || '#';
lt.modal.open('inventory-modal');
form.url.focus();
}
async function saveInventory(e) {
e.preventDefault();
try {
await lt.api.put(`/api/device/${encodeURIComponent(DEVICE_NAME)}/inventory`, { url: e.target.url.value.trim() });
lt.modal.close('inventory-modal');
lt.toast.success(e.target.url.value.trim() ? 'Inventory link saved' : 'Inventory link removed');
await loadDevice();
} catch (err) {
lt.toast.error(err.message || 'Save failed');
}
}
document.addEventListener('click', e => {
if (e.target.closest('[data-action="edit-inventory"]')) openInventoryEditor();
});
const _inventoryForm = document.getElementById('inventory-form');
if (_inventoryForm) _inventoryForm.addEventListener('submit', saveInventory);
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 = inventoryActions(d) + `
<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);
};