229 lines
No EOL
7.6 KiB
Python
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,
|
|
} |