Implement Monlet MVP stack and UI updates
This commit is contained in:
102
server/monlet_server/api/events.py
Normal file
102
server/monlet_server/api/events.py
Normal file
@@ -0,0 +1,102 @@
|
||||
import base64
|
||||
import binascii
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..db import get_session
|
||||
from ..models import Event
|
||||
from ..schemas import (
|
||||
ID_PATTERN,
|
||||
EventBatchAcceptedResponse,
|
||||
EventBatchRequest,
|
||||
EventsPage,
|
||||
StoredCheckResultEvent,
|
||||
)
|
||||
from ..services.ingestion import ingest_event
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _encode_cursor(received_at: datetime, event_id) -> str:
|
||||
raw = f"{received_at.isoformat()}|{event_id}".encode()
|
||||
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||
|
||||
|
||||
def _decode_cursor(cursor: str) -> tuple[datetime, str]:
|
||||
pad = "=" * (-len(cursor) % 4)
|
||||
try:
|
||||
raw = base64.urlsafe_b64decode(cursor + pad).decode()
|
||||
ts_str, eid = raw.split("|", 1)
|
||||
return datetime.fromisoformat(ts_str), eid
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail="invalid cursor") from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/events",
|
||||
response_model=EventBatchAcceptedResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
async def events(
|
||||
body: EventBatchRequest, session: AsyncSession = Depends(get_session)
|
||||
) -> EventBatchAcceptedResponse:
|
||||
accepted = 0
|
||||
deduplicated = 0
|
||||
for ev in body.events:
|
||||
ok = await ingest_event(session, body.agent_id, ev)
|
||||
if ok:
|
||||
accepted += 1
|
||||
else:
|
||||
deduplicated += 1
|
||||
await session.commit()
|
||||
return EventBatchAcceptedResponse(accepted=accepted, deduplicated=deduplicated)
|
||||
|
||||
|
||||
@router.get("/events/query", response_model=EventsPage)
|
||||
async def query_events(
|
||||
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),
|
||||
cursor: str | None = Query(default=None, max_length=512),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> EventsPage:
|
||||
stmt = select(Event).order_by(Event.received_at.desc(), Event.event_id.desc())
|
||||
if agent_id is not None:
|
||||
stmt = stmt.where(Event.agent_id == agent_id)
|
||||
if check_id is not None:
|
||||
stmt = stmt.where(Event.check_id == check_id)
|
||||
if cursor is not None:
|
||||
ts, eid = _decode_cursor(cursor)
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Event.received_at < ts,
|
||||
and_(Event.received_at == ts, Event.event_id < eid),
|
||||
)
|
||||
)
|
||||
stmt = stmt.limit(limit + 1)
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
next_cursor = None
|
||||
if len(rows) > limit:
|
||||
rows = rows[:limit]
|
||||
next_cursor = _encode_cursor(rows[-1].received_at, rows[-1].event_id)
|
||||
items = [
|
||||
StoredCheckResultEvent(
|
||||
event_id=str(r.event_id),
|
||||
agent_id=r.agent_id,
|
||||
check_id=r.check_id,
|
||||
observed_at=r.observed_at,
|
||||
received_at=r.received_at,
|
||||
status=r.status,
|
||||
exit_code=r.exit_code,
|
||||
duration_ms=r.duration_ms,
|
||||
output=r.output,
|
||||
output_truncated=r.output_truncated,
|
||||
notifications_enabled=r.notifications_enabled,
|
||||
incident_key=r.incident_key,
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
return EventsPage(items=items, next_cursor=next_cursor)
|
||||
Reference in New Issue
Block a user