feat: indie status page MVP -- FastAPI + SQLite

- 8 DB models (services, incidents, monitors, subscribers, etc.)
- Full CRUD API for services, incidents, monitors
- Public status page with live data
- Incident detail page with timeline
- API key authentication
- Uptime monitoring scheduler
- 13 tests passing
- TECHNICAL_DESIGN.md with full spec
This commit is contained in:
IndieStatusBot 2026-04-25 05:00:00 +00:00
commit 902133edd3
4655 changed files with 1342691 additions and 0 deletions

166
app/api/monitors.py Normal file
View file

@ -0,0 +1,166 @@
"""Monitors API endpoints."""
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
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)
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,
"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."""
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,
)
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."""
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)
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,
}