feat: SAC 0.7.0 display_name columns and event severity overrides
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -10,6 +10,7 @@ from app.config import get_settings
|
||||
from app.database import get_db
|
||||
from app.models import Event, Host, Problem
|
||||
from app.schemas.list_models import EventSummary
|
||||
from app.services.event_summary import event_to_summary
|
||||
from app.services.host_health import DAILY_REPORT_TYPES, HEARTBEAT_TYPE, count_stale_hosts
|
||||
|
||||
router = APIRouter(prefix="/dashboards", tags=["dashboards"])
|
||||
@@ -24,27 +25,13 @@ def fetch_recent_events(db: Session, *, limit: int = 8) -> list[EventSummary]:
|
||||
.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
|
||||
]
|
||||
return [event_to_summary(e) for e in rows]
|
||||
|
||||
|
||||
class TopHostItem(BaseModel):
|
||||
host_id: int
|
||||
hostname: str
|
||||
display_name: str | None = None
|
||||
count: int
|
||||
|
||||
|
||||
@@ -108,16 +95,16 @@ def dashboard_summary(
|
||||
)
|
||||
|
||||
top_host_rows = db.execute(
|
||||
select(Host.id, Host.hostname, func.count())
|
||||
select(Host.id, Host.hostname, Host.display_name, func.count())
|
||||
.select_from(Event)
|
||||
.join(Host, Event.host_id == Host.id)
|
||||
.where(Event.received_at >= since)
|
||||
.group_by(Host.id, Host.hostname)
|
||||
.group_by(Host.id, Host.hostname, Host.display_name)
|
||||
.order_by(func.count().desc())
|
||||
.limit(10)
|
||||
).all()
|
||||
top_hosts = [
|
||||
TopHostItem(host_id=row[0], hostname=row[1], count=row[2]) for row in top_host_rows
|
||||
TopHostItem(host_id=row[0], hostname=row[1], display_name=row[2], count=row[3]) for row in top_host_rows
|
||||
]
|
||||
|
||||
top_type_rows = db.execute(
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.database import get_db
|
||||
from app.models import Event, Host
|
||||
from app.schemas.list_models import EventDetail, EventListResponse, EventSummary
|
||||
from app.services.ingest import ingest_event
|
||||
from app.services.event_summary import event_to_summary
|
||||
from app.services.problems import maybe_create_problem
|
||||
from app.services.schema_validate import validate_event_payload
|
||||
from app.services.notify_dispatch import (
|
||||
@@ -133,8 +134,8 @@ def list_events(
|
||||
count_stmt = count_stmt.where(Event.host_id == host_id)
|
||||
if hostname:
|
||||
like = f"%{hostname}%"
|
||||
stmt = stmt.where(Host.hostname.ilike(like))
|
||||
count_stmt = count_stmt.where(Host.hostname.ilike(like))
|
||||
stmt = stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like))
|
||||
count_stmt = count_stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like))
|
||||
dt_from = _parse_optional_dt(from_time)
|
||||
dt_to = _parse_optional_dt(to_time)
|
||||
if dt_from:
|
||||
@@ -155,22 +156,7 @@ def list_events(
|
||||
.limit(page_size)
|
||||
).all()
|
||||
|
||||
items = [
|
||||
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
|
||||
]
|
||||
items = [event_to_summary(e) for e in rows]
|
||||
return EventListResponse(items=items, total=total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
@@ -190,6 +176,7 @@ def get_event(
|
||||
event_id=event.event_id,
|
||||
host_id=event.host_id,
|
||||
hostname=event.host.hostname,
|
||||
display_name=event.host.display_name,
|
||||
occurred_at=event.occurred_at,
|
||||
received_at=event.received_at,
|
||||
category=event.category,
|
||||
|
||||
+351
-174
@@ -1,174 +1,351 @@
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
|
||||
|
||||
from app.auth.jwt_auth import get_current_user
|
||||
|
||||
from app.database import get_db
|
||||
|
||||
from app.models import Event, Host, Problem, ProblemEvent
|
||||
|
||||
|
||||
|
||||
router = APIRouter(prefix="/problems", tags=["problems"])
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class ProblemSummary(BaseModel):
|
||||
|
||||
id: int
|
||||
|
||||
host_id: int | None
|
||||
|
||||
hostname: str | None
|
||||
|
||||
title: str
|
||||
|
||||
summary: str
|
||||
|
||||
severity: str
|
||||
|
||||
status: str
|
||||
|
||||
rule_id: str | None
|
||||
|
||||
fingerprint: str
|
||||
|
||||
event_count: int
|
||||
|
||||
last_seen_at: datetime
|
||||
|
||||
created_at: datetime
|
||||
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class ProblemListResponse(BaseModel):
|
||||
|
||||
items: list[ProblemSummary]
|
||||
|
||||
total: int
|
||||
|
||||
page: int
|
||||
|
||||
page_size: int
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class ProblemEventItem(BaseModel):
|
||||
|
||||
id: int
|
||||
|
||||
event_id: str
|
||||
|
||||
occurred_at: datetime
|
||||
|
||||
type: str
|
||||
|
||||
severity: str
|
||||
|
||||
title: str
|
||||
|
||||
summary: str
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class ProblemDetail(ProblemSummary):
|
||||
|
||||
events: list[ProblemEventItem]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _problem_summary(p: Problem) -> ProblemSummary:
|
||||
|
||||
return ProblemSummary(
|
||||
|
||||
id=p.id,
|
||||
|
||||
host_id=p.host_id,
|
||||
|
||||
hostname=p.host.hostname if p.host else None,
|
||||
|
||||
title=p.title,
|
||||
|
||||
summary=p.summary,
|
||||
|
||||
severity=p.severity,
|
||||
|
||||
status=p.status,
|
||||
|
||||
rule_id=p.rule_id,
|
||||
|
||||
fingerprint=p.fingerprint,
|
||||
|
||||
event_count=p.event_count,
|
||||
|
||||
last_seen_at=p.last_seen_at,
|
||||
|
||||
created_at=p.created_at,
|
||||
|
||||
updated_at=p.updated_at,
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("", response_model=ProblemListResponse)
|
||||
|
||||
def list_problems(
|
||||
|
||||
page: int = Query(1, ge=1),
|
||||
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
|
||||
status: str | None = Query(None),
|
||||
|
||||
severity: str | None = Query(None),
|
||||
|
||||
host_id: int | None = Query(None),
|
||||
|
||||
hostname: str | None = Query(None),
|
||||
|
||||
db: Session = Depends(get_db),
|
||||
|
||||
_user: str = Depends(get_current_user),
|
||||
|
||||
) -> ProblemListResponse:
|
||||
|
||||
stmt = select(Problem).join(Host, isouter=True).options(joinedload(Problem.host))
|
||||
|
||||
count_stmt = select(func.count()).select_from(Problem).join(Host, isouter=True)
|
||||
|
||||
|
||||
|
||||
if status:
|
||||
|
||||
stmt = stmt.where(Problem.status == status)
|
||||
|
||||
count_stmt = count_stmt.where(Problem.status == status)
|
||||
|
||||
if severity:
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
|
||||
|
||||
from app.auth.jwt_auth import get_current_user
|
||||
|
||||
from app.database import get_db
|
||||
|
||||
from app.models import Event, Host, Problem, ProblemEvent
|
||||
|
||||
|
||||
|
||||
router = APIRouter(prefix="/problems", tags=["problems"])
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class ProblemSummary(BaseModel):
|
||||
|
||||
id: int
|
||||
|
||||
host_id: int | None
|
||||
|
||||
hostname: str | None
|
||||
|
||||
display_name: str | None = None
|
||||
|
||||
title: str
|
||||
|
||||
summary: str
|
||||
|
||||
severity: str
|
||||
|
||||
status: str
|
||||
|
||||
rule_id: str | None
|
||||
|
||||
fingerprint: str
|
||||
|
||||
event_count: int
|
||||
|
||||
last_seen_at: datetime
|
||||
|
||||
created_at: datetime
|
||||
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class ProblemListResponse(BaseModel):
|
||||
|
||||
items: list[ProblemSummary]
|
||||
|
||||
total: int
|
||||
|
||||
page: int
|
||||
|
||||
page_size: int
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class ProblemEventItem(BaseModel):
|
||||
|
||||
id: int
|
||||
|
||||
event_id: str
|
||||
|
||||
occurred_at: datetime
|
||||
|
||||
type: str
|
||||
|
||||
severity: str
|
||||
|
||||
title: str
|
||||
|
||||
summary: str
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class ProblemDetail(ProblemSummary):
|
||||
|
||||
events: list[ProblemEventItem]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _problem_summary(p: Problem) -> ProblemSummary:
|
||||
|
||||
return ProblemSummary(
|
||||
|
||||
id=p.id,
|
||||
|
||||
host_id=p.host_id,
|
||||
|
||||
hostname=p.host.hostname if p.host else None,
|
||||
|
||||
display_name=p.host.display_name if p.host else None,
|
||||
|
||||
title=p.title,
|
||||
|
||||
summary=p.summary,
|
||||
|
||||
severity=p.severity,
|
||||
|
||||
status=p.status,
|
||||
|
||||
rule_id=p.rule_id,
|
||||
|
||||
fingerprint=p.fingerprint,
|
||||
|
||||
event_count=p.event_count,
|
||||
|
||||
last_seen_at=p.last_seen_at,
|
||||
|
||||
created_at=p.created_at,
|
||||
|
||||
updated_at=p.updated_at,
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("", response_model=ProblemListResponse)
|
||||
|
||||
def list_problems(
|
||||
|
||||
page: int = Query(1, ge=1),
|
||||
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
|
||||
status: str | None = Query(None),
|
||||
|
||||
severity: str | None = Query(None),
|
||||
|
||||
host_id: int | None = Query(None),
|
||||
|
||||
hostname: str | None = Query(None),
|
||||
|
||||
db: Session = Depends(get_db),
|
||||
|
||||
_user: str = Depends(get_current_user),
|
||||
|
||||
) -> ProblemListResponse:
|
||||
|
||||
stmt = select(Problem).join(Host, isouter=True).options(joinedload(Problem.host))
|
||||
|
||||
count_stmt = select(func.count()).select_from(Problem).join(Host, isouter=True)
|
||||
|
||||
|
||||
|
||||
if status:
|
||||
|
||||
stmt = stmt.where(Problem.status == status)
|
||||
|
||||
count_stmt = count_stmt.where(Problem.status == status)
|
||||
|
||||
if severity:
|
||||
|
||||
stmt = stmt.where(Problem.severity == severity)
|
||||
|
||||
count_stmt = count_stmt.where(Problem.severity == severity)
|
||||
|
||||
if host_id is not None:
|
||||
|
||||
stmt = stmt.where(Problem.host_id == host_id)
|
||||
|
||||
count_stmt = count_stmt.where(Problem.host_id == host_id)
|
||||
|
||||
if hostname:
|
||||
|
||||
like = f"%{hostname}%"
|
||||
|
||||
stmt = stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like))
|
||||
|
||||
count_stmt = count_stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like))
|
||||
|
||||
|
||||
|
||||
total = db.scalar(count_stmt) or 0
|
||||
|
||||
rows = db.scalars(
|
||||
|
||||
stmt.order_by(Problem.last_seen_at.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
|
||||
).all()
|
||||
|
||||
|
||||
|
||||
items = [_problem_summary(p) for p in rows]
|
||||
|
||||
return ProblemListResponse(items=items, total=total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/{problem_id}", response_model=ProblemDetail)
|
||||
|
||||
def get_problem(
|
||||
|
||||
problem_id: int,
|
||||
|
||||
db: Session = Depends(get_db),
|
||||
|
||||
_user: str = Depends(get_current_user),
|
||||
|
||||
) -> ProblemDetail:
|
||||
|
||||
problem = db.scalar(
|
||||
|
||||
select(Problem).where(Problem.id == problem_id).options(joinedload(Problem.host))
|
||||
|
||||
)
|
||||
|
||||
if problem is None:
|
||||
|
||||
raise HTTPException(status_code=404, detail="Problem not found")
|
||||
|
||||
|
||||
|
||||
event_rows = db.scalars(
|
||||
|
||||
select(Event)
|
||||
|
||||
.join(ProblemEvent, ProblemEvent.event_id == Event.id)
|
||||
|
||||
.where(ProblemEvent.problem_id == problem_id)
|
||||
|
||||
.order_by(Event.occurred_at.desc())
|
||||
|
||||
).all()
|
||||
|
||||
|
||||
|
||||
base = _problem_summary(problem)
|
||||
|
||||
return ProblemDetail(
|
||||
|
||||
**base.model_dump(),
|
||||
|
||||
events=[
|
||||
|
||||
ProblemEventItem(
|
||||
|
||||
id=e.id,
|
||||
|
||||
event_id=e.event_id,
|
||||
|
||||
occurred_at=e.occurred_at,
|
||||
|
||||
type=e.type,
|
||||
|
||||
severity=e.severity,
|
||||
|
||||
title=e.title,
|
||||
|
||||
summary=e.summary,
|
||||
|
||||
)
|
||||
|
||||
for e in event_rows
|
||||
|
||||
],
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{problem_id}/ack")
|
||||
|
||||
def ack_problem(
|
||||
|
||||
problem_id: int,
|
||||
|
||||
db: Session = Depends(get_db),
|
||||
|
||||
_user: str = Depends(get_current_user),
|
||||
|
||||
) -> dict:
|
||||
|
||||
problem = db.get(Problem, problem_id)
|
||||
|
||||
if problem is None:
|
||||
|
||||
raise HTTPException(status_code=404, detail="Problem not found")
|
||||
|
||||
if problem.status == "resolved":
|
||||
|
||||
raise HTTPException(status_code=409, detail="Problem already resolved")
|
||||
|
||||
problem.status = "acknowledged"
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"id": problem.id, "status": problem.status}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{problem_id}/resolve")
|
||||
|
||||
def resolve_problem(
|
||||
|
||||
problem_id: int,
|
||||
|
||||
db: Session = Depends(get_db),
|
||||
|
||||
_user: str = Depends(get_current_user),
|
||||
|
||||
) -> dict:
|
||||
|
||||
problem = db.get(Problem, problem_id)
|
||||
|
||||
if problem is None:
|
||||
|
||||
raise HTTPException(status_code=404, detail="Problem not found")
|
||||
|
||||
if problem.status == "resolved":
|
||||
|
||||
raise HTTPException(status_code=409, detail="Problem already resolved")
|
||||
|
||||
problem.status = "resolved"
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"id": problem.id, "status": problem.status}
|
||||
|
||||
|
||||
|
||||
@@ -23,7 +23,11 @@ from app.services.notification_settings import (
|
||||
upsert_webhook_channel,
|
||||
)
|
||||
from app.services.telegram_notify import TelegramNotConfiguredError, TelegramSendError, send_telegram_test_message
|
||||
from app.services.webhook_notify import WebhookNotConfiguredError, WebhookSendError, send_webhook_test_message
|
||||
from app.services.event_severity_overrides import (
|
||||
SOURCE_DB,
|
||||
list_severity_override_items,
|
||||
replace_severity_overrides,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
@@ -328,3 +332,63 @@ def test_email_settings(
|
||||
except EmailSendError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
return ChannelTestResponse(message="Тестовое письмо отправлено по SMTP")
|
||||
|
||||
|
||||
class EventSeverityOverrideRow(BaseModel):
|
||||
event_type: str
|
||||
default_severity: str
|
||||
override_severity: str | None = None
|
||||
|
||||
|
||||
class EventSeverityOverridesResponse(BaseModel):
|
||||
items: list[EventSeverityOverrideRow]
|
||||
source: str = Field(description="db — overrides хранятся в PostgreSQL")
|
||||
|
||||
|
||||
class EventSeverityOverridesUpdate(BaseModel):
|
||||
overrides: dict[str, str | None] = Field(
|
||||
description="event_type → severity; null или пустая строка сбрасывает override",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/notifications/severity-overrides", response_model=EventSeverityOverridesResponse)
|
||||
def get_severity_overrides(
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> EventSeverityOverridesResponse:
|
||||
items = list_severity_override_items(db)
|
||||
return EventSeverityOverridesResponse(
|
||||
items=[
|
||||
EventSeverityOverrideRow(
|
||||
event_type=i.event_type,
|
||||
default_severity=i.default_severity,
|
||||
override_severity=i.override_severity,
|
||||
)
|
||||
for i in items
|
||||
],
|
||||
source=SOURCE_DB,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/notifications/severity-overrides", response_model=EventSeverityOverridesResponse)
|
||||
def update_severity_overrides(
|
||||
body: EventSeverityOverridesUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> EventSeverityOverridesResponse:
|
||||
try:
|
||||
items = replace_severity_overrides(db, body.overrides)
|
||||
db.commit()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
return EventSeverityOverridesResponse(
|
||||
items=[
|
||||
EventSeverityOverrideRow(
|
||||
event_type=i.event_type,
|
||||
default_severity=i.default_severity,
|
||||
override_severity=i.override_severity,
|
||||
)
|
||||
for i in items
|
||||
],
|
||||
source=SOURCE_DB,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user