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