2026-03-01 23:03:18 -05:00
|
|
|
|
{% extends "base.html" %}
|
|
|
|
|
|
{% block title %}Suppressions – GANDALF{% endblock %}
|
|
|
|
|
|
|
|
|
|
|
|
{% block content %}
|
|
|
|
|
|
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<div class="g-page-header">
|
|
|
|
|
|
<h1 class="g-page-title">Alert Suppressions</h1>
|
|
|
|
|
|
<p class="g-page-sub">Manage maintenance windows and per-target alert suppression rules.</p>
|
2026-03-01 23:03:18 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-02 12:43:11 -05:00
|
|
|
|
<!-- ── Create suppression ─────────────────────────────────────────── -->
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<section class="g-section">
|
|
|
|
|
|
<div class="g-section-header">
|
|
|
|
|
|
<h2 class="g-section-title">Create Suppression</h2>
|
2026-03-02 12:43:11 -05:00
|
|
|
|
</div>
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<div class="lt-card">
|
|
|
|
|
|
<div class="lt-card-body">
|
|
|
|
|
|
<form id="create-suppression-form" onsubmit="createSuppression(event)">
|
|
|
|
|
|
<div class="form-row">
|
|
|
|
|
|
<div class="lt-form-group">
|
|
|
|
|
|
<label class="lt-label" for="s-type">Target Type <span class="required">*</span></label>
|
|
|
|
|
|
<select class="lt-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="lt-form-group" id="name-group">
|
|
|
|
|
|
<label class="lt-label" for="s-name">Target Name <span class="required">*</span></label>
|
|
|
|
|
|
<input type="text" class="lt-input" id="s-name" name="target_name"
|
2026-04-19 23:35:02 -04:00
|
|
|
|
placeholder="hostname or device name" autocomplete="off"
|
|
|
|
|
|
list="target-name-list">
|
|
|
|
|
|
<datalist id="target-name-list">
|
|
|
|
|
|
{% for name in snapshot.hosts.keys() | sort %}
|
|
|
|
|
|
<option value="{{ name }}">
|
|
|
|
|
|
{% endfor %}
|
|
|
|
|
|
</datalist>
|
2026-04-18 21:01:20 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="lt-form-group" id="detail-group" style="display:none">
|
|
|
|
|
|
<label class="lt-label" for="s-detail">Interface Name</label>
|
|
|
|
|
|
<input type="text" class="lt-input" id="s-detail" name="target_detail"
|
|
|
|
|
|
placeholder="e.g. enp35s0 or bond0" autocomplete="off">
|
|
|
|
|
|
</div>
|
2026-03-01 23:03:18 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<div class="form-row">
|
|
|
|
|
|
<div class="lt-form-group form-group-wide">
|
|
|
|
|
|
<label class="lt-label" for="s-reason">Reason <span class="required">*</span></label>
|
|
|
|
|
|
<input type="text" class="lt-input" id="s-reason" name="reason"
|
|
|
|
|
|
placeholder="e.g. Planned switch maintenance, replacing SFP on large1/enp43s0"
|
|
|
|
|
|
required>
|
|
|
|
|
|
</div>
|
2026-03-01 23:03:18 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<div class="form-row form-row-align">
|
|
|
|
|
|
<div class="lt-form-group">
|
|
|
|
|
|
<label class="lt-label">Duration</label>
|
|
|
|
|
|
<div class="duration-pills">
|
2026-04-19 00:01:52 -04:00
|
|
|
|
<button type="button" class="pill" data-duration="30">30 min</button>
|
|
|
|
|
|
<button type="button" class="pill" data-duration="60">1 hr</button>
|
|
|
|
|
|
<button type="button" class="pill" data-duration="240">4 hr</button>
|
|
|
|
|
|
<button type="button" class="pill" data-duration="480">8 hr</button>
|
|
|
|
|
|
<button type="button" class="pill pill-manual active" data-duration="">Manual ∞</button>
|
2026-04-18 21:01:20 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
<input type="hidden" id="s-expires" name="expires_minutes" value="">
|
|
|
|
|
|
<div class="lt-field-hint" id="s-dur-hint">Persists until manually removed.</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="lt-form-group form-group-submit">
|
|
|
|
|
|
<button type="submit" class="lt-btn lt-btn-primary lt-btn-lg">🔕 Apply Suppression</button>
|
2026-03-01 23:03:18 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-18 21:01:20 -04:00
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
2026-03-01 23:03:18 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
2026-03-02 12:43:11 -05:00
|
|
|
|
<!-- ── Active suppressions ────────────────────────────────────────── -->
|
2026-04-19 23:35:02 -04:00
|
|
|
|
<section class="g-section" id="active-sup-section">
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<div class="g-section-header">
|
|
|
|
|
|
<h2 class="g-section-title">Active Suppressions</h2>
|
2026-04-19 23:35:02 -04:00
|
|
|
|
<span class="g-section-badge" id="active-sup-badge">{{ active | length }}</span>
|
2026-03-02 12:43:11 -05:00
|
|
|
|
</div>
|
2026-04-19 23:35:02 -04:00
|
|
|
|
<div id="active-sup-wrap">
|
2026-03-01 23:03:18 -05:00
|
|
|
|
{% if active %}
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<div class="lt-table-wrap">
|
|
|
|
|
|
<table class="lt-table" id="active-sup-table">
|
|
|
|
|
|
<caption class="lt-sr-only">Active suppression rules</caption>
|
2026-03-01 23:03:18 -05:00
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
2026-03-02 12:43:11 -05:00
|
|
|
|
<th>Type</th><th>Target</th><th>Detail</th><th>Reason</th>
|
|
|
|
|
|
<th>By</th><th>Created</th><th>Expires</th><th>Actions</th>
|
2026-03-01 23:03:18 -05:00
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{% for s in active %}
|
|
|
|
|
|
<tr id="sup-row-{{ s.id }}">
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<td><span class="lt-badge badge-info">{{ s.target_type }}</span></td>
|
2026-03-03 15:39:48 -05:00
|
|
|
|
<td>{{ s.target_name or 'all' }}</td>
|
2026-03-01 23:03:18 -05:00
|
|
|
|
<td>{{ s.target_detail or '–' }}</td>
|
|
|
|
|
|
<td>{{ s.reason }}</td>
|
|
|
|
|
|
<td>{{ s.suppressed_by }}</td>
|
|
|
|
|
|
<td class="ts-cell">{{ s.created_at }}</td>
|
2026-03-02 12:43:11 -05:00
|
|
|
|
<td class="ts-cell">{% if s.expires_at %}{{ s.expires_at }}{% else %}<em>manual</em>{% endif %}</td>
|
2026-03-01 23:03:18 -05:00
|
|
|
|
<td>
|
2026-04-19 00:01:52 -04:00
|
|
|
|
<button class="lt-btn lt-btn-danger lt-btn-sm" data-action="remove-sup" data-sup-id="{{ s.id }}">Remove</button>
|
2026-03-01 23:03:18 -05:00
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
{% endfor %}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{% else %}
|
2026-04-19 23:35:02 -04:00
|
|
|
|
<p class="empty-state" id="no-active-msg">No active suppressions.</p>
|
2026-03-01 23:03:18 -05:00
|
|
|
|
{% endif %}
|
2026-04-19 23:35:02 -04:00
|
|
|
|
</div>
|
2026-03-01 23:03:18 -05:00
|
|
|
|
</section>
|
|
|
|
|
|
|
2026-03-02 12:43:11 -05:00
|
|
|
|
<!-- ── Suppression history ────────────────────────────────────────── -->
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<section class="g-section">
|
|
|
|
|
|
<div class="g-section-header">
|
|
|
|
|
|
<h2 class="g-section-title">History</h2>
|
|
|
|
|
|
<span class="g-section-badge">{{ history | length }}</span>
|
2026-03-02 12:43:11 -05:00
|
|
|
|
</div>
|
2026-03-01 23:03:18 -05:00
|
|
|
|
{% if history %}
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<div class="lt-table-wrap">
|
|
|
|
|
|
<table class="lt-table lt-table-sm">
|
|
|
|
|
|
<caption class="lt-sr-only">Suppression history</caption>
|
2026-03-01 23:03:18 -05:00
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
2026-03-02 12:43:11 -05:00
|
|
|
|
<th>Type</th><th>Target</th><th>Detail</th><th>Reason</th>
|
|
|
|
|
|
<th>By</th><th>Created</th><th>Expires</th><th>Active</th>
|
2026-03-01 23:03:18 -05:00
|
|
|
|
</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>
|
2026-03-02 12:43:11 -05:00
|
|
|
|
<td class="ts-cell">{% if s.expires_at %}{{ s.expires_at }}{% else %}<em>manual</em>{% endif %}</td>
|
2026-03-01 23:03:18 -05:00
|
|
|
|
<td>
|
|
|
|
|
|
{% if s.active %}
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<span class="lt-badge badge-ok">Yes</span>
|
2026-03-01 23:03:18 -05:00
|
|
|
|
{% else %}
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<span class="lt-badge badge-neutral">No</span>
|
2026-03-01 23:03:18 -05:00
|
|
|
|
{% endif %}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
{% endfor %}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{% else %}
|
|
|
|
|
|
<p class="empty-state">No suppression history yet.</p>
|
|
|
|
|
|
{% endif %}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
2026-03-02 12:43:11 -05:00
|
|
|
|
<!-- ── Available targets reference ───────────────────────────────── -->
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<section class="g-section">
|
|
|
|
|
|
<div class="g-section-header">
|
|
|
|
|
|
<h2 class="g-section-title">Available Targets</h2>
|
2026-03-02 12:43:11 -05:00
|
|
|
|
</div>
|
2026-03-01 23:03:18 -05:00
|
|
|
|
<div class="targets-grid">
|
|
|
|
|
|
{% for name, host in snapshot.hosts.items() %}
|
|
|
|
|
|
<div class="target-card">
|
|
|
|
|
|
<div class="target-name">{{ name }}</div>
|
2026-03-02 12:43:11 -05:00
|
|
|
|
<div class="target-type">{{ 'Proxmox Host (prometheus)' if host.source == 'prometheus' else 'Ping-only host' }}</div>
|
2026-03-01 23:03:18 -05:00
|
|
|
|
{% if host.interfaces %}
|
|
|
|
|
|
<div class="target-ifaces">
|
|
|
|
|
|
{% for iface in host.interfaces.keys() | sort %}
|
|
|
|
|
|
<code class="iface-chip">{{ iface }}</code>
|
|
|
|
|
|
{% endfor %}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{% endif %}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{% endfor %}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
{% endblock %}
|
|
|
|
|
|
|
|
|
|
|
|
{% block scripts %}
|
|
|
|
|
|
<script>
|
|
|
|
|
|
function onTypeChange() {
|
|
|
|
|
|
const t = document.getElementById('s-type').value;
|
2026-03-02 12:43:11 -05:00
|
|
|
|
document.getElementById('name-group').style.display = (t==='all') ? 'none' : '';
|
|
|
|
|
|
document.getElementById('detail-group').style.display = (t==='interface') ? '' : 'none';
|
|
|
|
|
|
document.getElementById('s-name').required = (t!=='all');
|
2026-03-01 23:03:18 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 14:36:55 -04:00
|
|
|
|
function setDur(mins, el) {
|
2026-03-01 23:03:18 -05:00
|
|
|
|
document.getElementById('s-expires').value = mins || '';
|
|
|
|
|
|
document.querySelectorAll('.duration-pills .pill').forEach(p => p.classList.remove('active'));
|
2026-03-13 14:36:55 -04:00
|
|
|
|
if (el) el.classList.add('active');
|
2026-03-01 23:03:18 -05:00
|
|
|
|
const hint = document.getElementById('s-dur-hint');
|
|
|
|
|
|
if (mins) {
|
2026-03-02 12:43:11 -05:00
|
|
|
|
const h = Math.floor(mins/60), m = mins%60;
|
2026-04-19 23:35:02 -04:00
|
|
|
|
hint.textContent = `Expires in ${h?h+'h ':''}${m?m+'m':''}`.trim()+'.';
|
2026-03-01 23:03:18 -05:00
|
|
|
|
} else {
|
2026-03-02 12:43:11 -05:00
|
|
|
|
hint.textContent = 'Persists until manually removed.';
|
2026-03-01 23:03:18 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 23:35:02 -04:00
|
|
|
|
function renderActiveRows(rows) {
|
|
|
|
|
|
const wrap = document.getElementById('active-sup-wrap');
|
|
|
|
|
|
const badge = document.getElementById('active-sup-badge');
|
|
|
|
|
|
if (!rows || !rows.length) {
|
|
|
|
|
|
wrap.innerHTML = '<p class="empty-state" id="no-active-msg">No active suppressions.</p>';
|
|
|
|
|
|
if (badge) badge.textContent = '0';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (badge) badge.textContent = rows.length;
|
|
|
|
|
|
const tbody = rows.map(s => `
|
|
|
|
|
|
<tr id="sup-row-${s.id}">
|
|
|
|
|
|
<td><span class="lt-badge badge-info">${lt.escHtml(s.target_type)}</span></td>
|
|
|
|
|
|
<td>${lt.escHtml(s.target_name || 'all')}</td>
|
|
|
|
|
|
<td>${lt.escHtml(s.target_detail || '–')}</td>
|
|
|
|
|
|
<td>${lt.escHtml(s.reason)}</td>
|
|
|
|
|
|
<td>${lt.escHtml(s.suppressed_by)}</td>
|
|
|
|
|
|
<td class="ts-cell">${lt.escHtml(s.created_at || '')}</td>
|
|
|
|
|
|
<td class="ts-cell">${s.expires_at ? lt.escHtml(s.expires_at) : '<em>manual</em>'}</td>
|
|
|
|
|
|
<td><button class="lt-btn lt-btn-danger lt-btn-sm" data-action="remove-sup" data-sup-id="${s.id}">Remove</button></td>
|
|
|
|
|
|
</tr>`).join('');
|
|
|
|
|
|
wrap.innerHTML = `
|
|
|
|
|
|
<div class="lt-table-wrap">
|
|
|
|
|
|
<table class="lt-table" id="active-sup-table">
|
|
|
|
|
|
<caption class="lt-sr-only">Active suppression rules</caption>
|
|
|
|
|
|
<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>${tbody}</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function refreshActive() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const rows = await lt.api.get('/api/suppressions');
|
|
|
|
|
|
renderActiveRows(rows);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.warn('Failed to refresh suppressions:', err);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 23:03:18 -05:00
|
|
|
|
async function createSuppression(e) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
const form = e.target;
|
2026-04-19 23:35:02 -04:00
|
|
|
|
const btn = form.querySelector('[type="submit"]');
|
|
|
|
|
|
btn.classList.add('is-loading');
|
2026-03-01 23:03:18 -05:00
|
|
|
|
const payload = {
|
2026-03-02 12:43:11 -05:00
|
|
|
|
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,
|
2026-03-01 23:03:18 -05:00
|
|
|
|
expires_minutes: form.expires_minutes.value ? parseInt(form.expires_minutes.value) : null,
|
|
|
|
|
|
};
|
2026-04-18 23:46:44 -04:00
|
|
|
|
try {
|
|
|
|
|
|
await lt.api.post('/api/suppressions', payload);
|
2026-03-01 23:03:18 -05:00
|
|
|
|
showToast('Suppression applied', 'success');
|
2026-04-19 23:35:02 -04:00
|
|
|
|
form.reset();
|
|
|
|
|
|
onTypeChange();
|
|
|
|
|
|
document.querySelectorAll('.duration-pills .pill').forEach(p => p.classList.remove('active'));
|
|
|
|
|
|
document.querySelector('.duration-pills .pill-manual')?.classList.add('active');
|
|
|
|
|
|
document.getElementById('s-dur-hint').textContent = 'Persists until manually removed.';
|
|
|
|
|
|
await refreshActive();
|
2026-04-18 23:46:44 -04:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
showToast(err.message || 'Error', 'error');
|
2026-04-19 23:35:02 -04:00
|
|
|
|
} finally {
|
|
|
|
|
|
btn.classList.remove('is-loading');
|
2026-03-01 23:03:18 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function removeSuppression(id) {
|
|
|
|
|
|
if (!confirm('Remove this suppression?')) return;
|
2026-04-18 23:46:44 -04:00
|
|
|
|
try {
|
|
|
|
|
|
await lt.api.delete(`/api/suppressions/${id}`);
|
2026-03-01 23:03:18 -05:00
|
|
|
|
document.getElementById(`sup-row-${id}`)?.remove();
|
2026-04-19 23:35:02 -04:00
|
|
|
|
const badge = document.getElementById('active-sup-badge');
|
|
|
|
|
|
if (badge) badge.textContent = Math.max(0, parseInt(badge.textContent || '0') - 1);
|
|
|
|
|
|
const tbody = document.querySelector('#active-sup-table tbody');
|
|
|
|
|
|
if (tbody && !tbody.children.length) {
|
|
|
|
|
|
document.getElementById('active-sup-wrap').innerHTML =
|
|
|
|
|
|
'<p class="empty-state" id="no-active-msg">No active suppressions.</p>';
|
|
|
|
|
|
if (badge) badge.textContent = '0';
|
|
|
|
|
|
}
|
2026-03-01 23:03:18 -05:00
|
|
|
|
showToast('Suppression removed', 'success');
|
2026-04-18 23:46:44 -04:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
showToast(err.message || 'Failed to remove suppression', 'error');
|
2026-03-01 23:03:18 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-18 23:46:44 -04:00
|
|
|
|
|
|
|
|
|
|
document.addEventListener('click', e => {
|
2026-04-19 00:01:52 -04:00
|
|
|
|
const pill = e.target.closest('#create-suppression-form .pill[data-duration]');
|
|
|
|
|
|
if (pill) {
|
|
|
|
|
|
const val = pill.dataset.duration;
|
|
|
|
|
|
setDur(val ? parseInt(val) : null, pill);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const removeBtn = e.target.closest('[data-action="remove-sup"]');
|
|
|
|
|
|
if (removeBtn) removeSuppression(parseInt(removeBtn.dataset.supId));
|
2026-04-18 23:46:44 -04:00
|
|
|
|
});
|
2026-03-01 23:03:18 -05:00
|
|
|
|
</script>
|
|
|
|
|
|
{% endblock %}
|