Files
gandalf/static/app.js
T

361 lines
15 KiB
JavaScript
Raw Normal View History

'use strict';
2026-03-17 20:43:08 -04:00
// ── Auto-redirect on auth timeout ─────────────────────────────────────
2026-04-18 23:46:44 -04:00
// Wraps fetch so a 401 (Authelia session expired) forces a full reload.
// lt.api uses fetch internally, so this covers all API calls too.
2026-03-17 20:43:08 -04:00
(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');
}
2026-03-17 20:43:08 -04:00
return resp;
};
})();
2026-04-18 23:46:44 -04:00
// ── 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);
2025-02-07 21:03:31 -05:00
}
2026-04-18 23:46:44 -04:00
// ── 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([
2026-04-18 23:46:44 -04:00
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 {
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';
}
2025-02-08 00:32:25 -05:00
}
function updateStatusBar(summary, lastCheck, daemonOk) {
const bar = document.querySelector('.status-chips');
if (!bar) return;
const chips = [];
if (daemonOk === false) chips.push('<span class="chip chip-critical">⚠ MONITOR OFFLINE</span>');
if (summary.critical) chips.push(`<span class="chip chip-critical">● ${summary.critical} CRITICAL</span>`);
if (summary.warning) chips.push(`<span class="chip chip-warning">● ${summary.warning} WARNING</span>`);
if (!summary.critical && !summary.warning && daemonOk !== false) chips.push('<span class="chip chip-ok">✔ ALL SYSTEMS NOMINAL</span>');
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;
2026-04-18 23:46:44 -04:00
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');
if (scCrit) scCrit.textContent = critCount;
if (scWarn) scWarn.textContent = warnCount;
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) {
2026-04-18 23:46:44 -04:00
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 = '<span class="lt-alert-icon">⚠</span><div class="lt-alert-body"><div class="lt-alert-msg"></div></div>';
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';
}
}
2025-02-07 23:54:28 -05:00
}
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}`;
2025-02-07 23:54:28 -05:00
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]) => `
<div class="iface-row">
<span class="iface-dot dot-${state}"></span>
2026-04-18 23:46:44 -04:00
<span class="iface-name">${lt.escHtml(iface)}</span>
<span class="iface-state state-${state}">${state}</span>
</div>
`).join('');
2025-02-07 23:54:28 -05:00
}
}
2025-02-07 23:54:28 -05:00
}
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');
});
});
2025-02-07 21:28:54 -05:00
}
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
? `<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
data-sup-type="unifi_device"
2026-04-18 23:46:44 -04:00
data-sup-name="${lt.escHtml(d.name)}"
data-sup-detail=""
aria-label="Suppress alerts for ${lt.escHtml(d.name)}">🔕 Suppress</button>`
: '';
return `
<tr class="${statusClass}">
<td><span class="${dotClass}"></span> ${statusText}</td>
2026-04-18 23:46:44 -04:00
<td><strong>${lt.escHtml(d.name)}</strong></td>
<td>${lt.escHtml(d.type)}</td>
<td>${lt.escHtml(d.model)}</td>
<td>${lt.escHtml(d.ip)}</td>
<td>${suppressBtn}</td>
</tr>`;
}).join('');
2025-01-04 01:42:16 -05:00
}
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 = '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-icon">✔</div><div class="lt-empty-state-title">No active alerts</div></div>';
return;
}
const truncated = totalActive != null && totalActive > active.length;
const countNotice = truncated
? `<div class="pagination-notice">Showing ${active.length} of ${totalActive} active alerts &mdash; <a href="/api/events?limit=1000">view all via API</a></div>`
: '';
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
2026-04-18 23:46:44 -04:00
? `<a href="${lt.escHtml(ticketBase)}${lt.escHtml(String(e.ticket_id))}" target="_blank"
class="ticket-link">#${e.ticket_id}</a>`
: '';
return `
<tr class="row-${e.severity}">
<td><span class="lt-badge badge-${e.severity}">${e.severity}</span></td>
2026-04-18 23:46:44 -04:00
<td>${lt.escHtml(e.event_type.replace(/_/g,' '))}</td>
<td><strong>${lt.escHtml(e.target_name)}</strong></td>
<td>${lt.escHtml(e.target_detail || '')}</td>
<td class="desc-cell" title="${lt.escHtml(e.description || '')}">${lt.escHtml((e.description||'').substring(0,60))}${(e.description||'').length>60?'…':''}</td>
<td class="ts-cell" title="${lt.escHtml(e.first_seen||'')}">${lt.time.ago(_toIso(e.first_seen))}</td>
<td class="ts-cell" title="${lt.escHtml(e.last_seen||'')}">${lt.time.ago(_toIso(e.last_seen))}</td>
<td>${e.consecutive_failures}</td>
<td>${ticket}</td>
<td>
<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
2026-04-18 23:46:44 -04:00
data-sup-type="${lt.escHtml(supType)}"
data-sup-name="${lt.escHtml(e.target_name)}"
data-sup-detail="${lt.escHtml(e.target_detail||'')}"
aria-label="Suppress alert for ${lt.escHtml(e.target_name)}">🔕</button>
</td>
</tr>`;
}).join('');
wrap.innerHTML = `
${countNotice}
<div class="lt-table-wrap">
<table class="lt-table" id="events-table">
<caption class="lt-sr-only">Active network alerts</caption>
<thead>
<tr>
<th>Sev</th><th>Type</th><th>Target</th><th>Detail</th>
<th>Description</th><th>First Seen</th><th>Last Seen</th><th>Failures</th><th>Ticket</th><th>Actions</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>`;
2025-02-07 20:55:38 -05:00
}
2026-04-18 23:46:44 -04:00
// ── Suppression modal ─────────────────────────────────────────────────
function openSuppressModal(type, name, detail) {
const modal = document.getElementById('suppress-modal');
if (!modal) return;
2026-04-18 23:46:44 -04:00
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();
2026-04-18 23:46:44 -04:00
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() {
2026-04-18 23:46:44 -04:00
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.';
}
}
2025-02-07 20:55:38 -05:00
}
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; }
2025-02-07 20:55:38 -05:00
try {
2026-04-18 23:46:44 -04:00
await lt.api.post('/api/suppressions', {
target_type: type,
target_name: name,
target_detail: detail,
reason,
expires_minutes: expires ? parseInt(expires) : null,
});
2026-04-18 23:46:44 -04:00
closeSuppressModal();
showToast('Suppression applied ✔', 'success');
setTimeout(refreshAll, 500);
} catch (err) {
2026-04-18 23:46:44 -04:00
showToast(err.message || 'Failed to apply suppression', 'error');
}
2025-02-07 21:03:31 -05:00
}
2025-02-07 20:55:38 -05:00
2026-04-18 23:46:44 -04:00
// ── Global click delegation ───────────────────────────────────────────
document.addEventListener('click', e => {
2026-04-18 23:46:44 -04:00
// 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;
}
2026-04-18 23:46:44 -04:00
// Suppress buttons
const btn = e.target.closest('.btn-suppress[data-sup-type]');
if (btn) {
openSuppressModal(
btn.dataset.supType || '',
btn.dataset.supName || '',
btn.dataset.supDetail || '',
);
}
});