indie-status-page/app/api/monitors.py
2026-04-25 12:14:06 +00:00

229 lines
No EOL
7.6 KiB
Python

"""Monitors API endpoints with tier enforcement.
When creating a monitor for an organization, tier enforcement checks:
- monitors_per_service: max number of monitors per service
- check_interval_min: minimum allowed check interval
"""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, verify_api_key
from app.models.models import Monitor, MonitorResult, Service
from app.models.saas_models import Organization
from app.services.tier_enforcement import (
enforce_monitor_limit,
validate_check_interval,
)
from app.services.tier_limits import TierLimitExceeded, get_tier_limits
router = APIRouter()
class MonitorCreate(BaseModel):
service_id: UUID
url: str = Field(..., max_length=500)
method: str = Field("GET", pattern=r"^(GET|POST|HEAD)$")
expected_status: int = 200
timeout_seconds: int = Field(10, ge=1, le=60)
interval_seconds: int = Field(60, ge=30, le=3600)
organization_id: str | None = None # Optional: for tier enforcement
class MonitorUpdate(BaseModel):
url: str | None = Field(None, max_length=500)
method: str | None = Field(None, pattern=r"^(GET|POST|HEAD)$")
expected_status: int | None = None
timeout_seconds: int | None = Field(None, ge=1, le=60)
interval_seconds: int | None = Field(None, ge=30, le=3600)
is_active: bool | None = None
def serialize_monitor(m: Monitor) -> dict:
return {
"id": m.id,
"service_id": m.service_id,
"url": m.url,
"method": m.method,
"expected_status": m.expected_status,
"timeout_seconds": m.timeout_seconds,
"interval_seconds": m.interval_seconds,
"is_active": m.is_active,
"organization_id": m.organization_id,
"created_at": m.created_at.isoformat() if m.created_at else None,
"updated_at": m.updated_at.isoformat() if m.updated_at else None,
}
@router.get("/")
async def list_monitors(db: AsyncSession = Depends(get_db)):
"""List all monitors."""
result = await db.execute(select(Monitor))
monitors = result.scalars().all()
return [serialize_monitor(m) for m in monitors]
@router.post("/", status_code=status.HTTP_201_CREATED)
async def create_monitor(
data: MonitorCreate,
db: AsyncSession = Depends(get_db),
api_key: str = Depends(verify_api_key),
):
"""Create a new monitor.
If organization_id is provided, tier enforcement is applied:
- check monitors_per_service limit
- validate check_interval against minimum for the tier
"""
# Look up org if provided
org = None
if data.organization_id:
result = await db.execute(
select(Organization).where(Organization.id == data.organization_id)
)
org = result.scalar_one_or_none()
if org is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Organization '{data.organization_id}' not found",
)
# Tier enforcement when org context is provided
if org is not None:
# Check monitors_per_service limit
await enforce_monitor_limit(db, org, str(data.service_id))
# Validate check interval
validate_check_interval(org, data.interval_seconds)
# Verify the service exists
service_result = await db.execute(
select(Service).where(Service.id == str(data.service_id))
)
service = service_result.scalar_one_or_none()
if not service:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Service '{data.service_id}' not found",
)
monitor = Monitor(
service_id=str(data.service_id),
url=data.url,
method=data.method,
expected_status=data.expected_status,
timeout_seconds=data.timeout_seconds,
interval_seconds=data.interval_seconds,
organization_id=data.organization_id or (service.organization_id if service else None),
)
db.add(monitor)
await db.flush()
await db.refresh(monitor)
return serialize_monitor(monitor)
@router.get("/{monitor_id}")
async def get_monitor(monitor_id: UUID, db: AsyncSession = Depends(get_db)):
"""Get a monitor with recent results."""
result = await db.execute(select(Monitor).where(Monitor.id == str(monitor_id)))
monitor = result.scalar_one_or_none()
if not monitor:
raise HTTPException(status_code=404, detail="Monitor not found")
# Query recent results separately
results_query = (
select(MonitorResult)
.where(MonitorResult.monitor_id == str(monitor_id))
.order_by(MonitorResult.checked_at.desc())
.limit(10)
)
results_result = await db.execute(results_query)
recent_results = results_result.scalars().all()
data = serialize_monitor(monitor)
data["recent_results"] = [
{
"id": r.id,
"status": r.status,
"response_time_ms": r.response_time_ms,
"status_code": r.status_code,
"error_message": r.error_message,
"checked_at": r.checked_at.isoformat() if r.checked_at else None,
}
for r in recent_results
]
return data
@router.patch("/{monitor_id}")
async def update_monitor(
monitor_id: UUID,
data: MonitorUpdate,
db: AsyncSession = Depends(get_db),
api_key: str = Depends(verify_api_key),
):
"""Update a monitor.
If interval_seconds is being changed, validate against the org's tier minimum.
"""
result = await db.execute(select(Monitor).where(Monitor.id == str(monitor_id)))
monitor = result.scalar_one_or_none()
if not monitor:
raise HTTPException(status_code=404, detail="Monitor not found")
update_data = data.model_dump(exclude_unset=True)
# If updating interval_seconds and org context exists, validate
if data.interval_seconds is not None and monitor.organization_id:
org_result = await db.execute(
select(Organization).where(Organization.id == monitor.organization_id)
)
org = org_result.scalar_one_or_none()
if org:
validate_check_interval(org, data.interval_seconds)
for field, value in update_data.items():
setattr(monitor, field, value)
await db.flush()
await db.refresh(monitor)
return serialize_monitor(monitor)
@router.delete("/{monitor_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_monitor(
monitor_id: UUID,
db: AsyncSession = Depends(get_db),
api_key: str = Depends(verify_api_key),
):
"""Delete a monitor."""
result = await db.execute(select(Monitor).where(Monitor.id == str(monitor_id)))
monitor = result.scalar_one_or_none()
if not monitor:
raise HTTPException(status_code=404, detail="Monitor not found")
await db.delete(monitor)
@router.post("/{monitor_id}/check")
async def trigger_check(
monitor_id: UUID,
db: AsyncSession = Depends(get_db),
api_key: str = Depends(verify_api_key),
):
"""Trigger a manual uptime check for this monitor."""
from app.services.uptime import check_monitor
result = await db.execute(select(Monitor).where(Monitor.id == str(monitor_id)))
monitor = result.scalar_one_or_none()
if not monitor:
raise HTTPException(status_code=404, detail="Monitor not found")
monitor_result = await check_monitor(monitor, db)
return {
"status": monitor_result.status,
"response_time_ms": monitor_result.response_time_ms,
"status_code": monitor_result.status_code,
}