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 >
< p class = "g-page-sub" style = "margin-top:4px" > Manage maintenance windows and per-target alert suppression rules.< / p >
< / 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-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-29 23:37:47 -04:00
{%- 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 >
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-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-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 %}
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-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 ) {
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 = `
<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 ) {
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 %}