feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
164
backend/app/routers/events.py
Normal file
164
backend/app/routers/events.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""Unified events timeline API endpoint.
|
||||
|
||||
Provides a single GET endpoint that unions alert events, device status changes,
|
||||
and config backup runs into a unified timeline for the dashboard.
|
||||
|
||||
RLS enforced via get_db() (app_user engine with tenant context).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db, set_tenant_context
|
||||
from app.middleware.tenant_context import CurrentUser, get_current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["events"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _check_tenant_access(
|
||||
current_user: CurrentUser, tenant_id: uuid.UUID, db: AsyncSession
|
||||
) -> None:
|
||||
"""Verify the current user is allowed to access the given tenant."""
|
||||
if current_user.is_super_admin:
|
||||
await set_tenant_context(db, str(tenant_id))
|
||||
elif current_user.tenant_id != tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this tenant",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unified events endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tenants/{tenant_id}/events",
|
||||
summary="List unified events (alerts, status changes, config backups)",
|
||||
)
|
||||
async def list_events(
|
||||
tenant_id: uuid.UUID,
|
||||
limit: int = Query(50, ge=1, le=200, description="Max events to return"),
|
||||
event_type: Optional[str] = Query(
|
||||
None,
|
||||
description="Filter by event type: alert, status_change, config_backup",
|
||||
),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return a unified list of recent events across alerts, device status, and config backups.
|
||||
|
||||
Events are ordered by timestamp descending, limited to `limit` (default 50).
|
||||
RLS automatically filters to the tenant's data via the app_user session.
|
||||
"""
|
||||
await _check_tenant_access(current_user, tenant_id, db)
|
||||
|
||||
if event_type and event_type not in ("alert", "status_change", "config_backup"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="event_type must be one of: alert, status_change, config_backup",
|
||||
)
|
||||
|
||||
events: list[dict[str, Any]] = []
|
||||
|
||||
# 1. Alert events
|
||||
if not event_type or event_type == "alert":
|
||||
alert_result = await db.execute(
|
||||
text("""
|
||||
SELECT ae.id, ae.status, ae.severity, ae.metric, ae.message,
|
||||
ae.fired_at, ae.device_id, d.hostname
|
||||
FROM alert_events ae
|
||||
LEFT JOIN devices d ON d.id = ae.device_id
|
||||
ORDER BY ae.fired_at DESC
|
||||
LIMIT :limit
|
||||
"""),
|
||||
{"limit": limit},
|
||||
)
|
||||
for row in alert_result.fetchall():
|
||||
alert_status = row[1] or "firing"
|
||||
metric = row[3] or "unknown"
|
||||
events.append({
|
||||
"id": str(row[0]),
|
||||
"event_type": "alert",
|
||||
"severity": row[2],
|
||||
"title": f"{alert_status}: {metric}",
|
||||
"description": row[4] or f"Alert {alert_status} for {metric}",
|
||||
"device_hostname": row[7],
|
||||
"device_id": str(row[6]) if row[6] else None,
|
||||
"timestamp": row[5].isoformat() if row[5] else None,
|
||||
})
|
||||
|
||||
# 2. Device status changes (inferred from current status + last_seen)
|
||||
if not event_type or event_type == "status_change":
|
||||
status_result = await db.execute(
|
||||
text("""
|
||||
SELECT d.id, d.hostname, d.status, d.last_seen
|
||||
FROM devices d
|
||||
WHERE d.last_seen IS NOT NULL
|
||||
ORDER BY d.last_seen DESC
|
||||
LIMIT :limit
|
||||
"""),
|
||||
{"limit": limit},
|
||||
)
|
||||
for row in status_result.fetchall():
|
||||
device_status = row[2] or "unknown"
|
||||
hostname = row[1] or "Unknown device"
|
||||
severity = "info" if device_status == "online" else "warning"
|
||||
events.append({
|
||||
"id": f"status-{row[0]}",
|
||||
"event_type": "status_change",
|
||||
"severity": severity,
|
||||
"title": f"Device {device_status}",
|
||||
"description": f"{hostname} is now {device_status}",
|
||||
"device_hostname": hostname,
|
||||
"device_id": str(row[0]),
|
||||
"timestamp": row[3].isoformat() if row[3] else None,
|
||||
})
|
||||
|
||||
# 3. Config backup runs
|
||||
if not event_type or event_type == "config_backup":
|
||||
backup_result = await db.execute(
|
||||
text("""
|
||||
SELECT cbr.id, cbr.trigger_type, cbr.created_at,
|
||||
cbr.device_id, d.hostname
|
||||
FROM config_backup_runs cbr
|
||||
LEFT JOIN devices d ON d.id = cbr.device_id
|
||||
ORDER BY cbr.created_at DESC
|
||||
LIMIT :limit
|
||||
"""),
|
||||
{"limit": limit},
|
||||
)
|
||||
for row in backup_result.fetchall():
|
||||
trigger_type = row[1] or "manual"
|
||||
hostname = row[4] or "Unknown device"
|
||||
events.append({
|
||||
"id": str(row[0]),
|
||||
"event_type": "config_backup",
|
||||
"severity": "info",
|
||||
"title": "Config backup",
|
||||
"description": f"{trigger_type} backup completed for {hostname}",
|
||||
"device_hostname": hostname,
|
||||
"device_id": str(row[3]) if row[3] else None,
|
||||
"timestamp": row[2].isoformat() if row[2] else None,
|
||||
})
|
||||
|
||||
# Sort all events by timestamp descending, then apply final limit
|
||||
events.sort(
|
||||
key=lambda e: e["timestamp"] or "",
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
return events[:limit]
|
||||
Reference in New Issue
Block a user