Add an alert history page and tie /health staleness to the poll interval
Lint / JS (eslint) (push) Successful in 6s
Lint / Python (flake8) (push) Successful in 1m56s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 2m3s
Test / Python Tests (pytest) (push) Successful in 1m0s
Lint / Python (flake8) (pull_request) Successful in 48s
Lint / JS (eslint) (pull_request) Successful in 6s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 2m7s
Test / Python Tests (pytest) (pull_request) Successful in 2m6s
Lint / JS (eslint) (push) Successful in 6s
Lint / Python (flake8) (push) Successful in 1m56s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 2m3s
Test / Python Tests (pytest) (push) Successful in 1m0s
Lint / Python (flake8) (pull_request) Successful in 48s
Lint / JS (eslint) (pull_request) Successful in 6s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 2m7s
Test / Python Tests (pytest) (pull_request) Successful in 2m6s
/events lists every alert from the last 24 hours to 90 days, open or cleared, with how long each lasted, failed checks and its ticket. Filters (device, type, severity, state, range) live in the URL, so device pages and tickets link straight to a device's history. A "Recurring" table lists alerts that fired more than once on the same target. On phones the rows become cards. /api/events/history backs it (100 per page). The existing /api/events is unchanged. /health and the dashboard's monitor indicator now call the monitor stalled after five missed polls (at least 10 minutes) instead of a fixed 20, and /health reports the monitor's age. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user