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>
This commit is contained in:
2026-03-01 23:03:18 -05:00
parent 4ed5ecacbb
commit 0c0150f698
13 changed files with 2787 additions and 512 deletions

36
templates/base.html Normal file
View File

@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}GANDALF{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<nav class="navbar">
<div class="nav-brand">
<span class="nav-logo"></span>
<span class="nav-title">GANDALF</span>
<span class="nav-sub">Network Monitor</span>
</div>
<div class="nav-links">
<a href="{{ url_for('index') }}" class="nav-link {% if request.endpoint == 'index' %}active{% endif %}">
Dashboard
</a>
<a href="{{ url_for('suppressions_page') }}" class="nav-link {% if request.endpoint == 'suppressions_page' %}active{% endif %}">
Suppressions
</a>
</div>
<div class="nav-user">
<span class="nav-user-name">{{ user.name or user.username }}</span>
</div>
</nav>
<main class="main">
{% block content %}{% endblock %}
</main>
<script src="{{ url_for('static', filename='app.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -1,69 +1,289 @@
<!DOCTYPE html>
<html>
<head>
<title>GANDALF - Network Monitor</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
</head>
<body>
<div class="container">
<div class="header">
<h1>GANDALF (Global Advanced Network Detection And Link Facilitator)</h1>
<p>Ubiquiti Network Management Dashboard</p>
</div>
{% extends "base.html" %}
{% block title %}Dashboard GANDALF{% endblock %}
<div class="metrics-container">
<div class="metric-card">
<h2>Network Overview</h2>
<div id="network-health">
{%- for device in devices %}
<div class="device-status" data-device-name="{{ device.name }}">
<span class="status-indicator"></span>
<div class="device-info">
<span class="device-name">{{ device.name }}</span>
<span class="device-details">{{ device.ip }}</span>
<span class="device-type">{{ device.type }} ({{ device.connection_type }})</span>
{% if device.critical %}
<span class="critical-badge">Critical</span>
{% endif %}
</div>
</div>
{%- endfor %}
</div>
</div>
<div class="metric-card expandable">
<h2>Interface Status</h2>
<div id="interface-details">
{%- for device in devices %}
<div class="interface-group" data-device-name="{{ device.name }}">
<div class="interface-header" onclick="toggleInterfaces(this)">
<h3>{{ device.name }}</h3>
<span class="expand-icon"></span>
</div>
<div class="interface-list collapsed"></div>
</div>
{%- endfor %}
</div>
</div>
<div class="metric-card">
<h2>System Health</h2>
<div id="system-metrics">
{%- for device in devices %}
<div class="health-metrics" data-device-name="{{ device.name }}">
<h3>{{ device.name }}</h3>
<div class="metrics-list">
<div class="metric-item cpu"></div>
<div class="metric-item memory"></div>
<div class="metric-item temperature"></div>
</div>
</div>
{%- endfor %}
</div>
</div>
</div>
{% block content %}
<!-- ── Status bar ─────────────────────────────────────────────────────── -->
<div class="status-bar">
<div class="status-chips">
{% if summary.critical %}
<span class="chip chip-critical">⬤ {{ summary.critical }} Critical</span>
{% endif %}
{% if summary.warning %}
<span class="chip chip-warning">{{ summary.warning }} Warning</span>
{% endif %}
{% if not summary.critical and not summary.warning %}
<span class="chip chip-ok">✔ All systems nominal</span>
{% endif %}
</div>
<div class="status-meta">
<span class="last-check" id="last-check">Last check: {{ last_check }}</span>
<button class="btn-refresh" onclick="refreshAll()">↻ Refresh</button>
</div>
</div>
<!-- ── Network topology + host grid ──────────────────────────────────── -->
<section class="section">
<h2 class="section-title">Network Hosts</h2>
<!-- Simple topology diagram -->
<div class="topology" id="topology-diagram">
<div class="topo-row topo-row-internet">
<div class="topo-node topo-internet">🌐 Internet</div>
</div>
<script src="{{ url_for('static', filename='app.js') }}"></script>
</body>
</html>
<div class="topo-connectors single">
<div class="topo-line"></div>
</div>
<div class="topo-row">
<div class="topo-node topo-unifi" id="topo-gateway">
<span class="topo-icon"></span>
<span class="topo-label">UDM-Pro</span>
<span class="topo-status-dot" data-topo-target="gateway"></span>
</div>
</div>
<div class="topo-connectors single">
<div class="topo-line topo-line-labeled" data-link-label="10G DAC"></div>
</div>
<div class="topo-row">
<div class="topo-node topo-switch" id="topo-switch-agg">
<span class="topo-icon"></span>
<span class="topo-label">Agg Switch</span>
<span class="topo-status-dot" data-topo-target="switch-agg"></span>
</div>
</div>
<div class="topo-connectors single">
<div class="topo-line topo-line-labeled" data-link-label="10G DAC"></div>
</div>
<div class="topo-row">
<div class="topo-node topo-switch" id="topo-switch-poe">
<span class="topo-icon"></span>
<span class="topo-label">PoE Switch</span>
<span class="topo-status-dot" data-topo-target="switch-poe"></span>
</div>
</div>
<div class="topo-connectors wide">
{% for name in snapshot.hosts %}
<div class="topo-line"></div>
{% endfor %}
</div>
<div class="topo-row topo-hosts-row">
{% for name, host in snapshot.hosts.items() %}
<div class="topo-node topo-host topo-status-{{ host.status }}" data-host="{{ name }}">
<span class="topo-icon"></span>
<span class="topo-label">{{ name }}</span>
<span class="topo-badge topo-badge-{{ host.status }}">{{ host.status }}</span>
</div>
{% endfor %}
</div>
</div>
<!-- Host cards -->
<div class="host-grid" id="host-grid">
{% for name, host in snapshot.hosts.items() %}
{% set suppressed = suppressions | selectattr('target_name', 'equalto', name) | list %}
<div class="host-card host-card-{{ host.status }}" data-host="{{ name }}">
<div class="host-card-header">
<div class="host-name-row">
<span class="host-status-dot dot-{{ host.status }}"></span>
<span class="host-name">{{ name }}</span>
{% if suppressed %}
<span class="badge badge-suppressed" title="Suppressed">🔕</span>
{% endif %}
</div>
<div class="host-meta">
<span class="host-ip">{{ host.ip }}</span>
<span class="host-source source-{{ host.source }}">{{ host.source }}</span>
</div>
</div>
{% if host.interfaces %}
<div class="iface-list">
{% for iface, state in host.interfaces.items() | sort %}
<div class="iface-row">
<span class="iface-dot dot-{{ state }}"></span>
<span class="iface-name">{{ iface }}</span>
<span class="iface-state state-{{ state }}">{{ state }}</span>
</div>
{% endfor %}
</div>
{% else %}
<div class="host-ping-note">Monitored via ping only</div>
{% endif %}
<div class="host-actions">
<button class="btn-sm btn-suppress"
onclick="openSuppressModal('host', '{{ name }}', '')"
title="Suppress alerts for this host">
🔕 Suppress Host
</button>
</div>
</div>
{% else %}
<p class="empty-state">No host data yet monitor is initializing.</p>
{% endfor %}
</div>
</section>
<!-- ── UniFi devices ──────────────────────────────────────────────────── -->
{% if snapshot.unifi %}
<section class="section">
<h2 class="section-title">UniFi Devices</h2>
<div class="table-wrap">
<table class="data-table" id="unifi-table">
<thead>
<tr>
<th>Status</th>
<th>Name</th>
<th>Type</th>
<th>Model</th>
<th>IP</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for d in snapshot.unifi %}
<tr class="{% if not d.connected %}row-critical{% endif %}">
<td>
<span class="dot-{{ 'up' if d.connected else 'down' }}"></span>
{{ 'Online' if d.connected else 'Offline' }}
</td>
<td><strong>{{ d.name }}</strong></td>
<td>{{ d.type }}</td>
<td>{{ d.model }}</td>
<td>{{ d.ip }}</td>
<td>
{% if not d.connected %}
<button class="btn-sm btn-suppress"
onclick="openSuppressModal('unifi_device', '{{ d.name }}', '')">
🔕 Suppress
</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endif %}
<!-- ── Active alerts ─────────────────────────────────────────────────── -->
<section class="section">
<h2 class="section-title">
Active Alerts
{% if summary.critical or summary.warning %}
<span class="section-badge badge-critical">{{ (summary.critical or 0) + (summary.warning or 0) }} open</span>
{% endif %}
</h2>
<div class="table-wrap" id="events-table-wrap">
{% if events %}
<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>
{% for e in events %}
{% if e.severity != 'info' %}
<tr class="row-{{ e.severity }}">
<td><span class="badge badge-{{ e.severity }}">{{ e.severity }}</span></td>
<td>{{ e.event_type | replace('_', ' ') }}</td>
<td><strong>{{ e.target_name }}</strong></td>
<td>{{ e.target_detail or '' }}</td>
<td class="desc-cell" title="{{ e.description }}">{{ e.description | truncate(60) }}</td>
<td class="ts-cell">{{ e.first_seen }}</td>
<td>{{ e.consecutive_failures }}</td>
<td>
{% if e.ticket_id %}
<a href="http://t.lotusguild.org/ticket/{{ e.ticket_id }}" target="_blank"
class="ticket-link">#{{ e.ticket_id }}</a>
{% else %}{% endif %}
</td>
<td>
<button class="btn-sm btn-suppress"
onclick="openSuppressModal('{{ 'unifi_device' if e.event_type == 'unifi_device_offline' else 'interface' if e.event_type == 'interface_down' else 'host' }}', '{{ e.target_name }}', '{{ e.target_detail or '' }}')"
title="Suppress this alert">
🔕
</button>
</td>
</tr>
{% endif %}
{% else %}
<tr class="empty-row">
<td colspan="9" class="empty-state">No active alerts ✔</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty-state">No active alerts ✔</p>
{% endif %}
</div>
</section>
<!-- ── Quick-suppress modal ───────────────────────────────────────────── -->
<div id="suppress-modal" class="modal-overlay" style="display:none">
<div class="modal">
<div class="modal-header">
<h3>Suppress Alert</h3>
<button class="modal-close" onclick="closeSuppressModal()"></button>
</div>
<form id="suppress-form" onsubmit="submitSuppress(event)">
<div class="form-group">
<label>Target Type</label>
<select id="sup-type" name="target_type" onchange="updateSuppressForm()">
<option value="host">Host (all interfaces)</option>
<option value="interface">Specific Interface</option>
<option value="unifi_device">UniFi Device</option>
<option value="all">Everything (global maintenance)</option>
</select>
</div>
<div class="form-group" id="sup-name-group">
<label>Target Name</label>
<input type="text" id="sup-name" name="target_name" placeholder="e.g. large1">
</div>
<div class="form-group" id="sup-detail-group">
<label>Interface Name <span class="form-hint">(for interface type)</span></label>
<input type="text" id="sup-detail" name="target_detail" placeholder="e.g. enp35s0">
</div>
<div class="form-group">
<label>Reason <span class="required">*</span></label>
<input type="text" id="sup-reason" name="reason" placeholder="e.g. Planned switch reboot" required>
</div>
<div class="form-group">
<label>Duration</label>
<div class="duration-pills">
<button type="button" class="pill" onclick="setDuration(30)">30 min</button>
<button type="button" class="pill" onclick="setDuration(60)">1 hr</button>
<button type="button" class="pill" onclick="setDuration(240)">4 hr</button>
<button type="button" class="pill" onclick="setDuration(480)">8 hr</button>
<button type="button" class="pill pill-manual active" onclick="setDuration(null)">Manual</button>
</div>
<input type="hidden" id="sup-expires" name="expires_minutes" value="">
<div class="form-hint" id="duration-hint">Suppression will persist until manually removed.</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeSuppressModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Apply Suppression</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Auto-refresh every 30 seconds
setInterval(refreshAll, 30000);
</script>
{% endblock %}

252
templates/suppressions.html Normal file
View File

@@ -0,0 +1,252 @@
{% extends "base.html" %}
{% block title %}Suppressions GANDALF{% endblock %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Alert Suppressions</h1>
<p class="page-sub">Manage maintenance windows and alert suppression rules.</p>
</div>
<!-- ── Create suppression ─────────────────────────────────────────────── -->
<section class="section">
<h2 class="section-title">Create Suppression</h2>
<div class="card form-card">
<form id="create-suppression-form" onsubmit="createSuppression(event)">
<div class="form-row">
<div class="form-group">
<label for="s-type">Target Type <span class="required">*</span></label>
<select id="s-type" name="target_type" onchange="onTypeChange()">
<option value="host">Host (all interfaces)</option>
<option value="interface">Specific Interface</option>
<option value="unifi_device">UniFi Device</option>
<option value="all">Global (suppress everything)</option>
</select>
</div>
<div class="form-group" id="name-group">
<label for="s-name">Target Name <span class="required">*</span></label>
<input type="text" id="s-name" name="target_name"
placeholder="hostname or device name" autocomplete="off">
</div>
<div class="form-group" id="detail-group" style="display:none">
<label for="s-detail">Interface Name</label>
<input type="text" id="s-detail" name="target_detail"
placeholder="e.g. enp35s0 or bond0" autocomplete="off">
</div>
</div>
<div class="form-row">
<div class="form-group form-group-wide">
<label for="s-reason">Reason <span class="required">*</span></label>
<input type="text" id="s-reason" name="reason"
placeholder="e.g. Planned switch maintenance, replacing SFP on large1/enp43s0"
required>
</div>
</div>
<div class="form-row form-row-align">
<div class="form-group">
<label>Duration</label>
<div class="duration-pills">
<button type="button" class="pill" onclick="setDur(30)">30 min</button>
<button type="button" class="pill" onclick="setDur(60)">1 hr</button>
<button type="button" class="pill" onclick="setDur(240)">4 hr</button>
<button type="button" class="pill" onclick="setDur(480)">8 hr</button>
<button type="button" class="pill pill-manual active" onclick="setDur(null)">Manual ∞</button>
</div>
<input type="hidden" id="s-expires" name="expires_minutes" value="">
<div class="form-hint" id="s-dur-hint">
This suppression will persist until manually removed.
</div>
</div>
<div class="form-group form-group-submit">
<button type="submit" class="btn btn-primary btn-lg">
🔕 Apply Suppression
</button>
</div>
</div>
</form>
</div>
</section>
<!-- ── Active suppressions ────────────────────────────────────────────── -->
<section class="section">
<h2 class="section-title">
Active Suppressions
<span class="section-badge">{{ active | length }}</span>
</h2>
{% if active %}
<div class="table-wrap">
<table class="data-table" id="active-sup-table">
<thead>
<tr>
<th>Type</th>
<th>Target</th>
<th>Detail</th>
<th>Reason</th>
<th>By</th>
<th>Created</th>
<th>Expires</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for s in active %}
<tr id="sup-row-{{ s.id }}">
<td><span class="badge badge-info">{{ s.target_type }}</span></td>
<td>{{ s.target_name or '<em>all</em>' | safe }}</td>
<td>{{ s.target_detail or '' }}</td>
<td>{{ s.reason }}</td>
<td>{{ s.suppressed_by }}</td>
<td class="ts-cell">{{ s.created_at }}</td>
<td class="ts-cell">
{% if s.expires_at %}{{ s.expires_at }}{% else %}<em>manual</em>{% endif %}
</td>
<td>
<button class="btn-sm btn-danger"
onclick="removeSuppression({{ s.id }})">
Remove
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty-state">No active suppressions.</p>
{% endif %}
</section>
<!-- ── Suppression history ────────────────────────────────────────────── -->
<section class="section">
<h2 class="section-title">History <span class="section-badge">{{ history | length }}</span></h2>
{% if history %}
<div class="table-wrap">
<table class="data-table data-table-sm">
<thead>
<tr>
<th>Type</th>
<th>Target</th>
<th>Detail</th>
<th>Reason</th>
<th>By</th>
<th>Created</th>
<th>Expires</th>
<th>Active</th>
</tr>
</thead>
<tbody>
{% for s in history %}
<tr class="{% if not s.active %}row-resolved{% endif %}">
<td>{{ s.target_type }}</td>
<td>{{ s.target_name or 'all' }}</td>
<td>{{ s.target_detail or '' }}</td>
<td>{{ s.reason }}</td>
<td>{{ s.suppressed_by }}</td>
<td class="ts-cell">{{ s.created_at }}</td>
<td class="ts-cell">
{% if s.expires_at %}{{ s.expires_at }}{% else %}<em>manual</em>{% endif %}
</td>
<td>
{% if s.active %}
<span class="badge badge-ok">Yes</span>
{% else %}
<span class="badge badge-neutral">No</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty-state">No suppression history yet.</p>
{% endif %}
</section>
<!-- ── Available targets reference ───────────────────────────────────── -->
<section class="section">
<h2 class="section-title">Available Targets</h2>
<div class="targets-grid">
{% for name, host in snapshot.hosts.items() %}
<div class="target-card">
<div class="target-name">{{ name }}</div>
<div class="target-type">Proxmox Host</div>
{% if host.interfaces %}
<div class="target-ifaces">
{% for iface in host.interfaces.keys() | sort %}
<code class="iface-chip">{{ iface }}</code>
{% endfor %}
</div>
{% else %}
<div class="target-type">ping-only</div>
{% endif %}
</div>
{% endfor %}
</div>
</section>
{% endblock %}
{% block scripts %}
<script>
function onTypeChange() {
const t = document.getElementById('s-type').value;
const nameGrp = document.getElementById('name-group');
const detailGrp = document.getElementById('detail-group');
nameGrp.style.display = (t === 'all') ? 'none' : '';
detailGrp.style.display = (t === 'interface') ? '' : 'none';
document.getElementById('s-name').required = (t !== 'all');
}
function setDur(mins) {
document.getElementById('s-expires').value = mins || '';
document.querySelectorAll('.duration-pills .pill').forEach(p => p.classList.remove('active'));
event.target.classList.add('active');
const hint = document.getElementById('s-dur-hint');
if (mins) {
const h = Math.floor(mins / 60), m = mins % 60;
hint.textContent = `Suppression expires in ${h ? h+'h ' : ''}${m ? m+'m' : ''}.`;
} else {
hint.textContent = 'This suppression will persist until manually removed.';
}
}
async function createSuppression(e) {
e.preventDefault();
const form = e.target;
const payload = {
target_type: form.target_type.value,
target_name: form.target_name ? form.target_name.value : '',
target_detail: document.getElementById('s-detail').value,
reason: form.reason.value,
expires_minutes: form.expires_minutes.value ? parseInt(form.expires_minutes.value) : null,
};
const resp = await fetch('/api/suppressions', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
});
const data = await resp.json();
if (data.success) {
showToast('Suppression applied', 'success');
setTimeout(() => location.reload(), 800);
} else {
showToast(data.error || 'Error applying suppression', 'error');
}
}
async function removeSuppression(id) {
if (!confirm('Remove this suppression?')) return;
const resp = await fetch(`/api/suppressions/${id}`, { method: 'DELETE' });
const data = await resp.json();
if (data.success) {
document.getElementById(`sup-row-${id}`)?.remove();
showToast('Suppression removed', 'success');
}
}
</script>
{% endblock %}