Files
monlet/server/monlet_server/api/incidents.py
2026-05-28 14:19:27 +04:00

150 lines
6.1 KiB
Python

from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..cursors import decode, decode_datetime, encode
from ..db import get_session
from ..models import Incident as IncidentModel
from ..schemas import ID_PATTERN, Incident, IncidentsPage
from ..timeutil import to_utc
router = APIRouter()
_SORT_COLUMNS = {
"opened_at": IncidentModel.opened_at,
"resolved_at": IncidentModel.resolved_at,
}
SortField = Literal["status", "opened_at", "resolved_at"]
# PH-review: the rank is materialized as a STORED generated column
# `incidents.status_rank` (see models.py / 0001_baseline.py). This map mirrors
# the SQL CASE for Python-side cursor encoding only.
_STATUS_RANK: dict[tuple[str, str], int] = {
("open", "critical"): 5,
("open", "warning"): 4,
("open", "unknown"): 3,
("resolved", "critical"): 2,
("resolved", "warning"): 1,
("resolved", "unknown"): 0,
}
def _to_schema(i: IncidentModel) -> Incident:
return Incident(
id=str(i.id),
incident_key=i.incident_key,
state=i.state,
severity=i.severity,
agent_id=i.agent_id,
check_id=i.check_id,
opened_at=to_utc(i.opened_at),
resolved_at=to_utc(i.resolved_at) if i.resolved_at else None,
summary=i.summary,
)
def _encode_incident_cursor(i: IncidentModel, sort: SortField) -> str:
if sort == "status":
return encode(i.status_rank, to_utc(i.opened_at).isoformat(), i.id)
return encode(to_utc(getattr(i, sort)).isoformat(), i.id)
@router.get("/incidents", response_model=IncidentsPage)
async def list_incidents(
state: Literal["open", "resolved"] | None = Query(default=None),
severity: Literal["warning", "critical", "unknown"] | None = Query(default=None),
agent_id: str | None = Query(default=None, max_length=128, pattern=ID_PATTERN),
check_id: str | None = Query(default=None, max_length=128, pattern=ID_PATTERN),
sort: SortField = Query(default="status"),
direction: Literal["asc", "desc"] = Query(default="desc"),
cursor: str | None = Query(default=None, max_length=512),
back: bool = Query(default=False),
limit: int = Query(default=100, ge=1, le=500),
session: AsyncSession = Depends(get_session),
) -> IncidentsPage:
# PH-001 finding-4: sort=resolved_at must skip open incidents — their
# resolved_at is NULL and would otherwise break to_utc() on the cursor.
if sort == "resolved_at" and state is None:
state = "resolved"
if sort == "resolved_at" and state == "open":
raise HTTPException(status_code=400, detail="sort=resolved_at requires state=resolved")
# PH-review: `back=True` flips the effective sort to walk to the previous
# page. We reverse the rows before returning so the response order matches
# the canonical (user-requested) direction.
effective_desc = (direction == "desc") ^ back
if sort == "status":
rank_col = IncidentModel.status_rank
rank_order = rank_col.desc() if effective_desc else rank_col.asc()
opened_order = (
IncidentModel.opened_at.desc() if effective_desc else IncidentModel.opened_at.asc()
)
id_order = IncidentModel.id.desc() if effective_desc else IncidentModel.id.asc()
stmt = select(IncidentModel).order_by(rank_order, opened_order, id_order)
else:
col = _SORT_COLUMNS[sort]
order_col = col.desc() if effective_desc else col.asc()
tiebreak = IncidentModel.id.desc() if effective_desc else IncidentModel.id.asc()
stmt = select(IncidentModel).order_by(order_col, tiebreak)
if sort == "resolved_at":
stmt = stmt.where(IncidentModel.resolved_at.is_not(None))
if state is not None:
stmt = stmt.where(IncidentModel.state == state)
if severity is not None:
stmt = stmt.where(IncidentModel.severity == severity)
if agent_id is not None:
stmt = stmt.where(IncidentModel.agent_id == agent_id)
if check_id is not None:
stmt = stmt.where(IncidentModel.check_id == check_id)
if cursor is not None:
if sort == "status":
rank_str, ts_str, last_id = decode(cursor, 3)
try:
rank = int(rank_str)
except ValueError as exc:
raise HTTPException(status_code=400, detail="invalid cursor") from exc
ts = decode_datetime(ts_str)
rank_col = IncidentModel.status_rank
rank_cmp = rank_col < rank if effective_desc else rank_col > rank
opened_cmp = (
IncidentModel.opened_at < ts if effective_desc else IncidentModel.opened_at > ts
)
id_cmp = IncidentModel.id < last_id if effective_desc else IncidentModel.id > last_id
stmt = stmt.where(
or_(
rank_cmp,
and_(rank_col == rank, opened_cmp),
and_(rank_col == rank, IncidentModel.opened_at == ts, id_cmp),
)
)
else:
col = _SORT_COLUMNS[sort]
ts_str, last_id = decode(cursor, 2)
ts = decode_datetime(ts_str)
cmp = col < ts if effective_desc else col > ts
tie = IncidentModel.id < last_id if effective_desc else IncidentModel.id > last_id
stmt = stmt.where(or_(cmp, and_(col == ts, tie)))
rows = (await session.execute(stmt.limit(limit + 1))).scalars().all()
has_more = len(rows) > limit
rows = rows[:limit]
if back:
rows = list(reversed(rows))
next_cursor: str | None = None
prev_cursor: str | None = None
if rows:
if back:
next_cursor = _encode_incident_cursor(rows[-1], sort)
if has_more:
prev_cursor = _encode_incident_cursor(rows[0], sort)
else:
if has_more:
next_cursor = _encode_incident_cursor(rows[-1], sort)
if cursor is not None:
prev_cursor = _encode_incident_cursor(rows[0], sort)
return IncidentsPage(
items=[_to_schema(r) for r in rows],
next_cursor=next_cursor,
prev_cursor=prev_cursor,
)