'use strict'; /* exported initDeviceSearch */ /* global DEVICE_NAME */ // ── Auto-redirect on auth timeout ───────────────────────────────────── // Wraps fetch so a 401 (Authelia session expired) forces a full reload. // lt.api uses fetch internally, so this covers all API calls too. (function () { const _fetch = window.fetch; window.fetch = async function (...args) { const resp = await _fetch(...args); if (resp.status === 401) { window.location.reload(); throw new Error('Session expired — reloading'); } return resp; }; })(); // ── Toast notifications — thin wrapper over lt.toast ────────────────── function showToast(msg, type = 'success') { if (type === 'error') return lt.toast.error(msg); if (type === 'warning') return lt.toast.warning(msg); if (type === 'info') return lt.toast.info(msg); return lt.toast.success(msg); } // ── Normalise UTC timestamp string for Date() parsing ───────────────── // Server returns "2026-03-14 14:14:21 UTC"; Date() needs ISO 8601. function _toIso(s) { if (!s) return s; return s.replace(' UTC', 'Z').replace(' ', 'T'); } // ── Dashboard auto-refresh ──────────────────────────────────────────── async function refreshAll() { const refreshBtn = document.querySelector('[data-action="refresh"]'); const spinner = document.getElementById('refresh-spinner'); if (refreshBtn) refreshBtn.classList.add('is-loading'); if (spinner) spinner.style.display = ''; try { const [netResult, statusResult] = await Promise.allSettled([ lt.api.get('/api/network'), lt.api.get('/api/status'), refreshModem(), ]); if (netResult.status === 'fulfilled') { const net = netResult.value; updateHostGrid(net.hosts || {}); updateUnifiTable(net.unifi || []); updateTopology(net.hosts || {}); } else { showToast('Network data unavailable', 'warning'); } if (statusResult.status === 'fulfilled') { const status = statusResult.value; updateEventsTable(status.events || [], status.total_active); updateStatusBar(status.summary || {}, status.last_check || '', status.daemon_ok); if (typeof window.gandalfNotifUpdate === 'function') window.gandalfNotifUpdate(status.events || []); } else { showToast('Status data unavailable', 'warning'); } } finally { if (refreshBtn) refreshBtn.classList.remove('is-loading'); if (spinner) spinner.style.display = 'none'; } } function updateStatusBar(summary, lastCheck, daemonOk) { const bar = document.querySelector('.status-chips'); if (!bar) return; const chips = []; if (daemonOk === false) chips.push('⚠ MONITOR OFFLINE'); if (summary.critical) chips.push(`● ${summary.critical} CRITICAL`); if (summary.warning) chips.push(`● ${summary.warning} WARNING`); if (!summary.critical && !summary.warning && daemonOk !== false) chips.push('✔ ALL SYSTEMS NOMINAL'); bar.innerHTML = chips.join(''); const lc = document.getElementById('last-check'); if (lc && lastCheck) lc.textContent = lastCheck; const critCount = summary.critical || 0; const warnCount = summary.warning || 0; if (critCount) document.title = `(${critCount} CRIT) GANDALF`; else if (warnCount) document.title = `(${warnCount} WARN) GANDALF`; else document.title = 'GANDALF'; const alertBadge = document.getElementById('alert-count-badge'); if (alertBadge) { const total = critCount + warnCount; alertBadge.textContent = total; alertBadge.style.display = total ? '' : 'none'; } // Update stat cards const scCrit = document.getElementById('stat-critical-val'); const scWarn = document.getElementById('stat-warning-val'); const scRes = document.getElementById('stat-resolved-val'); if (scCrit) scCrit.textContent = critCount; if (scWarn) scWarn.textContent = warnCount; if (scRes && summary.resolved_24h !== null && summary.resolved_24h !== undefined) scRes.textContent = summary.resolved_24h; const statCritCard = document.getElementById('stat-critical'); if (statCritCard) statCritCard.classList.toggle('lt-stat-card--alert', critCount > 0); // Stale data banner: warn if last_check is older than 15 minutes let staleBanner = document.getElementById('stale-banner'); if (lastCheck) { const checkAge = (Date.now() - new Date(_toIso(lastCheck))) / 1000; if (checkAge > 900) { if (!staleBanner) { staleBanner = document.createElement('div'); staleBanner.id = 'stale-banner'; staleBanner.className = 'lt-alert lt-alert--warning'; staleBanner.innerHTML = '⚠
'; document.querySelector('.lt-main').prepend(staleBanner); } const mins = Math.floor(checkAge / 60); staleBanner.querySelector('.lt-alert-msg').textContent = `Monitoring data is stale — last check was ${mins} minute${mins !== 1 ? 's' : ''} ago. The monitor daemon may be down.`; staleBanner.style.display = ''; } else if (staleBanner) { staleBanner.style.display = 'none'; } } } function updateHostGrid(hosts) { const scHosts = document.getElementById('stat-hosts-val'); if (scHosts) scHosts.textContent = Object.keys(hosts).length; for (const [name, host] of Object.entries(hosts)) { const card = document.querySelector(`.host-card[data-host="${CSS.escape(name)}"]`); if (!card) continue; card.className = card.className.replace(/host-card-(up|down|degraded|unknown)/g, ''); card.classList.add(`host-card-${host.status}`); const dot = card.querySelector('.host-status-dot'); if (dot) dot.className = `host-status-dot dot-${host.status}`; const ifaceList = card.querySelector('.iface-list'); if (ifaceList && host.interfaces && Object.keys(host.interfaces).length > 0) { ifaceList.innerHTML = Object.entries(host.interfaces) .sort(([a], [b]) => a.localeCompare(b)) .map(([iface, state]) => `
${lt.escHtml(iface)} ${state}
`).join(''); } } } function updateTopology(hosts) { document.querySelectorAll('.topo-host').forEach(node => { const name = node.dataset.host; const host = hosts[name]; if (!host) return; node.className = node.className.replace(/topo-v2-status-(up|down|degraded|unknown)/g, ''); node.classList.add(`topo-v2-status-${host.status}`); const badge = node.querySelector('.topo-badge'); if (badge) { badge.className = `topo-badge topo-badge-${host.status}`; badge.textContent = host.status; } // Animate the 10G drop-wire red+dashed when host is down document.querySelectorAll(`.topo-v2-wire-10g[data-host="${CSS.escape(name)}"]`).forEach(wire => { wire.classList.toggle('wire-down', host.status === 'down'); }); }); } // ── Device search ───────────────────────────────────────────────────── function deviceUrl(name) { return `/device/${encodeURIComponent(name)}`; } // All words must match somewhere; the device's name matching ranks first. function rankDevices(devices, query) { const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean); if (!words.length) return []; const scored = []; for (const d of devices) { if (!words.every(w => d.search.includes(w))) continue; const name = d.name.toLowerCase(); let score = 3; if (name === words[0]) score = 0; else if (name.startsWith(words[0])) score = 1; else if (name.includes(words[0]) || (d.ip || '').startsWith(words[0])) score = 2; scored.push({ d, score }); } return scored .sort((a, b) => a.score - b.score || a.d.name.localeCompare(b.d.name)) .slice(0, 12) .map(s => s.d); } function initDeviceSearch() { const input = document.getElementById('device-search-input'); const results = document.getElementById('device-search-results'); if (!input || !results) return; let devices = []; let matches = []; let active = 0; async function loadDevices() { try { devices = (await lt.api.get('/api/devices')).devices || []; } catch (err) { console.warn('Device list unavailable', err); } } function close() { results.hidden = true; input.setAttribute('aria-expanded', 'false'); input.removeAttribute('aria-activedescendant'); } function render() { matches = rankDevices(devices, input.value); if (!input.value.trim()) { close(); return; } active = Math.min(active, Math.max(matches.length - 1, 0)); results.hidden = false; input.setAttribute('aria-expanded', 'true'); if (!matches.length) { results.innerHTML = '
No devices match.
'; input.removeAttribute('aria-activedescendant'); return; } results.innerHTML = matches.map((d, i) => ` ${lt.escHtml(d.name)} ${lt.escHtml(d.kind)} ${lt.escHtml(d.ip || '')} ${lt.escHtml(d.model || '')} `).join(''); input.setAttribute('aria-activedescendant', `device-opt-${active}`); } input.addEventListener('input', () => { active = 0; render(); }); input.addEventListener('focus', () => { loadDevices().then(render); }); input.addEventListener('keydown', e => { if (e.key === 'Escape') { close(); return; } if (!matches.length) return; if (e.key === 'ArrowDown') { e.preventDefault(); active = (active + 1) % matches.length; render(); } if (e.key === 'ArrowUp') { e.preventDefault(); active = (active - 1 + matches.length) % matches.length; render(); } if (e.key === 'Enter') { e.preventDefault(); window.location.href = deviceUrl(matches[active].name); } }); document.addEventListener('click', e => { if (!e.target.closest('#device-search')) close(); }); document.addEventListener('keydown', e => { const typing = e.target.closest('input, textarea, select, [contenteditable]'); if (e.key === '/' && !typing && !e.ctrlKey && !e.metaKey) { e.preventDefault(); input.focus(); } }); loadDevices(); } // ── WAN modem / fiber panel ─────────────────────────────────────────── async function refreshModem() { try { updateModem(await lt.api.get('/api/modem')); } catch (err) { console.warn('Modem data unavailable', err); } } function _modemUptime(seconds) { if (seconds === null || seconds === undefined) return '–'; const d = Math.floor(seconds / 86400); const h = Math.floor((seconds % 86400) / 3600); const m = Math.floor((seconds % 3600) / 60); return d ? `${d}d ${h}h` : `${h}h ${m}m`; } function _modemRate(bytesPerSec) { if (bytesPerSec === null || bytesPerSec === undefined) return '–'; const mbps = bytesPerSec * 8 / 1e6; return mbps >= 1000 ? `${(mbps / 1000).toFixed(2)} Gbps` : `${mbps.toFixed(1)} Mbps`; } function _modemStat(label, value, cls, note) { return `
${lt.escHtml(label)} ${lt.escHtml(String(value))} ${note ? `${lt.escHtml(note)}` : ''}
`; } function _modemOptic(label, metric, status, unit) { if (!metric || metric.value === null || metric.value === undefined) return _modemStat(label, '–'); const cls = { alarm: 'lt-text-red', warning: 'lt-text-amber', ok: 'lt-text-green' }[status] || ''; return _modemStat(label, `${metric.value} ${unit}`, cls, `warn ${metric.warn_low} to ${metric.warn_high}`); } function _modemChart(history) { const points = (history || []).filter(p => p.rx_avg !== null && p.rx_avg !== undefined); if (points.length < 2) { return '
Rx power trend appears after a few polls.
'; } const W = 600, H = 70, PAD = 4; const values = points.map(p => p.rx_avg); const low = Math.min(...values) - 0.5, high = Math.max(...values) + 0.5; const t0 = points[0].ts, tSpan = (points[points.length - 1].ts - t0) || 1; const coord = p => `${((p.ts - t0) / tSpan * W).toFixed(1)},` + `${(PAD + (high - p.rx_avg) / (high - low) * (H - 2 * PAD)).toFixed(1)}`; // Break the line where samples are missing rather than drawing across an outage const gaps = points.slice(1).map((p, i) => p.ts - points[i].ts).sort((a, b) => a - b); const maxGap = gaps[Math.floor(gaps.length / 2)] * 3; const segments = [[]]; points.forEach((p, i) => { if (i && p.ts - points[i - 1].ts > maxGap) segments.push([]); segments[segments.length - 1].push(coord(p)); }); const lines = segments .map(s => ``) .join(''); const summary = `range ${Math.min(...values)} to ${Math.max(...values)} dBm`; return `
Rx power, last 7 days ${lt.escHtml(summary)}
${lines}
`; } function _modemWhen(epochSeconds) { return new Date(epochSeconds * 1000).toLocaleString([], { dateStyle: 'short', timeStyle: 'short' }); } function _modemLastRestart(history) { const restarts = (history && history.restarts) || []; if (!restarts.length) return ''; const last = restarts[restarts.length - 1]; return `restarted ${_modemWhen(last.at)}${last.by ? ` by ${last.by}` : ''}`; } function _modemDuration(seconds) { if (seconds < 60) return `${Math.round(seconds)}s`; if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`; return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`; } function _modemOutages(history) { const outages = ((history && history.outages) || []).slice(-5).reverse(); if (!outages.length) return ''; const rows = outages.map(o => { const length = o.restored_at === null || o.restored_at === undefined ? 'ongoing' : lt.escHtml(_modemDuration(o.restored_at - o.failed_at)); return `
  • ${lt.escHtml(_modemWhen(o.failed_at))}${length}
  • `; }).join(''); return `
    Recent WAN outages
    `; } function updateModem(data) { const section = document.getElementById('modem-section'); const body = document.getElementById('modem-body'); if (!section || !body) return; section.hidden = !data.configured; if (!data.configured) return; // On the dashboard, link to the modem's page. On that page, it would link to itself. const detailsLink = document.getElementById('modem-device-link'); if (detailsLink && typeof DEVICE_NAME === 'undefined' && data.name) { detailsLink.href = deviceUrl(data.name); detailsLink.hidden = false; } const rxLabel = document.getElementById('topo-modem-rx'); const topo = document.getElementById('topo-modem'); const title = document.getElementById('modem-title'); if (!data.reachable) { if (rxLabel) rxLabel.textContent = 'unreachable'; if (topo) topo.classList.add('topo-v2-status-down'); body.innerHTML = `
    Modem status unavailable: ${lt.escHtml(data.error || 'unknown error')} Last attempt ${lt.escHtml(data.updated || '–')}
    ${_modemChart(data.history)}`; return; } const problems = data.wan_problems || []; const optics = data.optics || {}; const status = data.optics_status || {}; const counters = data.counters || {}; const rates = data.rates || {}; const rx = optics.rx_power ? optics.rx_power.value : null; if (topo) topo.classList.toggle('topo-v2-status-down', problems.length > 0); if (rxLabel) rxLabel.textContent = (rx === null || rx === undefined) ? 'Rx –' : `Rx ${rx} dBm`; if (title) { title.textContent = [data.model || 'AT&T Gateway', data.firmware && `fw ${data.firmware}`] .filter(Boolean).join(' · '); } const errs = (counters.rx_errors || 0) + (counters.tx_errors || 0); const drops = (counters.rx_drops || 0) + (counters.tx_drops || 0); body.innerHTML = ` ${problems.length ? `
    WAN down: ${lt.escHtml(problems.join('; '))}
    ` : ''}
    ${_modemStat('WAN', problems.length ? 'DOWN' : 'UP', problems.length ? 'lt-text-red' : 'lt-text-green', data.wan_ip || '')} ${_modemStat('PON', data.pon_state || '–', '', data.wavelength || '')} ${_modemOptic('Rx power', optics.rx_power, status.rx_power, 'dBm')} ${_modemOptic('Tx power', optics.tx_power, status.tx_power, 'dBm')} ${_modemOptic('Optics temp', optics.temperature, status.temperature, '°C')} ${_modemStat('Throughput', `↓ ${_modemRate(rates.rx_bytes)}`, '', `↑ ${_modemRate(rates.tx_bytes)}`)} ${_modemStat('Errors / drops', `${errs} / ${drops}`, errs ? 'lt-text-amber' : '', 'since modem reboot')} ${_modemStat('Uptime', _modemUptime(data.uptime_s), '', _modemLastRestart(data.wan_history))}
    ${_modemChart(data.history)} ${_modemOutages(data.wan_history)}
    Updated ${lt.escHtml(data.updated || '–')}
    `; } function updateUnifiTable(devices) { const tbody = document.querySelector('#unifi-table tbody'); if (!tbody || !devices.length) return; tbody.innerHTML = devices.map(d => { const statusClass = d.connected ? '' : 'row-critical'; const dotClass = d.connected ? 'dot-up' : 'dot-down'; const statusText = d.connected ? 'ONLINE' : 'OFFLINE'; const suppressBtn = !d.connected ? `` : ''; return ` ${statusText} ${lt.escHtml(d.name)} ${lt.escHtml(d.type)} ${lt.escHtml(d.model)} ${lt.escHtml(d.ip)} ${suppressBtn} `; }).join(''); } function updateEventsTable(events, totalActive) { const wrap = document.getElementById('events-table-wrap'); if (!wrap) return; const active = (events || []).filter(e => e.severity !== 'info'); if (!active.length) { wrap.innerHTML = '
    ✔
    No active alerts
    '; return; } const truncated = totalActive !== null && totalActive !== undefined && totalActive > active.length; const countNotice = truncated ? `
    Showing ${active.length} of ${totalActive} active alerts — view all via API
    ` : ''; const rows = active.map(e => { const supType = e.event_type === 'unifi_device_offline' ? 'unifi_device' : e.event_type === 'interface_down' ? 'interface' : 'host'; const ticketBase = (typeof GANDALF_CONFIG !== 'undefined' && GANDALF_CONFIG.ticket_web_url) ? GANDALF_CONFIG.ticket_web_url : 'http://t.lotusguild.org/ticket/'; const ticket = e.ticket_id ? `#${lt.escHtml(String(e.ticket_id))}` : '–'; const supBadge = e.is_suppressed ? `🔕 sup` : ''; return ` ${e.severity}${supBadge} ${lt.escHtml(e.event_type.replace(/_/g,' '))} ${lt.escHtml(e.target_name)} ${lt.escHtml(e.target_detail || '–')} ${lt.escHtml((e.description||'').substring(0,60))}${(e.description||'').length>60?'…':''} ${lt.time.ago(_toIso(e.first_seen))} ${lt.time.ago(_toIso(e.last_seen))} ${e.consecutive_failures} ${ticket} `; }).join(''); wrap.innerHTML = ` ${countNotice}
    ${rows}
    Active network alerts
    SevTypeTargetDetail DescriptionFirst SeenLast SeenFailuresTicketActions
    `; } // ── Suppression modal ───────────────────────────────────────────────── function openSuppressModal(type, name, detail) { const modal = document.getElementById('suppress-modal'); if (!modal) return; document.getElementById('sup-type').value = type; document.getElementById('sup-name').value = name; document.getElementById('sup-detail').value = detail; document.getElementById('sup-reason').value = ''; document.getElementById('sup-expires').value = ''; updateSuppressForm(); lt.modal.open('suppress-modal'); document.querySelectorAll('#suppress-modal .pill').forEach(p => { p.classList.remove('active'); p.setAttribute('aria-pressed', 'false'); }); const manualPill = document.querySelector('#suppress-modal .pill-manual'); if (manualPill) { manualPill.classList.add('active'); manualPill.setAttribute('aria-pressed', 'true'); } const hint = document.getElementById('duration-hint'); if (hint) hint.textContent = 'Suppression will persist until manually removed.'; } function closeSuppressModal() { lt.modal.close('suppress-modal'); } function updateSuppressForm() { const type = document.getElementById('sup-type').value; const nameGrp = document.getElementById('sup-name-group'); const detailGrp = document.getElementById('sup-detail-group'); const nameInput = document.getElementById('sup-name'); const detailInput = document.getElementById('sup-detail'); if (nameGrp) nameGrp.style.display = (type === 'all') ? 'none' : ''; if (detailGrp) detailGrp.style.display = (type === 'interface') ? '' : 'none'; if (nameInput) { const req = (type !== 'all'); nameInput.required = req; nameInput.setAttribute('aria-required', String(req)); } if (detailInput) { const req = (type === 'interface'); detailInput.required = req; detailInput.setAttribute('aria-required', String(req)); } } function setDuration(mins, el, opts) { const o = opts || {}; const expiresEl = document.getElementById(o.expiresId || 'sup-expires'); const pillSel = o.pillSel || '#suppress-modal .pill'; const hint = document.getElementById(o.hintId || 'duration-hint'); if (expiresEl) expiresEl.value = mins || ''; document.querySelectorAll(pillSel).forEach(p => { p.classList.remove('active'); p.setAttribute('aria-pressed', 'false'); }); if (el) { el.classList.add('active'); el.setAttribute('aria-pressed', 'true'); } if (hint) { if (mins) { const h = Math.floor(mins / 60), m = mins % 60; hint.textContent = `Expires in ${h ? h + 'h ' : ''}${m ? m + 'm' : ''}.`; } else { hint.textContent = 'Suppression will persist until manually removed.'; } } } async function submitSuppress(e) { e.preventDefault(); const type = document.getElementById('sup-type').value; const name = document.getElementById('sup-name').value; const detail = document.getElementById('sup-detail').value; const reason = document.getElementById('sup-reason').value; const expires = document.getElementById('sup-expires').value; if (!reason.trim()) { showToast('Reason is required', 'error'); return; } if (type !== 'all' && !name.trim()) { showToast('Target name is required', 'error'); return; } try { await lt.api.post('/api/suppressions', { target_type: type, target_name: name, target_detail: detail, reason, expires_minutes: expires ? parseInt(expires) : null, }); closeSuppressModal(); showToast('Suppression applied ✔', 'success'); setTimeout(refreshAll, 500); } catch (err) { showToast(err.message || 'Failed to apply suppression', 'error'); } } // ── Suppress form – wired here so the modal works from any page ────── document.getElementById('suppress-form')?.addEventListener('submit', submitSuppress); document.getElementById('sup-type')?.addEventListener('change', updateSuppressForm); // ── Global click delegation ─────────────────────────────────────────── document.addEventListener('click', e => { // Refresh button if (e.target.closest('[data-action="refresh"]')) { lt.autoRefresh.now(); return; } // Duration pills (data-duration="" = manual/forever) const pill = e.target.closest('.pill[data-duration]'); if (pill) { const val = pill.dataset.duration; setDuration(val ? parseInt(val) : null, pill); return; } // Suppress buttons const btn = e.target.closest('.btn-suppress[data-sup-type]'); if (btn) { openSuppressModal( btn.dataset.supType || '', btn.dataset.supName || '', btn.dataset.supDetail || '', ); } });