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:
51
app.py
51
app.py
@@ -47,8 +47,11 @@ _diag_jobs: dict = {}
|
|||||||
_diag_lock = threading.Lock()
|
_diag_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
_last_event_purge = [0.0] # mutable container so the thread can update it
|
||||||
|
|
||||||
|
|
||||||
def _purge_old_jobs_loop():
|
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:
|
while True:
|
||||||
time.sleep(120)
|
time.sleep(120)
|
||||||
cutoff = time.time() - 600
|
cutoff = time.time() - 600
|
||||||
@@ -63,6 +66,15 @@ def _purge_old_jobs_loop():
|
|||||||
j['result'] = {'status': 'error', 'error': 'Diagnostic timed out (thread crash)'}
|
j['result'] = {'status': 'error', 'error': 'Diagnostic timed out (thread crash)'}
|
||||||
logger.error(f'Diagnostic job {jid} appeared stuck; marked as errored')
|
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 = threading.Thread(target=_purge_old_jobs_loop, daemon=True)
|
||||||
_purge_thread.start()
|
_purge_thread.start()
|
||||||
@@ -120,11 +132,15 @@ def require_auth(f):
|
|||||||
# Page routes
|
# Page routes
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_PAGE_LIMIT = 200 # max events returned per request
|
||||||
|
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
@require_auth
|
@require_auth
|
||||||
def index():
|
def index():
|
||||||
user = _get_user()
|
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()
|
summary = db.get_status_summary()
|
||||||
snapshot_raw = db.get_state('network_snapshot')
|
snapshot_raw = db.get_state('network_snapshot')
|
||||||
last_check = db.get_state('last_check', 'Never')
|
last_check = db.get_state('last_check', 'Never')
|
||||||
@@ -135,6 +151,7 @@ def index():
|
|||||||
'index.html',
|
'index.html',
|
||||||
user=user,
|
user=user,
|
||||||
events=events,
|
events=events,
|
||||||
|
total_active=total_active,
|
||||||
summary=summary,
|
summary=summary,
|
||||||
snapshot=snapshot,
|
snapshot=snapshot,
|
||||||
last_check=last_check,
|
last_check=last_check,
|
||||||
@@ -181,10 +198,12 @@ def suppressions_page():
|
|||||||
@app.route('/api/status')
|
@app.route('/api/status')
|
||||||
@require_auth
|
@require_auth
|
||||||
def api_status():
|
def api_status():
|
||||||
|
active = db.get_active_events(limit=_PAGE_LIMIT)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'summary': db.get_status_summary(),
|
'summary': db.get_status_summary(),
|
||||||
'last_check': db.get_state('last_check', 'Never'),
|
'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')
|
@app.route('/api/events')
|
||||||
@require_auth
|
@require_auth
|
||||||
def api_events():
|
def api_events():
|
||||||
return jsonify({
|
try:
|
||||||
'active': db.get_active_events(),
|
limit = min(int(request.args.get('limit', _PAGE_LIMIT)), 1000)
|
||||||
'resolved': db.get_recent_resolved(hours=24, limit=30),
|
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'])
|
@app.route('/api/suppressions', methods=['GET'])
|
||||||
@@ -245,6 +276,12 @@ def api_create_suppression():
|
|||||||
return jsonify({'error': 'target_name required'}), 400
|
return jsonify({'error': 'target_name required'}), 400
|
||||||
if not reason:
|
if not reason:
|
||||||
return jsonify({'error': 'reason required'}), 400
|
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(
|
sup_id = db.create_suppression(
|
||||||
target_type=target_type,
|
target_type=target_type,
|
||||||
|
|||||||
16
db.py
16
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 get_conn() as conn:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
@@ -161,7 +161,9 @@ def get_active_events() -> list:
|
|||||||
WHERE resolved_at IS NULL
|
WHERE resolved_at IS NULL
|
||||||
ORDER BY
|
ORDER BY
|
||||||
FIELD(severity,'critical','warning','info'),
|
FIELD(severity,'critical','warning','info'),
|
||||||
first_seen DESC"""
|
first_seen DESC
|
||||||
|
LIMIT %s OFFSET %s""",
|
||||||
|
(limit, offset),
|
||||||
)
|
)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
for r in rows:
|
for r in rows:
|
||||||
@@ -171,6 +173,16 @@ def get_active_events() -> list:
|
|||||||
return rows
|
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:
|
def get_recent_resolved(hours: int = 24, limit: int = 50) -> list:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ async function refreshAll() {
|
|||||||
|
|
||||||
updateHostGrid(net.hosts || {});
|
updateHostGrid(net.hosts || {});
|
||||||
updateUnifiTable(net.unifi || []);
|
updateUnifiTable(net.unifi || []);
|
||||||
updateEventsTable(status.events || []);
|
updateEventsTable(status.events || [], status.total_active);
|
||||||
updateStatusBar(status.summary || {}, status.last_check || '');
|
updateStatusBar(status.summary || {}, status.last_check || '');
|
||||||
updateTopology(net.hosts || {});
|
updateTopology(net.hosts || {});
|
||||||
|
|
||||||
@@ -147,7 +147,7 @@ function updateUnifiTable(devices) {
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateEventsTable(events) {
|
function updateEventsTable(events, totalActive) {
|
||||||
const wrap = document.getElementById('events-table-wrap');
|
const wrap = document.getElementById('events-table-wrap');
|
||||||
if (!wrap) return;
|
if (!wrap) return;
|
||||||
|
|
||||||
@@ -157,6 +157,11 @@ function updateEventsTable(events) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const truncated = totalActive != null && totalActive > active.length;
|
||||||
|
const countNotice = truncated
|
||||||
|
? `<div class="pagination-notice">Showing ${active.length} of ${totalActive} active alerts — <a href="/api/events?limit=1000">view all via API</a></div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
const rows = active.map(e => {
|
const rows = active.map(e => {
|
||||||
const supType = e.event_type === 'unifi_device_offline' ? 'unifi_device'
|
const supType = e.event_type === 'unifi_device_offline' ? 'unifi_device'
|
||||||
: e.event_type === 'interface_down' ? 'interface'
|
: e.event_type === 'interface_down' ? 'interface'
|
||||||
@@ -188,6 +193,7 @@ function updateEventsTable(events) {
|
|||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
wrap.innerHTML = `
|
wrap.innerHTML = `
|
||||||
|
${countNotice}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="data-table" id="events-table">
|
<table class="data-table" id="events-table">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -2201,3 +2201,17 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
|
|||||||
padding: 2px 7px;
|
padding: 2px 7px;
|
||||||
border-radius: 10px;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -277,6 +277,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="events-table-wrap">
|
<div id="events-table-wrap">
|
||||||
{% if events %}
|
{% if events %}
|
||||||
|
{% if total_active is defined and total_active > events|length %}
|
||||||
|
<div class="pagination-notice">Showing {{ events|length }} of {{ total_active }} active alerts — <a href="/api/events?limit=1000">view all via API</a></div>
|
||||||
|
{% endif %}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="data-table" id="events-table">
|
<table class="data-table" id="events-table">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
Reference in New Issue
Block a user