Files
gandalf/static/app.js
Jared Vititoe 0c0150f698 Complete rewrite: full-featured network monitoring dashboard
- Two-service architecture: Flask web app (gandalf.service) + background
  polling daemon (gandalf-monitor.service)
- Monitor polls Prometheus node_network_up for physical NIC states on all
  6 hypervisors (added storage-01 at 10.10.10.11:9100)
- UniFi API monitoring for switches, APs, and gateway device status
- Ping reachability for hosts without node_exporter (pbs only now)
- Smart baseline: interfaces first seen as down are never alerted on;
  only UP→DOWN regressions trigger tickets
- Cluster-wide P1 ticket when 3+ hosts have genuine simultaneous
  interface regressions (guards against false positives on startup)
- Tinker Tickets integration with 24-hour hash-based deduplication
- Alert suppression: manual toggle or timed windows (30m/1h/4h/8h)
- Authelia SSO via forward-auth headers, admin group required
- Network topology: Internet → UDM-Pro → Agg Switch (10G DAC) →
  PoE Switch (10G DAC) → Hosts
- MariaDB schema, suppression management UI, host/interface cards

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 23:03:18 -05:00

273 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// ── Toast notifications ───────────────────────────────────────────────
function showToast(msg, type = 'success') {
let container = document.querySelector('.toast-container');
if (!container) {
container = document.createElement('div');
container.className = 'toast-container';
document.body.appendChild(container);
}
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = msg;
container.appendChild(toast);
setTimeout(() => toast.remove(), 3500);
}
// ── 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 || []);
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(`<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) 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 = `Last check: ${lastCheck}`;
}
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]) => `
<div class="iface-row">
<span class="iface-dot dot-${state}"></span>
<span class="iface-name">${escHtml(iface)}</span>
<span class="iface-state state-${state}">${state}</span>
</div>
`).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-status-(up|down|degraded|unknown)/g, '');
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
? `<button class="btn-sm btn-suppress"
onclick="openSuppressModal('unifi_device','${escHtml(d.name)}','')">🔕 Suppress</button>`
: '';
return `
<tr class="${statusClass}">
<td><span class="${dotClass}"></span> ${statusText}</td>
<td><strong>${escHtml(d.name)}</strong></td>
<td>${escHtml(d.type)}</td>
<td>${escHtml(d.model)}</td>
<td>${escHtml(d.ip)}</td>
<td>${suppressBtn}</td>
</tr>`;
}).join('');
}
function updateEventsTable(events) {
const wrap = document.getElementById('events-table-wrap');
if (!wrap) return;
const active = events.filter(e => e.severity !== 'info');
if (!active.length) {
wrap.innerHTML = '<p class="empty-state">No active alerts ✔</p>';
return;
}
const rows = active.map(e => {
const supType = e.event_type === 'unifi_device_offline' ? 'unifi_device'
: e.event_type === 'interface_down' ? 'interface'
: 'host';
const ticket = e.ticket_id
? `<a href="http://t.lotusguild.org/ticket/${e.ticket_id}" target="_blank"
class="ticket-link">#${e.ticket_id}</a>`
: '';
return `
<tr class="row-${e.severity}">
<td><span class="badge badge-${e.severity}">${e.severity}</span></td>
<td>${escHtml(e.event_type.replace(/_/g,' '))}</td>
<td><strong>${escHtml(e.target_name)}</strong></td>
<td>${escHtml(e.target_detail || '')}</td>
<td class="desc-cell" title="${escHtml(e.description || '')}">${escHtml((e.description||'').substring(0,60))}${(e.description||'').length>60?'…':''}</td>
<td class="ts-cell">${escHtml(e.first_seen||'')}</td>
<td>${e.consecutive_failures}</td>
<td>${ticket}</td>
<td>
<button class="btn-sm btn-suppress"
onclick="openSuppressModal('${supType}','${escHtml(e.target_name)}','${escHtml(e.target_detail||'')}')">
🔕
</button>
</td>
</tr>`;
}).join('');
wrap.innerHTML = `
<table class="data-table" id="events-table">
<thead>
<tr>
<th>Severity</th><th>Type</th><th>Target</th><th>Detail</th>
<th>Description</th><th>First Seen</th><th>Failures</th><th>Ticket</th><th>Actions</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>`;
}
// ── 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) {
document.getElementById('sup-expires').value = mins || '';
document.querySelectorAll('#suppress-modal .pill').forEach(p => p.classList.remove('active'));
event.currentTarget.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');
}
}
// ── Close modal on backdrop click ─────────────────────────────────────
document.addEventListener('click', e => {
const modal = document.getElementById('suppress-modal');
if (modal && e.target === modal) closeSuppressModal();
});
// ── Utility ───────────────────────────────────────────────────────────
function escHtml(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}