From e2b65db2fce632d79b92f74d0d6706e50497a280 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 17 Mar 2026 20:32:32 -0400 Subject: [PATCH] Add pagination to event queries, input validation, daily event purge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - get_active_events() now takes limit/offset (default 200) to cap unbounded queries - count_active_events() added to return total for pagination display - /api/events supports ?limit=, ?offset=, ?status= query params (max 1000) - /api/status includes total_active count alongside paginated events list - index() route passes total_active to template for server-side truncation notice - Show "Showing X of Y" notice in dashboard when events are truncated - Suppression POST validates: reason ≤500 chars, target_name/detail ≤255 chars - _purge_old_jobs_loop runs purge_old_resolved_events(90d) once per day Co-Authored-By: Claude Sonnet 4.6 --- app.py | 51 ++++++++++++++++++++++++++++++++++++++------ db.py | 16 ++++++++++++-- static/app.js | 10 +++++++-- static/style.css | 14 ++++++++++++ templates/index.html | 3 +++ 5 files changed, 83 insertions(+), 11 deletions(-) diff --git a/app.py b/app.py index d52f600..3d93f0c 100644 --- a/app.py +++ b/app.py @@ -47,8 +47,11 @@ _diag_jobs: dict = {} _diag_lock = threading.Lock() +_last_event_purge = [0.0] # mutable container so the thread can update it + + def _purge_old_jobs_loop(): - """Background thread: remove jobs older than 10 minutes and mark stuck running jobs as errored.""" + """Background thread: remove stale diag jobs and run daily event purge.""" while True: time.sleep(120) cutoff = time.time() - 600 @@ -63,6 +66,15 @@ def _purge_old_jobs_loop(): j['result'] = {'status': 'error', 'error': 'Diagnostic timed out (thread crash)'} logger.error(f'Diagnostic job {jid} appeared stuck; marked as errored') + # Purge old resolved events once per day + now = time.time() + if now - _last_event_purge[0] > 86400: + try: + db.purge_old_resolved_events(days=90) + except Exception as e: + logger.error(f'Daily event purge failed: {e}') + _last_event_purge[0] = now + _purge_thread = threading.Thread(target=_purge_old_jobs_loop, daemon=True) _purge_thread.start() @@ -120,11 +132,15 @@ def require_auth(f): # Page routes # --------------------------------------------------------------------------- +_PAGE_LIMIT = 200 # max events returned per request + + @app.route('/') @require_auth def index(): user = _get_user() - events = db.get_active_events() + events = db.get_active_events(limit=_PAGE_LIMIT) + total_active = db.count_active_events() summary = db.get_status_summary() snapshot_raw = db.get_state('network_snapshot') last_check = db.get_state('last_check', 'Never') @@ -135,6 +151,7 @@ def index(): 'index.html', user=user, events=events, + total_active=total_active, summary=summary, snapshot=snapshot, last_check=last_check, @@ -181,10 +198,12 @@ def suppressions_page(): @app.route('/api/status') @require_auth def api_status(): + active = db.get_active_events(limit=_PAGE_LIMIT) return jsonify({ 'summary': db.get_status_summary(), 'last_check': db.get_state('last_check', 'Never'), - 'events': db.get_active_events(), + 'events': active, + 'total_active': db.count_active_events(), }) @@ -215,10 +234,22 @@ def api_links(): @app.route('/api/events') @require_auth def api_events(): - return jsonify({ - 'active': db.get_active_events(), - 'resolved': db.get_recent_resolved(hours=24, limit=30), - }) + try: + limit = min(int(request.args.get('limit', _PAGE_LIMIT)), 1000) + offset = max(int(request.args.get('offset', 0)), 0) + except ValueError: + return jsonify({'error': 'limit and offset must be integers'}), 400 + status_filter = request.args.get('status', 'active') + if status_filter not in ('active', 'resolved', 'all'): + return jsonify({'error': 'status must be active, resolved, or all'}), 400 + + result: dict = {} + if status_filter in ('active', 'all'): + result['active'] = db.get_active_events(limit=limit, offset=offset) + result['total_active'] = db.count_active_events() + if status_filter in ('resolved', 'all'): + result['resolved'] = db.get_recent_resolved(hours=24, limit=30) + return jsonify(result) @app.route('/api/suppressions', methods=['GET']) @@ -245,6 +276,12 @@ def api_create_suppression(): return jsonify({'error': 'target_name required'}), 400 if not reason: return jsonify({'error': 'reason required'}), 400 + if len(reason) > 500: + return jsonify({'error': 'reason must be 500 characters or fewer'}), 400 + if len(target_name) > 255: + return jsonify({'error': 'target_name must be 255 characters or fewer'}), 400 + if len(target_detail) > 255: + return jsonify({'error': 'target_detail must be 255 characters or fewer'}), 400 sup_id = db.create_suppression( target_type=target_type, diff --git a/db.py b/db.py index d814e26..d0d3ba3 100644 --- a/db.py +++ b/db.py @@ -153,7 +153,7 @@ def set_ticket_id(event_id: int, ticket_id: str) -> None: ) -def get_active_events() -> list: +def get_active_events(limit: int = 200, offset: int = 0) -> list: with get_conn() as conn: with conn.cursor() as cur: cur.execute( @@ -161,7 +161,9 @@ def get_active_events() -> list: WHERE resolved_at IS NULL ORDER BY FIELD(severity,'critical','warning','info'), - first_seen DESC""" + first_seen DESC + LIMIT %s OFFSET %s""", + (limit, offset), ) rows = cur.fetchall() for r in rows: @@ -171,6 +173,16 @@ def get_active_events() -> list: return rows +def count_active_events() -> int: + """Return count of all unresolved events (for pagination).""" + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT COUNT(*) AS n FROM network_events WHERE resolved_at IS NULL" + ) + return cur.fetchone()['n'] + + def get_recent_resolved(hours: int = 24, limit: int = 50) -> list: with get_conn() as conn: with conn.cursor() as cur: diff --git a/static/app.js b/static/app.js index 38cc979..d964102 100644 --- a/static/app.js +++ b/static/app.js @@ -22,7 +22,7 @@ async function refreshAll() { updateHostGrid(net.hosts || {}); updateUnifiTable(net.unifi || []); - updateEventsTable(status.events || []); + updateEventsTable(status.events || [], status.total_active); updateStatusBar(status.summary || {}, status.last_check || ''); updateTopology(net.hosts || {}); @@ -147,7 +147,7 @@ function updateUnifiTable(devices) { }).join(''); } -function updateEventsTable(events) { +function updateEventsTable(events, totalActive) { const wrap = document.getElementById('events-table-wrap'); if (!wrap) return; @@ -157,6 +157,11 @@ function updateEventsTable(events) { return; } + const truncated = totalActive != null && totalActive > active.length; + const countNotice = truncated + ? `
Showing ${active.length} of ${totalActive} active alerts — view all via API
` + : ''; + const rows = active.map(e => { const supType = e.event_type === 'unifi_device_offline' ? 'unifi_device' : e.event_type === 'interface_down' ? 'interface' @@ -188,6 +193,7 @@ function updateEventsTable(events) { }).join(''); wrap.innerHTML = ` + ${countNotice}
diff --git a/static/style.css b/static/style.css index deb2a5c..87f582a 100644 --- a/static/style.css +++ b/static/style.css @@ -2201,3 +2201,17 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); } padding: 2px 7px; border-radius: 10px; } + +/* ── Pagination notice ────────────────────────────────────────────── */ +.pagination-notice { + font-size: .8em; + color: var(--text-muted); + padding: 6px 0 8px; +} +.pagination-notice a { + color: var(--accent); + text-decoration: none; +} +.pagination-notice a:hover { + text-decoration: underline; +} diff --git a/templates/index.html b/templates/index.html index 81b3fd9..9b6c619 100644 --- a/templates/index.html +++ b/templates/index.html @@ -277,6 +277,9 @@
{% if events %} + {% if total_active is defined and total_active > events|length %} +
Showing {{ events|length }} of {{ total_active }} active alerts — view all via API
+ {% endif %}