Files
gandalf/templates/suppressions.html
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

253 lines
9.1 KiB
HTML
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.
{% 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 %}