Add pagination to event queries, input validation, daily event purge

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-17 20:32:32 -04:00
parent b80fda7cb2
commit e2b65db2fc
5 changed files with 83 additions and 11 deletions

51
app.py
View File

@@ -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,

16
db.py
View File

@@ -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:

View File

@@ -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
? `<div class="pagination-notice">Showing ${active.length} of ${totalActive} active alerts &mdash; <a href="/api/events?limit=1000">view all via API</a></div>`
: '';
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}
<div class="table-wrap">
<table class="data-table" id="events-table">
<thead>

View File

@@ -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;
}

View File

@@ -277,6 +277,9 @@
</div>
<div id="events-table-wrap">
{% if events %}
{% if total_active is defined and total_active > events|length %}
<div class="pagination-notice">Showing {{ events|length }} of {{ total_active }} active alerts &mdash; <a href="/api/events?limit=1000">view all via API</a></div>
{% endif %}
<div class="table-wrap">
<table class="data-table" id="events-table">
<thead>