feat: live последние события на Dashboard; retention, health, runbook

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-28 11:29:31 +10:00
parent 9ed0670863
commit 108e1756c4
14 changed files with 625 additions and 249 deletions
+38 -25
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.orm import Session, joinedload
@@ -15,6 +15,33 @@ from app.services.host_health import DAILY_REPORT_SSH, HEARTBEAT_TYPE, count_sta
router = APIRouter(prefix="/dashboards", tags=["dashboards"])
def fetch_recent_events(db: Session, *, limit: int = 8) -> list[EventSummary]:
"""Последние события по времени приёма ingest (received_at)."""
rows = db.scalars(
select(Event)
.join(Host)
.options(joinedload(Event.host))
.order_by(Event.received_at.desc())
.limit(limit)
).all()
return [
EventSummary(
id=e.id,
event_id=e.event_id,
host_id=e.host_id,
hostname=e.host.hostname,
occurred_at=e.occurred_at,
received_at=e.received_at,
category=e.category,
type=e.type,
severity=e.severity,
title=e.title,
summary=e.summary,
)
for e in rows
]
class TopHostItem(BaseModel):
host_id: int
hostname: str
@@ -109,30 +136,7 @@ def dashboard_summary(
).all()
severity_24h = {row[0]: row[1] for row in severity_rows}
recent = db.scalars(
select(Event)
.join(Host)
.options(joinedload(Event.host))
.order_by(Event.received_at.desc())
.limit(8)
).all()
recent_events = [
EventSummary(
id=e.id,
event_id=e.event_id,
host_id=e.host_id,
hostname=e.host.hostname,
occurred_at=e.occurred_at,
received_at=e.received_at,
category=e.category,
type=e.type,
severity=e.severity,
title=e.title,
summary=e.summary,
)
for e in recent
]
recent_events = fetch_recent_events(db, limit=8)
return DashboardSummary(
events_last_24h=events_24h,
@@ -148,3 +152,12 @@ def dashboard_summary(
top_event_types=top_event_types,
recent_events=recent_events,
)
@router.get("/recent-events", response_model=list[EventSummary])
def dashboard_recent_events(
limit: int = Query(8, ge=1, le=50),
db: Session = Depends(get_db),
_user: str = Depends(get_current_user),
) -> list[EventSummary]:
return fetch_recent_events(db, limit=limit)
+21 -2
View File
@@ -1,8 +1,11 @@
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy import func, select, text
from sqlalchemy.orm import Session
from app.config import get_settings
from app.database import get_db
from app.models import Event
from app.services.host_health import count_stale_hosts
from app.version import APP_NAME, APP_VERSION
router = APIRouter(tags=["health"])
@@ -14,12 +17,28 @@ def build_health_payload(db: Session) -> dict:
db.execute(text("SELECT 1"))
except Exception:
db_status = "error"
hosts_stale = 0
last_event_received_at: str | None = None
if db_status == "ok":
settings = get_settings()
hosts_stale = count_stale_hosts(db, settings.sac_heartbeat_stale_minutes)
last_received = db.scalar(select(func.max(Event.received_at)))
if last_received is not None:
last_event_received_at = last_received.isoformat()
overall = "ok" if db_status == "ok" else "degraded"
if db_status == "ok" and hosts_stale > 0:
overall = "degraded"
return {
"status": "ok" if db_status == "ok" else "degraded",
"status": overall,
"database": db_status,
"service": "security-alert-center",
"name": APP_NAME,
"version": APP_VERSION,
"hosts_stale": hosts_stale,
"last_event_received_at": last_event_received_at,
}
+4
View File
@@ -64,6 +64,10 @@ class Settings(BaseSettings):
sac_privilege_spike_window_minutes: int = 10
sac_privilege_spike_threshold: int = 10
# Retention (app.jobs.retention / systemd timer)
sac_events_retention_days: int = 90
sac_problems_retention_days: int = 180
@lru_cache
def get_settings() -> Settings:
+1
View File
@@ -0,0 +1 @@
"""Фоновые задачи SAC (retention и др.)."""
+36
View File
@@ -0,0 +1,36 @@
"""CLI: python -m app.jobs.retention"""
from __future__ import annotations
import logging
import sys
from app.config import get_settings
from app.database import SessionLocal
from app.services.retention import purge_old_data
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger("sac.retention")
def main() -> int:
settings = get_settings()
db = SessionLocal()
try:
stats = purge_old_data(db, settings)
logger.info(
"retention done events_deleted=%s problems_deleted=%s",
stats["events_deleted"],
stats["problems_deleted"],
)
return 0
except Exception:
db.rollback()
logger.exception("retention failed")
return 1
finally:
db.close()
if __name__ == "__main__":
sys.exit(main())
+39
View File
@@ -0,0 +1,39 @@
"""Удаление устаревших events и resolved problems."""
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete
from sqlalchemy.orm import Session
from app.config import Settings
from app.models import Event, Problem
def purge_old_data(db: Session, settings: Settings) -> dict:
"""
Events: received_at старше sac_events_retention_days.
Problems: status=resolved и updated_at старше sac_problems_retention_days.
"""
now = datetime.now(timezone.utc)
events_cutoff = now - timedelta(days=settings.sac_events_retention_days)
problems_cutoff = now - timedelta(days=settings.sac_problems_retention_days)
events_result = db.execute(delete(Event).where(Event.received_at < events_cutoff))
events_deleted = events_result.rowcount or 0
problems_result = db.execute(
delete(Problem).where(
Problem.status == "resolved",
Problem.updated_at < problems_cutoff,
)
)
problems_deleted = problems_result.rowcount or 0
db.commit()
return {
"events_deleted": events_deleted,
"problems_deleted": problems_deleted,
"events_cutoff": events_cutoff.isoformat(),
"problems_cutoff": problems_cutoff.isoformat(),
}