'use strict'; // ── Auto-redirect on auth timeout ───────────────────────────────────── // Intercept all fetch() calls: if the server returns 401 (auth expired), // reload the page so Authelia redirects to the login screen. (function () { const _fetch = window.fetch; window.fetch = async function (...args) { const resp = await _fetch(...args); if (resp.status === 401) { window.location.reload(); } return resp; }; })(); // ── Toast notifications — delegates to lt.toast from base.js ───────── 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); } // ── Dashboard auto-refresh ──────────────────────────────────────────── async function refreshAll() { try { const [netResp, statusResp] = await Promise.all([ fetch('/api/network'), fetch('/api/status'), ]); if (!netResp.ok || !statusResp.ok) return; const net = await netResp.json(); const status = await statusResp.json(); updateHostGrid(net.hosts || {}); updateUnifiTable(net.unifi || []); updateEventsTable(status.events || [], status.total_active); updateStatusBar(status.summary || {}, status.last_check || ''); updateTopology(net.hosts || {}); } catch (e) { console.warn('Refresh failed:', e); } } function updateStatusBar(summary, lastCheck) { const bar = document.querySelector('.status-chips'); if (!bar) return; const chips = []; if (summary.critical) chips.push(`● ${summary.critical} CRITICAL`); if (summary.warning) chips.push(`● ${summary.warning} WARNING`); if (!summary.critical && !summary.warning) chips.push('✔ ALL SYSTEMS NOMINAL'); bar.innerHTML = chips.join(''); const lc = document.getElementById('last-check'); if (lc && lastCheck) lc.textContent = lastCheck; // Update browser tab title with alert count 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) { // last_check format: "2026-03-14 14:14:21 UTC" const checkAge = (Date.now() - new Date(lastCheck.replace(' UTC', 'Z').replace(' ', 'T'))) / 1000; if (checkAge > 900) { // 15 minutes if (!staleBanner) { staleBanner = document.createElement('div'); staleBanner.id = 'stale-banner'; staleBanner.className = 'stale-banner'; document.querySelector('.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; // Update card border class card.className = card.className.replace(/host-card-(up|down|degraded|unknown)/g, ''); card.classList.add(`host-card-${host.status}`); // Update status dot in header const dot = card.querySelector('.host-status-dot'); if (dot) dot.className = `host-status-dot dot-${host.status}`; // Update interface rows 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]) => `
${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} ${escHtml(d.name)} ${escHtml(d.type)} ${escHtml(d.model)} ${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} ${escHtml(e.event_type.replace(/_/g,' '))} ${escHtml(e.target_name)} ${escHtml(e.target_detail || '–')} ${escHtml((e.description||'').substring(0,60))}${(e.description||'').length>60?'…':''} ${fmtRelTime(e.first_seen)} ${fmtRelTime(e.last_seen)} ${e.consecutive_failures} ${ticket} `; }).join(''); wrap.innerHTML = ` ${countNotice}
${rows}
SevTypeTargetDetail DescriptionFirst SeenLast SeenFailuresTicketActions
`; } // ── Suppression modal (dashboard) ──────────────────────────────────── 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(); modal.style.display = 'flex'; 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() { const modal = document.getElementById('suppress-modal'); if (modal) modal.style.display = 'none'; } 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 { const resp = await fetch('/api/suppressions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target_type: type, target_name: name, target_detail: detail, reason: reason, expires_minutes: expires ? parseInt(expires) : null, }), }); const data = await resp.json(); if (data.success) { closeSuppressModal(); showToast('Suppression applied ✔', 'success'); setTimeout(refreshAll, 500); } else { showToast(data.error || 'Failed to apply suppression', 'error'); } } catch (err) { showToast('Network error', 'error'); } } // ── Global click handler: modal backdrop + suppress button delegation ─ document.addEventListener('click', e => { // Close modal when clicking backdrop const modal = document.getElementById('suppress-modal'); if (modal && e.target === modal) { closeSuppressModal(); return; } // Suppress button via data attributes (avoids inline onclick XSS) const btn = e.target.closest('.btn-suppress[data-sup-type]'); if (btn) { openSuppressModal( btn.dataset.supType || '', btn.dataset.supName || '', btn.dataset.supDetail || '', ); } }); // ── Relative time ───────────────────────────────────────────────────── function fmtRelTime(tsStr) { if (!tsStr) return '–'; const d = new Date(tsStr.replace(' UTC', 'Z').replace(' ', 'T')); if (isNaN(d)) return tsStr; const secs = Math.floor((Date.now() - d) / 1000); if (secs < 60) return `${secs}s ago`; if (secs < 3600) return `${Math.floor(secs/60)}m ago`; if (secs < 86400) return `${Math.floor(secs/3600)}h ago`; return `${Math.floor(secs/86400)}d ago`; } // ── Utility ─────────────────────────────────────────────────────────── function escHtml(str) { if (str === null || str === undefined) return ''; return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); }