Harden production workflows and agent admission
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -5,11 +7,29 @@ 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 Incident, IncidentsPage
|
||||
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(
|
||||
@@ -25,30 +45,105 @@ def _to_schema(i: IncidentModel) -> Incident:
|
||||
)
|
||||
|
||||
|
||||
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: str | None = Query(default=None),
|
||||
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:
|
||||
if state is not None and state not in ("open", "resolved"):
|
||||
raise HTTPException(status_code=400, detail="invalid state")
|
||||
stmt = select(IncidentModel).order_by(IncidentModel.opened_at.desc(), IncidentModel.id.desc())
|
||||
# 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:
|
||||
ts_str, last_id = decode(cursor, 2)
|
||||
ts = decode_datetime(ts_str)
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
IncidentModel.opened_at < ts,
|
||||
and_(IncidentModel.opened_at == ts, IncidentModel.id < last_id),
|
||||
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()
|
||||
next_cursor = None
|
||||
if len(rows) > limit:
|
||||
rows = rows[:limit]
|
||||
next_cursor = encode(to_utc(rows[-1].opened_at).isoformat(), rows[-1].id)
|
||||
return IncidentsPage(items=[_to_schema(r) for r in rows], next_cursor=next_cursor)
|
||||
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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user