feat: live последние события на Dashboard; retention, health, runbook
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Фоновые задачи SAC (retention и др.)."""
|
||||
@@ -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())
|
||||
@@ -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(),
|
||||
}
|
||||
Reference in New Issue
Block a user