'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('ERR'); if ((d.tx_drops_rate || 0) > 0.001 || (d.rx_drops_rate || 0) > 0.001) out.push('DROP'); if ((d.carrier_changes || 0) > 3) out.push('FLAP'); return out.join(''); } function stat(label, valueHtml, tooltip) { const tip = tooltip ? ` data-tooltip="${esc(tooltip)}"` : ''; return ``; } 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) => `
${label}
${fmtRate(rate)}
`; return `
${row('TX', tx, d.tx_bytes_rate, true)}${row('RX', rx, d.rx_bytes_rate, false)}
`; } 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) => `
${label} ${value} ${pct === undefined ? '' : `
`}
`; 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 `
${vendor}
${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.') : ''}
`; } // ── 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: ` ${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 '
Nothing matches this filter
'; 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 ? `${esc(r.neighbor.name)}` : esc(r.neighbor.chassis || r.neighbor.port || '')) : '–'; return ` ${r.num !== undefined ? `#${esc(r.num)} ` : ''}${esc(r.label)}
${esc(r.sub)}
${r.up ? fmtSpeed(d.speed_mbps) : '–'} ${fmtRate(d.rx_bytes_rate)} ${fmtRate(d.tx_bytes_rate)} ${isPorts ? `${neighbor}` : ''} ${badges(d) || (r.up ? 'OK' : 'down')} ${open ? `${r.detail}` : ''}`; }).join(''); return `
${isPorts ? '' : ''}${body}
Interfaces
ExpandState ${isPorts ? 'Port' : 'Interface'}SpeedRXTXNeighborHealth
`; } function section(title, inner, id) { return `

${esc(title)}

╚╝ ${inner}
`; } function kvGrid(pairs) { return `
${pairs.filter(([, v]) => v !== null && v !== undefined && v !== '').map(([k, v]) => `
${esc(k)}${esc(v)}
`).join('')}
`; } 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 => ` ${esc(u.host_interface || '–')} ${esc(u.switch)} #${esc(u.port_idx)} ${esc(u.port)} ${fmtSpeed(u.speed_mbps)} `).join(''); return section('Connected to', `
${rows}
Switch ports this host connects to, from LLDP
StateInterfaceSwitchPortSpeed
`); } 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 `${esc(n.toUpperCase())} ${fmtSpeed(w.speed_mbps)}${w.latency_ms === null || w.latency_ms === undefined ? '–' : `${esc(w.latency_ms)} ms`} ${w.availability === null || w.availability === undefined ? '–' : `${esc(w.availability)}%`}`; }).join(''); return section('WAN', `
${rows}
WAN connections
StateConnectionSpeedLatencyAvailability
`); } function renderOutlets(d) { const outlets = (d.info && d.info.outlets) || []; if (!outlets.length) return ''; const rows = outlets.map(o => ` #${esc(o.index)} ${esc(o.name || '–')} ${o.power_w === null || o.power_w === undefined ? '–' : `${o.power_w.toFixed(1)} W`} ${o.auto_power_cycle ? 'auto power-cycle' : ''} `).join(''); return section('Outlets', `
${rows}
PDU outlets
StateOutletNamePower
`); } function renderEvents(events) { const body = !(events || []).length ? '
✔
No recorded alerts
' : `
${events.map(e => ` `).join('')}
Alerts for this device
SevTypeDetailDescriptionFirst seenState
${esc(e.severity)}${e.is_suppressed && !e.resolved_at ? '🔕 sup' : ''} ${esc(e.event_type.replace(/_/g, ' '))} ${esc(e.target_detail || '–')} ${esc(e.description || '')} ${esc(lt.time.ago(_toIso(e.first_seen)))} ${e.resolved_at ? `resolved ${esc(lt.time.ago(_toIso(e.resolved_at)))}` : 'active'}
`; 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 = ` `; 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 = `
✕
${notFound ? 'No device with this name' : 'Could not load this device'}
${notFound ? 'It may have been renamed, or the monitor may not have seen it yet.' : 'Check that the Gandalf service is healthy, then refresh.'}
`; } } 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); };