150 lines
6.1 KiB
Python
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,
|
|
)
|