2026-03-01 23:03:18 -05:00
{% extends "base.html" %}
{% block title %}Suppressions – GANDALF{% endblock %}
{% block content %}
2026-05-01 01:09:30 -04:00
< div class = "lt-page-header" >
< div >
< h1 class = "lt-page-title" > Alert Suppressions</ h1 >
2026-05-10 23:09:25 -04:00
< p class = "g-page-sub" > Manage maintenance windows and per-target alert suppression rules.</ p >
2026-05-01 01:09:30 -04:00
</ div >
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" >
2026-04-29 17:53:48 -04:00
< form id = "create-suppression-form" >
2026-04-18 21:01:20 -04:00
< div class = "form-row" >
< div class = "lt-form-group" >
< label class = "lt-label" for = "s-type" > Target Type < span class = "required" > *</ span ></ label >
2026-04-29 17:53:48 -04:00
< select class = "lt-select" id = "s-type" name = "target_type" >
2026-04-18 21:01:20 -04:00
< 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-05-01 17:15:48 -04:00
< div class = "lt-frame" >
< span class = "lt-frame-bl" > ╚ </ span >
< span class = "lt-frame-br" > ╝ </ span >
< div class = "lt-section-header" > Active Rules</ div >
< 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 >
{% for s in active %}
< tr id = "sup-row-{{ s.id }}" >
{%- set _sup_badge = {'host':'badge-warning','interface':'badge-info','unifi_device':'badge-purple','all':'badge-critical'} -%}
< td >< span class = "lt-badge {{ _sup_badge.get(s.target_type, 'badge-neutral') }}" > {{ s.target_type }}</ span ></ 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 >
< button class = "lt-btn lt-btn-danger lt-btn-sm" data-action = "remove-sup" data-sup-id = "{{ s.id }}" > Remove</ button >
</ td >
</ tr >
{% endfor %}
</ tbody >
</ table >
</ div >
2026-03-01 23:03:18 -05:00
</ div >
{% else %}
2026-04-30 21:09:56 -04:00
< div class = "lt-empty-state lt-empty-state--sm" id = "no-active-msg" >
< div class = "lt-empty-state-icon" > 🔕</ div >
< div class = "lt-empty-state-title" > No active suppressions</ div >
< div class = "lt-empty-state-body" > All alerts are active. Use the form above to silence a host or interface.</ div >
</ div >
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-05-01 17:15:48 -04:00
< div class = "lt-frame" >
< span class = "lt-frame-bl" > ╚ </ span >
< span class = "lt-frame-br" > ╝ </ span >
< div class = "lt-section-header" > Suppression Log</ div >
< div class = "lt-table-wrap" >
< table class = "lt-table lt-table-sm" >
< caption class = "lt-sr-only" > Suppression history</ 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 > 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 = "lt-badge badge-ok" > Yes</ span >
{% else %}
< span class = "lt-badge badge-neutral" > No</ span >
{% endif %}
</ td >
</ tr >
{% endfor %}
</ tbody >
</ table >
</ div >
2026-03-01 23:03:18 -05:00
</ div >
{% else %}
2026-04-30 21:09:56 -04:00
< div class = "lt-empty-state lt-empty-state--sm" >
< div class = "lt-empty-state-icon" > 📋</ div >
< div class = "lt-empty-state-title" > No suppression history yet</ div >
</ div >
2026-03-01 23:03:18 -05:00
{% 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-05-01 17:39:11 -04:00
< div class = "lt-frame" >
< span class = "lt-frame-bl" > ╚ </ span >
< span class = "lt-frame-br" > ╝ </ span >
< div class = "lt-section-header" > Host & Interface Reference</ div >
2026-05-10 23:09:25 -04:00
< div class = "lt-section-body" >
2026-05-01 17:39:11 -04:00
< 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 (prometheus)' if host.source == 'prometheus' else 'Ping-only host' }}</ div >
{% if host.interfaces %}
< div class = "target-ifaces" >
{% for iface in host.interfaces.keys() | sort %}
< code class = "iface-chip" > {{ iface }}</ code >
{% endfor %}
</ div >
{% endif %}
</ div >
2026-03-01 23:03:18 -05:00
{% endfor %}
</ div >
</ div >
</ 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 ) {
2026-04-30 21:09:56 -04:00
wrap . innerHTML = '<div class="lt-empty-state lt-empty-state--sm" id="no-active-msg"><div class="lt-empty-state-icon">🔕</div><div class="lt-empty-state-title">No active suppressions</div><div class="lt-empty-state-body">All alerts are active. Use the form above to silence a host or interface.</div></div>' ;
2026-04-19 23:35:02 -04:00
if ( badge ) badge . textContent = '0' ;
return ;
}
if ( badge ) badge . textContent = rows . length ;
2026-04-29 23:37:47 -04:00
const SUP_BADGE = { host : 'badge-warning' , interface : 'badge-info' , unifi_device : 'badge-purple' , all : 'badge-critical' };
2026-04-19 23:35:02 -04:00
const tbody = rows . map ( s => `
<tr id="sup-row- ${ s . id } ">
2026-04-29 23:37:47 -04:00
<td><span class="lt-badge ${ SUP_BADGE [ s . target_type ] || 'badge-neutral' } "> ${ lt . escHtml ( s . target_type ) } </span></td>
2026-04-19 23:35:02 -04:00
<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 = `
2026-05-01 17:15:48 -04:00
<div class="lt-frame">
<span class="lt-frame-bl">╚</span>
<span class="lt-frame-br">╝</span>
<div class="lt-section-header">Active Rules</div>
<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>
2026-04-19 23:35:02 -04:00
</div>` ;
}
async function refreshActive () {
try {
const rows = await lt . api . get ( '/api/suppressions' );
renderActiveRows ( rows );
} catch ( err ) {
2026-04-30 21:09:56 -04:00
showToast ( 'Failed to refresh suppressions' , 'warning' );
2026-04-19 23:35:02 -04:00
}
}
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 =
2026-04-30 21:09:56 -04:00
'<div class="lt-empty-state lt-empty-state--sm" id="no-active-msg"><div class="lt-empty-state-icon">🔕</div><div class="lt-empty-state-title">No active suppressions</div><div class="lt-empty-state-body">All alerts are active. Use the form above to silence a host or interface.</div></div>' ;
2026-04-19 23:35:02 -04:00
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
2026-04-29 17:53:48 -04:00
document . getElementById ( 's-type' ) ? . addEventListener ( 'change' , onTypeChange );
document . getElementById ( 'create-suppression-form' ) ? . addEventListener ( 'submit' , createSuppression );
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 %}