Alert history page and a tighter monitor health check #21

Merged
jared merged 1 commits from events-history into main 2026-11-10 20:37:46 -05:00
11 changed files with 582 additions and 17 deletions
Showing only changes of commit 9d561db703 - Show all commits
+19
View File
@@ -114,6 +114,23 @@ Ping-only (no node_exporter): **pbs** (10.10.10.3)
- Quick-suppress modal: apply timed or manual suppression from any alert row
- Auto-refreshes every 30 seconds via `/api/status` + `/api/network`
### Alert history (`/events`)
Every alert Gandalf raised in the last 24 hours to 90 days, including ones that cleared before anyone
saw them: when each started, how long it lasted, failed checks, and its ticket.
- Filter by device (name, port or interface), alert type, severity, and open or cleared. Filters are
kept in the URL, so `/events?target=large1&range=90d` is a shareable link; device pages and tickets
link to their own history this way.
- **Recurring** lists the same alert on the same target more than once in the range, worst first, since
a flapping cable or optic shows up here before anyone notices the tickets.
- API: `GET /api/events/history?range=24h|7d|30d|90d&type=&target=&severity=&status=all|active|resolved&offset=`
(100 per page).
### Health check (`/health`)
No login needed. Returns 200 when the database answers and the monitor has completed a check recently,
otherwise 503 with the failing check: `{"status": "degraded", "checks": {"db": "ok", "monitor": "stale",
"monitor_age_s": 742}}`. The monitor counts as stalled after five missed polls (at least 10 minutes),
so point an uptime monitor at it to catch a hung `gandalf-monitor`, not just a dead web app.
### Device pages (`/device/<name>`)
One page per host, switch, router, AP, PDU, or modem. Reach them from the search box on the
dashboard (`/` focuses it), or from any device name. Search matches hostname, IP, MAC, model, type,
@@ -611,12 +628,14 @@ gandalf/
│ ├── app.js # Dashboard JS (auto-refresh, events, suppress modal, search, modem panel)
│ ├── device.js # Device page rendering
│ ├── cutsheet.js # Cutsheet page
│ ├── events.js # Alert history page
│ └── port-inspector.js # Switch front panel, port panel, path check, diagnostics
└── templates/
├── base.html # Shared layout (header, nav, footer)
├── index.html # Dashboard page
├── device.html # Per-device page
├── cutsheet.html # Cutsheet page and its editors
├── events.html # Alert history page
├── _modem_panel.html # WAN / Fiber panel, shared by the dashboard and modem page
└── suppressions.html # Suppression management page
```
+80 -16
View File
@@ -17,6 +17,7 @@ import time
import uuid
from datetime import datetime, timezone
from functools import wraps
from typing import Optional
from flask import Flask, jsonify, make_response, redirect, render_template, request, send_file, url_for
@@ -104,15 +105,34 @@ def add_security_headers(response):
return response
def _daemon_ok(last_check: str) -> bool:
"""Return True if monitor last checked within 20 minutes."""
def _monitor_stale_after() -> int:
"""Seconds without a completed check before the monitor counts as stalled.
Five missed polls, and never under 10 minutes: one slow cycle (an SSH timeout
to a dead host) shouldn't trip it, but a hung monitor is caught in minutes.
"""
try:
poll = int(_config().get('monitor', {}).get('poll_interval', 120))
except (TypeError, ValueError):
poll = 120
return max(600, poll * 5)
def _monitor_age(last_check: str) -> Optional[float]:
"""Seconds since the monitor's last check, or None if it never ran or can't be read."""
if not last_check or last_check == 'Never':
return False
return None
try:
ts = datetime.strptime(last_check, '%Y-%m-%d %H:%M:%S UTC').replace(tzinfo=timezone.utc)
return (datetime.now(timezone.utc) - ts).total_seconds() < 1200
except Exception:
return False
except ValueError:
return None
return (datetime.now(timezone.utc) - ts).total_seconds()
def _daemon_ok(last_check: str) -> bool:
"""True if the monitor checked recently enough (see _monitor_stale_after)."""
age = _monitor_age(last_check)
return age is not None and age < _monitor_stale_after()
# ---------------------------------------------------------------------------
@@ -308,6 +328,50 @@ def inspector():
return redirect(url_for('index'))
@app.route('/events')
@require_auth
def events_page():
return render_template('events.html', user=_get_user())
_HISTORY_RANGES = {'24h': 24, '7d': 24 * 7, '30d': 24 * 30, '90d': 24 * 90}
_HISTORY_PAGE = 100
@app.route('/api/events/history')
@require_auth
def api_events_history():
"""Every alert open at some point in a time window, with summaries.
Query: range (24h|7d|30d|90d), type, target (substring of name or detail),
severity, status (all|active|resolved), offset.
"""
args = request.args
hours = _HISTORY_RANGES.get(args.get('range', '7d'))
if hours is None:
return jsonify({'error': f'range must be one of {", ".join(_HISTORY_RANGES)}'}), 400
severity = args.get('severity', '')
if severity not in ('', 'critical', 'warning', 'info'):
return jsonify({'error': 'severity must be critical, warning or info'}), 400
status = args.get('status', 'all')
if status not in ('all', 'active', 'resolved'):
return jsonify({'error': 'status must be all, active or resolved'}), 400
try:
offset = max(int(args.get('offset', 0)), 0)
except ValueError:
return jsonify({'error': 'offset must be an integer'}), 400
event_type = args.get('type', '')[:60]
target = args.get('target', '').strip()[:255]
result = db.get_events_history(hours, event_type, target, severity, status,
limit=_HISTORY_PAGE, offset=offset)
result['flappers'] = db.get_flappers(hours, event_type, target, severity)
result['types'] = db.get_event_types()
result['offset'] = offset
result['page_size'] = _HISTORY_PAGE
return jsonify(result)
@app.route('/suppressions')
@require_auth
@require_admin
@@ -1090,19 +1154,19 @@ def health():
checks['db'] = 'error'
overall = 'degraded'
# Monitor freshness: fail if last_check is older than 20 minutes
# Monitor freshness: a hung or crashed monitor stops updating last_check
try:
last_check = db.get_state('last_check', '')
if last_check:
ts = datetime.strptime(last_check, '%Y-%m-%d %H:%M:%S UTC').replace(tzinfo=timezone.utc)
age_s = (datetime.now(timezone.utc) - ts).total_seconds()
if age_s > 1200:
checks['monitor'] = 'stale'
overall = 'degraded'
else:
checks['monitor'] = 'ok'
else:
age_s = _monitor_age(last_check)
if not last_check:
checks['monitor'] = 'no data yet'
elif age_s is None or age_s > _monitor_stale_after():
checks['monitor'] = 'stale'
checks['monitor_age_s'] = int(age_s) if age_s is not None else None
overall = 'degraded'
else:
checks['monitor'] = 'ok'
checks['monitor_age_s'] = int(age_s)
except Exception as e:
logger.error(f'Health check monitor error: {e}')
checks['monitor'] = 'error'
+94
View File
@@ -423,6 +423,100 @@ def event_stats(event_type: str, target_name: str, target_detail: str = '') -> d
}
def _history_where(since_hours: int, event_type: str = '', target: str = '',
severity: str = '', status: str = 'all') -> tuple:
"""WHERE clause shared by the history list and its summaries.
An event is in range if it was open at any point in the window: it started
in it, or it was still open when the window began.
"""
clauses = ['(first_seen > DATE_SUB(NOW(), INTERVAL %s HOUR) OR '
'resolved_at IS NULL OR resolved_at > DATE_SUB(NOW(), INTERVAL %s HOUR))']
params: list = [since_hours, since_hours]
if event_type:
clauses.append('event_type=%s')
params.append(event_type)
if severity:
clauses.append('severity=%s')
params.append(severity)
if target:
like = '%' + target.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_') + '%'
clauses.append('(target_name LIKE %s OR target_detail LIKE %s)')
params.extend([like, like])
if status == 'active':
clauses.append('resolved_at IS NULL')
elif status == 'resolved':
clauses.append('resolved_at IS NOT NULL')
return ' AND '.join(clauses), params
def _iso(rows: list, keys=('first_seen', 'last_seen', 'resolved_at')) -> list:
for r in rows:
for k in keys:
if r.get(k) and hasattr(r[k], 'isoformat'):
r[k] = r[k].isoformat() + 'Z'
return rows
def get_events_history(since_hours: int, event_type: str = '', target: str = '', severity: str = '',
status: str = 'all', limit: int = 100, offset: int = 0) -> dict:
"""Events open at any time in the last `since_hours`, newest first, with totals.
Returns {'events', 'total', 'by_type': {type: count}}.
"""
where, params = _history_where(since_hours, event_type, target, severity, status)
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
f"""SELECT id, event_type, severity, source_type, target_name, target_detail, description,
first_seen, last_seen, resolved_at, consecutive_failures, ticket_id,
TIMESTAMPDIFF(SECOND, first_seen, COALESCE(resolved_at, NOW())) AS duration_s
FROM network_events WHERE {where}
ORDER BY first_seen DESC, id DESC LIMIT %s OFFSET %s""", # nosec B608 - fixed clauses
params + [limit, offset],
)
events = _iso(list(cur.fetchall()))
cur.execute(
f"""SELECT event_type, COUNT(*) AS n
FROM network_events WHERE {where} GROUP BY event_type ORDER BY n DESC""", # nosec B608
params,
)
groups = cur.fetchall()
return {
'events': events,
'total': sum(int(g['n']) for g in groups),
'by_type': {g['event_type']: int(g['n']) for g in groups},
}
def get_flappers(since_hours: int, event_type: str = '', target: str = '', severity: str = '',
min_count: int = 2, limit: int = 10) -> list:
"""The alerts that fired most often in the window: the ones worth a root cause."""
where, params = _history_where(since_hours, event_type, target, severity)
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
f"""SELECT event_type, target_name, target_detail, COUNT(*) AS n,
SUM(TIMESTAMPDIFF(SECOND, first_seen, COALESCE(resolved_at, NOW()))) AS total_s,
MAX(first_seen) AS last_seen, SUM(resolved_at IS NULL) AS open_now
FROM network_events WHERE {where}
GROUP BY event_type, target_name, target_detail
HAVING n >= %s ORDER BY n DESC, last_seen DESC LIMIT %s""", # nosec B608 - fixed clauses
params + [min_count, limit],
)
rows = list(cur.fetchall())
for r in rows:
r['n'], r['total_s'], r['open_now'] = int(r['n']), int(r['total_s'] or 0), int(r['open_now'] or 0)
return _iso(rows, ('last_seen',))
def get_event_types() -> list:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute('SELECT DISTINCT event_type FROM network_events ORDER BY event_type')
return [r['event_type'] for r in cur.fetchall()]
def purge_old_resolved_events(days: int = 90) -> int:
"""Delete resolved events older than `days` days. Returns count deleted."""
with get_conn() as conn:
+1
View File
@@ -1159,6 +1159,7 @@ class NetworkMonitor:
lines = []
if device:
lines.append(f'- {ticket_text.link(f"{device} in Gandalf", ticket_text.device_url(base, device))}')
lines.append(f'- {ticket_text.link(f"Alert history for {device}", ticket_text.history_url(base, device))}')
lines.append(f'- {ticket_text.link("Gandalf dashboard", base + "/")}')
return lines
+2 -1
View File
@@ -300,7 +300,8 @@ function renderEvents(events) {
<td class="ts-cell" title="${esc(e.first_seen || '')}">${esc(lt.time.ago(_toIso(e.first_seen)))}</td>
<td>${e.resolved_at ? `resolved ${esc(lt.time.ago(_toIso(e.resolved_at)))}` : '<strong>active</strong>'}</td>
</tr>`).join('')}</tbody></table></div>`;
return section('Alerts', body);
const more = `<p class="g-section-more"><a href="/events?target=${encodeURIComponent(DEVICE_NAME)}&amp;range=90d">Full alert history for ${esc(DEVICE_NAME)} →</a></p>`;
return section('Alerts', body + more);
}
function currentRows() {
+218
View File
@@ -0,0 +1,218 @@
'use strict';
/* global deviceUrl, _toIso */
// Alert history page: renders /api/events/history with filters kept in the URL.
const esc = s => lt.escHtml(s == null ? '' : String(s));
const FILTERS = ['target', 'range', 'type', 'severity', 'status'];
const DEFAULTS = { target: '', range: '7d', type: '', severity: '', status: 'all' };
const RANGE_TEXT = { '24h': '24 hours', '7d': '7 days', '30d': '30 days', '90d': '90 days' };
let _offset = 0;
let _loadSeq = 0;
function typeText(t) {
return String(t || '').replace(/_/g, ' ');
}
function duration(seconds) {
let s = Math.max(0, Math.floor(seconds || 0));
if (s < 60) return `${s} s`;
let m = Math.floor(s / 60);
if (m < 60) return `${m} min`;
let h = Math.floor(m / 60);
m %= 60;
if (h < 48) return m ? `${h} h ${m} min` : `${h} h`;
const d = Math.floor(h / 24);
h %= 24;
return h ? `${d} d ${h} h` : `${d} d`;
}
function when(iso) {
if (!iso) return '–';
const d = new Date(_toIso(iso));
if (isNaN(d)) return esc(iso);
const text = d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
return `<span title="${esc(d.toLocaleString())}">${esc(text)}</span>`;
}
function ticketLink(id) {
if (!id) return '<span class="val-neutral">–</span>';
const base = (typeof GANDALF_CONFIG !== 'undefined' && GANDALF_CONFIG.ticket_web_url) || '';
return base ? `<a href="${esc(base + id)}" target="_blank" rel="noopener">#${esc(id)}</a>` : `#${esc(id)}`;
}
function filters() {
const f = {};
FILTERS.forEach(k => { f[k] = document.getElementById(`ev-${k}`).value.trim(); });
return f;
}
function readUrl() {
const q = new URLSearchParams(location.search);
FILTERS.forEach(k => {
const el = document.getElementById(`ev-${k}`);
const v = q.get(k);
if (v == null) return;
if (el.tagName === 'SELECT' && ![...el.options].some(o => o.value === v)) {
// An alert type from a link may not be in the list until data arrives
if (k === 'type') el.add(new Option(typeText(v), v));
else return;
}
el.value = v;
});
}
function writeUrl(f) {
const q = new URLSearchParams();
FILTERS.forEach(k => { if (f[k] && f[k] !== DEFAULTS[k]) q.set(k, f[k]); });
const qs = q.toString();
history.replaceState(null, '', qs ? `?${qs}` : location.pathname);
document.getElementById('ev-clear').hidden = !FILTERS.some(k => k !== 'range' && f[k] && f[k] !== DEFAULTS[k]);
}
function setFilter(changes) {
Object.entries(changes).forEach(([k, v]) => {
const el = document.getElementById(`ev-${k}`);
if (el.tagName === 'SELECT' && v && ![...el.options].some(o => o.value === v)) el.add(new Option(typeText(v), v));
el.value = v;
});
_offset = 0;
load();
}
function fillTypes(types) {
const sel = document.getElementById('ev-type');
const current = sel.value;
sel.innerHTML = '<option value="">All alert types</option>' +
types.map(t => `<option value="${esc(t)}">${esc(typeText(t))}</option>`).join('');
if (current && !types.includes(current)) sel.add(new Option(typeText(current), current));
sel.value = current;
}
function renderSummary(data, f) {
const open = data.events.filter(e => !e.resolved_at).length;
const chips = [
`<span class="cs-chip"><b>${data.total}</b> alert${data.total === 1 ? '' : 's'} in the last ${esc(RANGE_TEXT[f.range] || f.range)}</span>`,
];
if (open && data.offset === 0) chips.push(`<span class="cs-chip cs-chip-bad"><b>${open}</b> open now</span>`);
Object.entries(data.by_type || {}).forEach(([t, n]) => {
const on = f.type === t;
chips.push(`<button type="button" class="cs-chip ev-type-chip${on ? ' active' : ''}" data-type="${esc(on ? '' : t)}"
aria-pressed="${on}"><b>${n}</b> ${esc(typeText(t))}</button>`);
});
document.getElementById('ev-summary').innerHTML = chips.join('');
}
function renderFlappers(rows) {
const sec = document.getElementById('ev-flappers-section');
sec.hidden = !rows.length;
if (!rows.length) return;
document.getElementById('ev-flappers').innerHTML = `<div class="lt-table-wrap"><table class="lt-table ev-cards">
<caption class="lt-sr-only">Alerts that fired more than once</caption>
<thead><tr><th>Alert</th><th>Target</th><th>Times</th><th>Total time</th><th>Last started</th><th></th></tr></thead>
<tbody>${rows.map(r => `
<tr>
<td class="ev-c-type">${esc(typeText(r.event_type))}${r.open_now ? ' <span class="lt-badge badge-critical">open</span>' : ''}</td>
<td class="ev-c-target"><a class="device-link" href="${esc(deviceUrl(r.target_name))}">${esc(r.target_name)}</a>
${r.target_detail ? `<span class="cs-port">${esc(r.target_detail)}</span>` : ''}</td>
<td data-label="Times"><strong>${r.n}</strong></td>
<td data-label="Total">${esc(duration(r.total_s))}</td>
<td class="ts-cell" data-label="Last">${when(r.last_seen)}</td>
<td class="ev-c-action"><button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-show-type="${esc(r.event_type)}"
data-show-target="${esc(r.target_name)}">Show these</button></td>
</tr>`).join('')}</tbody></table></div>`;
}
function renderTable(data) {
document.getElementById('ev-count').textContent = data.total;
const wrap = document.getElementById('ev-table');
if (!data.events.length) {
wrap.innerHTML = '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-icon">✔</div>' +
'<div class="lt-empty-state-title">No alerts match</div></div>';
document.getElementById('ev-pager').innerHTML = '';
return;
}
wrap.innerHTML = `<div class="lt-table-wrap"><table class="lt-table ev-table ev-cards">
<caption class="lt-sr-only">Alert history</caption>
<thead><tr><th>State</th><th>Type</th><th>Target</th><th>Started</th><th>Lasted</th>
<th title="Consecutive failed checks">Checks</th><th>Ticket</th><th>Description</th></tr></thead>
<tbody>${data.events.map(e => {
const open = !e.resolved_at;
return `<tr class="${open ? `row-${esc(e.severity)}` : ''}">
<td class="ev-c-state"><span class="lt-badge badge-${open ? esc(e.severity) : 'neutral'}">${open ? 'open' : 'cleared'}</span></td>
<td class="ev-c-type"><button type="button" class="ev-link" data-show-type="${esc(e.event_type)}"
title="Only show ${esc(typeText(e.event_type))}">${esc(typeText(e.event_type))}</button></td>
<td class="ev-c-target"><a class="device-link" href="${esc(deviceUrl(e.target_name))}">${esc(e.target_name)}</a>
${e.target_detail ? `<span class="cs-port">${esc(e.target_detail)}</span>` : ''}</td>
<td class="ts-cell" data-label="Started">${when(e.first_seen)}</td>
<td data-label="Lasted">${esc(duration(e.duration_s))}${open ? ' <span class="val-neutral">so far</span>' : ''}</td>
<td data-label="Checks">${esc(e.consecutive_failures)}</td>
<td data-label="Ticket">${ticketLink(e.ticket_id)}</td>
<td class="desc-cell ev-c-desc" title="${esc(e.description || '')}">${esc(e.description || '')}</td>
</tr>`;
}).join('')}</tbody></table></div>`;
const end = data.offset + data.events.length;
const prev = data.offset > 0;
const next = end < data.total;
document.getElementById('ev-pager').innerHTML = (prev || next) ? `
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-page="${data.offset - data.page_size}" ${prev ? '' : 'disabled'}>← Newer</button>
<span>${data.offset + 1}–${end} of ${data.total}</span>
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-page="${end}" ${next ? '' : 'disabled'}>Older →</button>` : '';
}
async function load() {
const f = filters();
writeUrl(f);
const q = new URLSearchParams();
FILTERS.forEach(k => { if (f[k]) q.set(k, f[k]); });
if (_offset) q.set('offset', _offset);
const seq = ++_loadSeq;
try {
const data = await lt.api.get(`/api/events/history?${q}`);
if (seq !== _loadSeq) return; // a newer filter change is already loading
fillTypes(data.types || []);
renderSummary(data, f);
renderFlappers(data.flappers || []);
renderTable(data);
} catch (err) {
if (seq !== _loadSeq) return;
document.getElementById('ev-table').innerHTML =
`<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">Could not load alerts</div>
<div class="lt-empty-state-text">${esc(err.message || err)}</div></div>`;
}
}
let _typing = null;
document.getElementById('ev-target').addEventListener('input', () => {
clearTimeout(_typing);
_typing = setTimeout(() => { _offset = 0; load(); }, 300);
});
document.getElementById('ev-filters').addEventListener('change', e => {
if (e.target.id === 'ev-target') return;
_offset = 0;
load();
});
document.getElementById('ev-filters').addEventListener('submit', e => { e.preventDefault(); _offset = 0; load(); });
document.getElementById('ev-clear').addEventListener('click', () => {
setFilter({ target: '', type: '', severity: '', status: 'all' });
});
document.addEventListener('click', e => {
const typeChip = e.target.closest('.ev-type-chip');
if (typeChip) return setFilter({ type: typeChip.dataset.type });
const show = e.target.closest('[data-show-type]');
if (show) {
const changes = { type: show.dataset.showType };
if (show.dataset.showTarget) changes.target = show.dataset.showTarget;
return setFilter(changes);
}
const page = e.target.closest('[data-page]');
if (page && !page.disabled) {
_offset = Math.max(0, parseInt(page.dataset.page, 10) || 0);
load();
document.getElementById('ev-count').scrollIntoView({ block: 'nearest' });
}
});
readUrl();
load();
+29
View File
@@ -1393,3 +1393,32 @@ polygon.fp-led-off { fill: #6b6c71; }
.cs-label-card { break-inside: avoid; border-color: #000; color: #000; }
.lt-table, .lt-table td, .lt-table th { color: #000 !important; }
}
/* ── Alert history (/events) ───────────────────────────────────────── */
.ev-filters .lt-select { flex: 0 1 170px; }
.ev-type-chip { background: none; cursor: pointer; font: inherit; font-size: .82rem; }
.ev-type-chip:hover, .ev-type-chip:focus-visible { border-color: var(--accent-cyan); }
.ev-type-chip.active { border-color: var(--accent-cyan); background: var(--accent-cyan-dim); }
.ev-link { background: none; border: 0; padding: 0; color: inherit; font: inherit; cursor: pointer;
text-decoration: underline dotted; text-underline-offset: 3px; text-align: left; }
.ev-link:hover, .ev-link:focus-visible { color: var(--accent-cyan); }
.ev-table td { vertical-align: top; }
.ev-table .cs-port { display: block; }
.ev-pager { display: flex; gap: 12px; align-items: center; justify-content: center; margin-top: 12px; font-size: .85rem; }
.g-section-more { margin: 10px 12px 4px; font-size: .85rem; }
.ev-table .lt-badge, .ev-c-type { white-space: nowrap; }
/* Phones: each alert becomes a card instead of a squeezed table row */
@media (max-width: 760px) {
.ev-cards thead { display: none; }
.ev-cards, .ev-cards tbody { display: block; width: 100%; }
.ev-cards tr { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 14px; padding: 10px 12px;
border-bottom: 1px solid var(--border-color); }
.ev-cards td { display: block; padding: 0; border: 0; max-width: none; white-space: normal; font-size: .82rem; }
.ev-cards td[data-label]::before { content: attr(data-label) " "; color: var(--text-muted); font-size: .72rem;
text-transform: uppercase; letter-spacing: .05em; }
.ev-cards .ev-c-target { flex: 1 1 100%; order: -1; font-size: .95rem; }
.ev-cards .ev-c-target .cs-port { display: inline; margin-left: 6px; }
.ev-cards .ev-c-desc { flex: 1 1 100%; color: var(--text-muted); white-space: normal; overflow: visible; }
.ev-cards .ev-c-action { flex: 1 1 100%; }
}
+9
View File
@@ -37,6 +37,9 @@
<a href="{{ url_for('index') }}"
class="lt-nav-drawer-link{% if request.endpoint == 'index' %} active{% endif %}"
{% if request.endpoint == 'index' %}aria-current="page"{% endif %}>Dashboard</a>
<a href="{{ url_for('events_page') }}"
class="lt-nav-drawer-link{% if request.endpoint == 'events_page' %} active{% endif %}"
{% if request.endpoint == 'events_page' %}aria-current="page"{% endif %}>History</a>
<a href="{{ url_for('cutsheet_page') }}"
class="lt-nav-drawer-link{% if request.endpoint == 'cutsheet_page' %} active{% endif %}"
{% if request.endpoint == 'cutsheet_page' %}aria-current="page"{% endif %}>Cutsheet</a>
@@ -82,6 +85,11 @@
{% if request.endpoint == 'index' %}aria-current="page"{% endif %}>
Dashboard
</a>
<a href="{{ url_for('events_page') }}"
class="lt-nav-link{% if request.endpoint == 'events_page' %} active{% endif %}"
{% if request.endpoint == 'events_page' %}aria-current="page"{% endif %}>
History
</a>
<a href="{{ url_for('cutsheet_page') }}"
class="lt-nav-link{% if request.endpoint == 'cutsheet_page' %} active{% endif %}"
{% if request.endpoint == 'cutsheet_page' %}aria-current="page"{% endif %}>
@@ -324,6 +332,7 @@
// Command palette
lt.cmdPalette.init([
{ id: 'nav-dashboard', group: 'Navigate', icon: '~', label: 'Dashboard', action: function() { window.location.href = '/'; } },
{ id: 'nav-history', group: 'Navigate', icon: '⏱', label: 'Alert History', action: function() { window.location.href = '/events'; } },
{ id: 'nav-cutsheet', group: 'Navigate', icon: '🔌', label: 'Cutsheet', action: function() { window.location.href = '/cutsheet'; } },
{ id: 'nav-suppressions', group: 'Navigate', icon: '🔕', label: 'Suppressions', action: function() { window.location.href = '/suppressions'; } },
{ id: 'action-refresh', group: 'Actions', icon: '↻', label: 'Refresh Data', kbd: 'R', action: function() { lt.autoRefresh.now(); } },
+66
View File
@@ -0,0 +1,66 @@
{% extends "base.html" %}
{% block title %}Alert History – GANDALF{% endblock %}
{% block content %}
<div class="lt-page-header">
<div>
<h1 class="lt-page-title">Alert History</h1>
<p class="g-page-sub">Every alert Gandalf raised, including the ones that cleared on their own. Resolved alerts are kept 90 days.</p>
</div>
</div>
<form class="cs-filters ev-filters" id="ev-filters" role="search" aria-label="Filter alert history">
<input type="search" class="lt-input" id="ev-target" name="target" placeholder="Device, host, port or interface"
aria-label="Filter by device" autocomplete="off">
<select class="lt-select" id="ev-range" name="range" aria-label="Time range">
<option value="24h">Last 24 hours</option>
<option value="7d" selected>Last 7 days</option>
<option value="30d">Last 30 days</option>
<option value="90d">Last 90 days</option>
</select>
<select class="lt-select" id="ev-type" name="type" aria-label="Alert type">
<option value="">All alert types</option>
</select>
<select class="lt-select" id="ev-severity" name="severity" aria-label="Severity">
<option value="">All severities</option>
<option value="critical">Critical</option>
<option value="warning">Warning</option>
<option value="info">Info</option>
</select>
<select class="lt-select" id="ev-status" name="status" aria-label="State">
<option value="all">Open and cleared</option>
<option value="active">Open now</option>
<option value="resolved">Cleared</option>
</select>
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" id="ev-clear" hidden>Clear filters</button>
</form>
<div id="ev-summary" class="cs-summary" aria-live="polite"></div>
<section class="g-section" id="ev-flappers-section" hidden>
<div class="g-section-header">
<h2 class="g-section-title">Recurring</h2>
</div>
<p class="cs-hint">The same alert on the same target more than once in this range. Something that keeps coming back usually has one cause worth finding.</p>
<div class="lt-frame">
<span class="lt-frame-bl">&#x255A;</span><span class="lt-frame-br">&#x255D;</span>
<div id="ev-flappers"></div>
</div>
</section>
<section class="g-section">
<div class="g-section-header">
<h2 class="g-section-title">Alerts</h2>
<span class="g-section-badge" id="ev-count">0</span>
</div>
<div class="lt-frame">
<span class="lt-frame-bl">&#x255A;</span><span class="lt-frame-br">&#x255D;</span>
<div id="ev-table"><div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">Loading…</div></div></div>
</div>
<div class="ev-pager" id="ev-pager"></div>
</section>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='events.js') }}"></script>
{% endblock %}
+60
View File
@@ -316,3 +316,63 @@ class TestCutsheetLivePorts:
def test_admin_only(self, cs_client):
client, _ = cs_client
assert client.get('/api/cutsheet/live-ports', headers=USER).status_code == 403
class TestAlertHistory:
HISTORY = {'events': [], 'total': 0, 'by_type': {}}
def _db(self):
db = gandalf.db
db.get_events_history.return_value = dict(self.HISTORY)
db.get_flappers.return_value = []
db.get_event_types.return_value = ['host_unreachable']
return db
def test_page_and_nav(self, client):
html = client.get('/events', headers=AUTH).get_data(as_text=True)
assert 'Alert History' in html and 'events.js' in html
assert 'href="/events"' in client.get('/', headers=AUTH).get_data(as_text=True)
def test_defaults_to_seven_days(self, client):
db = self._db()
data = client.get('/api/events/history', headers=AUTH).get_json()
assert db.get_events_history.call_args.args == (168, '', '', '', 'all')
assert data['types'] == ['host_unreachable'] and data['page_size'] == 100
def test_filters_are_passed_through(self, client):
db = self._db()
client.get('/api/events/history?range=90d&type=interface_down&target=+large1+&severity=critical'
'&status=resolved&offset=200', headers=AUTH)
assert db.get_events_history.call_args.args == (2160, 'interface_down', 'large1', 'critical', 'resolved')
assert db.get_events_history.call_args.kwargs == {'limit': 100, 'offset': 200}
assert db.get_flappers.call_args.args == (2160, 'interface_down', 'large1', 'critical')
@pytest.mark.parametrize('query', ['range=1y', 'severity=bad', 'status=open', 'offset=x'])
def test_bad_filters_are_rejected(self, client, query):
self._db()
assert client.get(f'/api/events/history?{query}', headers=AUTH).status_code == 400
def test_needs_login(self, client):
assert client.get('/api/events/history').status_code == 401
class TestHealth:
def _health(self, client, age_s, poll=120):
from datetime import datetime, timedelta, timezone
stamp = (datetime.now(timezone.utc) - timedelta(seconds=age_s)).strftime('%Y-%m-%d %H:%M:%S UTC')
gandalf.db.get_state.side_effect = lambda key, default=None: stamp if key == 'last_check' else default
with patch.object(gandalf, '_cfg', dict(CONFIG, monitor={'poll_interval': poll})):
return client.get('/health')
def test_recent_check_is_healthy(self, client):
resp = self._health(client, 150)
assert resp.status_code == 200 and resp.get_json()['checks']['monitor'] == 'ok'
def test_stalled_monitor_fails_after_ten_minutes(self, client):
resp = self._health(client, 660)
assert resp.status_code == 503
assert resp.get_json()['checks'] == {'db': 'ok', 'monitor': 'stale', 'monitor_age_s': 660}
def test_limit_follows_a_long_poll_interval(self, client):
assert self._health(client, 1000, poll=300).status_code == 200
assert self._health(client, 1600, poll=300).status_code == 503
+4
View File
@@ -49,6 +49,10 @@ def device_url(base: str, name: str, port_idx=None) -> str:
return f'{url}#port-{port_idx}' if port_idx not in (None, '') else url
def history_url(base: str, target: str) -> str:
return f'{base}/events?target={quote(target, safe="")}&range=90d' if base and target else ''
def cutsheet_url(base: str, cable_id) -> str:
return f'{base}/cutsheet#cable-{cable_id}' if base else ''