Implement Monlet MVP stack and UI updates
This commit is contained in:
0
server/monlet_server/api/__init__.py
Normal file
0
server/monlet_server/api/__init__.py
Normal file
48
server/monlet_server/api/agents.py
Normal file
48
server/monlet_server/api/agents.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..cursors import decode, encode
|
||||
from ..db import get_session
|
||||
from ..models import Agent as AgentModel
|
||||
from ..schemas import Agent, AgentsPage
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _to_schema(a: AgentModel) -> Agent:
|
||||
return Agent(
|
||||
agent_id=a.agent_id,
|
||||
hostname=a.hostname,
|
||||
status=a.status,
|
||||
last_seen_at=a.last_seen_at,
|
||||
features=a.features or {"push": False, "metrics": False},
|
||||
labels=a.labels or {},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/agents", response_model=AgentsPage)
|
||||
async def list_agents(
|
||||
cursor: str | None = Query(default=None, max_length=512),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> AgentsPage:
|
||||
stmt = select(AgentModel).order_by(AgentModel.agent_id)
|
||||
if cursor is not None:
|
||||
(last_id,) = decode(cursor, 1)
|
||||
stmt = stmt.where(AgentModel.agent_id > last_id)
|
||||
rows = (await session.execute(stmt.limit(limit + 1))).scalars().all()
|
||||
next_cursor = None
|
||||
if len(rows) > limit:
|
||||
rows = rows[:limit]
|
||||
next_cursor = encode(rows[-1].agent_id)
|
||||
return AgentsPage(items=[_to_schema(a) for a in rows], next_cursor=next_cursor)
|
||||
|
||||
|
||||
@router.get("/agents/{agent_id}", response_model=Agent)
|
||||
async def get_agent(agent_id: str, session: AsyncSession = Depends(get_session)) -> Agent:
|
||||
res = await session.execute(select(AgentModel).where(AgentModel.agent_id == agent_id))
|
||||
a = res.scalar_one_or_none()
|
||||
if a is None:
|
||||
raise HTTPException(status_code=404, detail="agent not found")
|
||||
return _to_schema(a)
|
||||
47
server/monlet_server/api/checks.py
Normal file
47
server/monlet_server/api/checks.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..cursors import decode, encode
|
||||
from ..db import get_session
|
||||
from ..models import Check
|
||||
from ..schemas import ID_PATTERN, ChecksPage, CheckState
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/checks", response_model=ChecksPage)
|
||||
async def list_checks(
|
||||
agent_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),
|
||||
) -> ChecksPage:
|
||||
stmt = select(Check).order_by(Check.agent_id, Check.check_id)
|
||||
if agent_id is not None:
|
||||
stmt = stmt.where(Check.agent_id == agent_id)
|
||||
if cursor is not None:
|
||||
last_agent, last_check = decode(cursor, 2)
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Check.agent_id > last_agent,
|
||||
and_(Check.agent_id == last_agent, Check.check_id > last_check),
|
||||
)
|
||||
)
|
||||
rows = (await session.execute(stmt.limit(limit + 1))).scalars().all()
|
||||
next_cursor = None
|
||||
if len(rows) > limit:
|
||||
rows = rows[:limit]
|
||||
next_cursor = encode(rows[-1].agent_id, rows[-1].check_id)
|
||||
items = [
|
||||
CheckState(
|
||||
agent_id=r.agent_id,
|
||||
check_id=r.check_id,
|
||||
status=r.status,
|
||||
last_observed_at=r.last_observed_at,
|
||||
exit_code=r.exit_code,
|
||||
incident_key=r.incident_key,
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
return ChecksPage(items=items, next_cursor=next_cursor)
|
||||
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)
|
||||
22
server/monlet_server/api/health.py
Normal file
22
server/monlet_server/api/health.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..db import get_session
|
||||
from ..schemas import HealthResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse)
|
||||
async def health() -> HealthResponse:
|
||||
return HealthResponse()
|
||||
|
||||
|
||||
@router.get("/ready", response_model=HealthResponse)
|
||||
async def ready(session: AsyncSession = Depends(get_session)) -> HealthResponse:
|
||||
try:
|
||||
await session.execute(text("SELECT 1"))
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=503, detail="database not reachable") from exc
|
||||
return HealthResponse()
|
||||
21
server/monlet_server/api/heartbeat.py
Normal file
21
server/monlet_server/api/heartbeat.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..db import get_session
|
||||
from ..schemas import AcceptedResponse, HeartbeatRequest
|
||||
from ..services.ingestion import upsert_agent_heartbeat
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/heartbeat",
|
||||
response_model=AcceptedResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
async def heartbeat(
|
||||
body: HeartbeatRequest, session: AsyncSession = Depends(get_session)
|
||||
) -> AcceptedResponse:
|
||||
await upsert_agent_heartbeat(session, body)
|
||||
await session.commit()
|
||||
return AcceptedResponse(accepted=True)
|
||||
53
server/monlet_server/api/incidents.py
Normal file
53
server/monlet_server/api/incidents.py
Normal file
@@ -0,0 +1,53 @@
|
||||
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 Incident, IncidentsPage
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
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=i.opened_at,
|
||||
resolved_at=i.resolved_at,
|
||||
summary=i.summary,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/incidents", response_model=IncidentsPage)
|
||||
async def list_incidents(
|
||||
state: str | None = Query(default=None),
|
||||
cursor: str | None = Query(default=None, max_length=512),
|
||||
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())
|
||||
if state is not None:
|
||||
stmt = stmt.where(IncidentModel.state == state)
|
||||
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),
|
||||
)
|
||||
)
|
||||
rows = (await session.execute(stmt.limit(limit + 1))).scalars().all()
|
||||
next_cursor = None
|
||||
if len(rows) > limit:
|
||||
rows = rows[:limit]
|
||||
next_cursor = encode(rows[-1].opened_at.isoformat(), rows[-1].id)
|
||||
return IncidentsPage(items=[_to_schema(r) for r in rows], next_cursor=next_cursor)
|
||||
58
server/monlet_server/api/outbox.py
Normal file
58
server/monlet_server/api/outbox.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from fastapi import APIRouter, Depends, 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 NotificationOutbox
|
||||
from ..schemas import NotificationOutboxItem, OutboxPage
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/notifiers/outbox", response_model=OutboxPage)
|
||||
async def list_outbox(
|
||||
state: str | None = Query(default=None),
|
||||
cursor: str | None = Query(default=None, max_length=512),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> OutboxPage:
|
||||
stmt = select(NotificationOutbox).order_by(
|
||||
NotificationOutbox.created_at.desc(), NotificationOutbox.id.desc()
|
||||
)
|
||||
if state is not None:
|
||||
stmt = stmt.where(NotificationOutbox.state == state)
|
||||
if cursor is not None:
|
||||
ts_str, last_id = decode(cursor, 2)
|
||||
ts = decode_datetime(ts_str)
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
NotificationOutbox.created_at < ts,
|
||||
and_(
|
||||
NotificationOutbox.created_at == ts,
|
||||
NotificationOutbox.id < last_id,
|
||||
),
|
||||
)
|
||||
)
|
||||
rows = (await session.execute(stmt.limit(limit + 1))).scalars().all()
|
||||
next_cursor = None
|
||||
if len(rows) > limit:
|
||||
rows = rows[:limit]
|
||||
next_cursor = encode(rows[-1].created_at.isoformat(), rows[-1].id)
|
||||
return OutboxPage(
|
||||
items=[
|
||||
NotificationOutboxItem(
|
||||
id=str(r.id),
|
||||
notifier=r.notifier,
|
||||
state=r.state,
|
||||
incident_id=str(r.incident_id),
|
||||
event_type=r.event_type,
|
||||
attempts=r.attempts,
|
||||
next_attempt_at=r.next_attempt_at,
|
||||
last_error=r.last_error,
|
||||
updated_at=r.updated_at,
|
||||
)
|
||||
for r in rows
|
||||
],
|
||||
next_cursor=next_cursor,
|
||||
)
|
||||
Reference in New Issue
Block a user