'use strict'; // ── 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"]'); if (refreshBtn) refreshBtn.classList.add('is-loading'); try { const [netResult, statusResult] = await Promise.allSettled([ lt.api.get('/api/network'), lt.api.get('/api/status'), ]); if (netResult.status === 'fulfilled') { const net = netResult.value; updateHostGrid(net.hosts || {}); updateUnifiTable(net.unifi || []); updateTopology(net.hosts || {}); } else { console.warn('Network API failed:', netResult.reason); } if (statusResult.status === 'fulfilled') { const status = statusResult.value; updateEventsTable(status.events || [], status.total_active); updateStatusBar(status.summary || {}, status.last_check || '', status.daemon_ok); } else { console.warn('Status API failed:', statusResult.reason); } } finally { if (refreshBtn) refreshBtn.classList.remove('is-loading'); } } 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'; // 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 = 'stale-banner'; document.querySelector('.lt-main').prepend(staleBanner); } const mins = Math.floor(checkAge / 60); staleBanner.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) { 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.className = node.className.replace(/topo-status-(up|down|degraded|unknown)/g, ''); node.classList.add(`topo-v2-status-${host.status}`); node.classList.add(`topo-status-${host.status}`); const badge = node.querySelector('.topo-badge'); if (badge) { badge.className = `topo-badge topo-badge-${host.status}`; badge.textContent = host.status; } }); } 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 > 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 ? `#${e.ticket_id}` : '–'; return ` ${e.severity} ${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')); const manualPill = document.querySelector('#suppress-modal .pill-manual'); if (manualPill) manualPill.classList.add('active'); 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'); if (nameGrp) nameGrp.style.display = (type === 'all') ? 'none' : ''; if (detailGrp) detailGrp.style.display = (type === 'interface') ? '' : 'none'; } function setDuration(mins, el) { document.getElementById('sup-expires').value = mins || ''; document.querySelectorAll('#suppress-modal .pill').forEach(p => p.classList.remove('active')); if (el) el.classList.add('active'); const hint = document.getElementById('duration-hint'); 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'); } } // ── 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 || '', ); } });