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

0
app/api/__init__.py Normal file
View file

195
app/api/incidents.py Normal file
View file

@ -0,0 +1,195 @@
"""Incidents API endpoints."""
from datetime import datetime
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 Incident, IncidentUpdate
router = APIRouter()
class IncidentCreate(BaseModel):
service_id: UUID
title: str = Field(..., max_length=200)
status: str = Field(..., pattern=r"^(investigating|identified|monitoring|resolved)$")
severity: str = Field(..., pattern=r"^(minor|major|outage)$")
started_at: datetime | None = None
class IncidentUpdateCreate(BaseModel):
status: str = Field(..., pattern=r"^(investigating|identified|monitoring|resolved)$")
body: str
class IncidentPatch(BaseModel):
title: str | None = None
status: str | None = Field(None, pattern=r"^(investigating|identified|monitoring|resolved)$")
severity: str | None = Field(None, pattern=r"^(minor|major|outage)$")
def serialize_incident(i: Incident) -> dict:
return {
"id": i.id,
"service_id": i.service_id,
"title": i.title,
"status": i.status,
"severity": i.severity,
"started_at": i.started_at.isoformat() if i.started_at else None,
"resolved_at": i.resolved_at.isoformat() if i.resolved_at else None,
"created_at": i.created_at.isoformat() if i.created_at else None,
"updated_at": i.updated_at.isoformat() if i.updated_at else None,
}
async def serialize_incident_detail(i: Incident, db: AsyncSession) -> dict:
"""Serialize incident with its updates, querying explicitly to avoid lazy-load issues."""
data = serialize_incident(i)
# Explicitly query updates instead of relying on lazy-loaded relationship
result = await db.execute(
select(IncidentUpdate)
.where(IncidentUpdate.incident_id == i.id)
.order_by(IncidentUpdate.created_at)
)
updates = result.scalars().all()
data["updates"] = [
{
"id": u.id,
"status": u.status,
"body": u.body,
"created_at": u.created_at.isoformat() if u.created_at else None,
}
for u in updates
]
return data
@router.get("/")
async def list_incidents(
service_id: UUID | None = None,
status: str | None = None,
limit: int = 50,
offset: int = 0,
db: AsyncSession = Depends(get_db),
):
"""List incidents with optional filtering."""
query = select(Incident).order_by(Incident.started_at.desc())
if service_id:
query = query.where(Incident.service_id == str(service_id))
if status:
query = query.where(Incident.status == status)
query = query.offset(offset).limit(limit)
result = await db.execute(query)
incidents = result.scalars().all()
return [serialize_incident(i) for i in incidents]
@router.post("/", status_code=status.HTTP_201_CREATED)
async def create_incident(
data: IncidentCreate,
db: AsyncSession = Depends(get_db),
api_key: str = Depends(verify_api_key),
):
"""Create a new incident."""
incident = Incident(
service_id=str(data.service_id),
title=data.title,
status=data.status,
severity=data.severity,
started_at=data.started_at or datetime.utcnow(),
)
db.add(incident)
await db.flush()
await db.refresh(incident)
return serialize_incident(incident)
@router.get("/{incident_id}")
async def get_incident(incident_id: UUID, db: AsyncSession = Depends(get_db)):
"""Get an incident with its updates."""
result = await db.execute(select(Incident).where(Incident.id == str(incident_id)))
incident = result.scalar_one_or_none()
if not incident:
raise HTTPException(status_code=404, detail="Incident not found")
return await serialize_incident_detail(incident, db)
@router.patch("/{incident_id}")
async def update_incident(
incident_id: UUID,
data: IncidentPatch,
db: AsyncSession = Depends(get_db),
api_key: str = Depends(verify_api_key),
):
"""Update incident fields (title, status, severity)."""
result = await db.execute(select(Incident).where(Incident.id == str(incident_id)))
incident = result.scalar_one_or_none()
if not incident:
raise HTTPException(status_code=404, detail="Incident not found")
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(incident, field, value)
# If status changed to resolved, set resolved_at
if data.status == "resolved" and "status" in update_data:
incident.resolved_at = datetime.utcnow()
await db.flush()
await db.refresh(incident)
return await serialize_incident_detail(incident, db)
@router.delete("/{incident_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_incident(
incident_id: UUID,
db: AsyncSession = Depends(get_db),
api_key: str = Depends(verify_api_key),
):
"""Delete an incident."""
result = await db.execute(select(Incident).where(Incident.id == str(incident_id)))
incident = result.scalar_one_or_none()
if not incident:
raise HTTPException(status_code=404, detail="Incident not found")
await db.delete(incident)
@router.post("/{incident_id}/updates", status_code=status.HTTP_201_CREATED)
async def create_incident_update(
incident_id: UUID,
data: IncidentUpdateCreate,
db: AsyncSession = Depends(get_db),
api_key: str = Depends(verify_api_key),
):
"""Add an update to an incident."""
result = await db.execute(select(Incident).where(Incident.id == str(incident_id)))
incident = result.scalar_one_or_none()
if not incident:
raise HTTPException(status_code=404, detail="Incident not found")
update = IncidentUpdate(
incident_id=str(incident_id),
status=data.status,
body=data.body,
)
db.add(update)
# Also update incident status
incident.status = data.status
# If resolved, set resolved_at
if data.status == "resolved":
incident.resolved_at = datetime.utcnow()
await db.flush()
await db.refresh(update)
return {
"id": update.id,
"incident_id": update.incident_id,
"status": update.status,
"body": update.body,
"created_at": update.created_at.isoformat() if update.created_at else None,
}

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,
}

15
app/api/router.py Normal file
View file

@ -0,0 +1,15 @@
from fastapi import APIRouter
from app.api.services import router as services_router
from app.api.incidents import router as incidents_router
from app.api.monitors import router as monitors_router
from app.api.subscribers import router as subscribers_router
from app.api.settings import router as settings_router
api_v1_router = APIRouter()
api_v1_router.include_router(services_router, prefix="/services", tags=["services"])
api_v1_router.include_router(incidents_router, prefix="/incidents", tags=["incidents"])
api_v1_router.include_router(monitors_router, prefix="/monitors", tags=["monitors"])
api_v1_router.include_router(subscribers_router, prefix="/subscribers", tags=["subscribers"])
api_v1_router.include_router(settings_router, prefix="/settings", tags=["settings"])

120
app/api/services.py Normal file
View file

@ -0,0 +1,120 @@
"""Services 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 Service
router = APIRouter()
class ServiceCreate(BaseModel):
name: str = Field(..., max_length=100)
slug: str = Field(..., max_length=50, pattern=r"^[a-z0-9-]+$")
description: str | None = None
group_name: str | None = Field(None, max_length=50)
position: int = 0
is_visible: bool = True
class ServiceUpdate(BaseModel):
name: str | None = None
slug: str | None = Field(None, max_length=50, pattern=r"^[a-z0-9-]+$")
description: str | None = None
group_name: str | None = None
position: int | None = None
is_visible: bool | None = None
def serialize_service(s: Service) -> dict:
return {
"id": s.id,
"name": s.name,
"slug": s.slug,
"description": s.description,
"group_name": s.group_name,
"position": s.position,
"is_visible": s.is_visible,
"created_at": s.created_at.isoformat() if s.created_at else None,
"updated_at": s.updated_at.isoformat() if s.updated_at else None,
}
@router.get("/")
async def list_services(db: AsyncSession = Depends(get_db)):
"""List all services."""
result = await db.execute(select(Service).order_by(Service.position, Service.name))
services = result.scalars().all()
return [serialize_service(s) for s in services]
@router.post("/", status_code=status.HTTP_201_CREATED)
async def create_service(
data: ServiceCreate,
db: AsyncSession = Depends(get_db),
api_key: str = Depends(verify_api_key),
):
"""Create a new service."""
service = Service(
name=data.name,
slug=data.slug,
description=data.description,
group_name=data.group_name,
position=data.position,
is_visible=data.is_visible,
)
db.add(service)
await db.flush()
await db.refresh(service)
return serialize_service(service)
@router.get("/{service_id}")
async def get_service(service_id: UUID, db: AsyncSession = Depends(get_db)):
"""Get a service by ID."""
result = await db.execute(select(Service).where(Service.id == str(service_id)))
service = result.scalar_one_or_none()
if not service:
raise HTTPException(status_code=404, detail="Service not found")
return serialize_service(service)
@router.patch("/{service_id}")
async def update_service(
service_id: UUID,
data: ServiceUpdate,
db: AsyncSession = Depends(get_db),
api_key: str = Depends(verify_api_key),
):
"""Update a service."""
result = await db.execute(select(Service).where(Service.id == str(service_id)))
service = result.scalar_one_or_none()
if not service:
raise HTTPException(status_code=404, detail="Service not found")
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(service, field, value)
await db.flush()
await db.refresh(service)
return serialize_service(service)
@router.delete("/{service_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_service(
service_id: UUID,
db: AsyncSession = Depends(get_db),
api_key: str = Depends(verify_api_key),
):
"""Delete a service."""
result = await db.execute(select(Service).where(Service.id == str(service_id)))
service = result.scalar_one_or_none()
if not service:
raise HTTPException(status_code=404, detail="Service not found")
await db.delete(service)

36
app/api/settings.py Normal file
View file

@ -0,0 +1,36 @@
"""Site settings API endpoints."""
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, verify_api_key
from app.models.models import SiteSetting
router = APIRouter()
@router.get("/")
async def list_settings(db: AsyncSession = Depends(get_db)):
"""List all site settings."""
result = await db.execute(select(SiteSetting))
settings = result.scalars().all()
return {s.key: s.value for s in settings}
@router.patch("/")
async def update_settings(
updates: dict[str, str],
db: AsyncSession = Depends(get_db),
api_key: str = Depends(verify_api_key),
):
"""Update site settings (key-value pairs)."""
for key, value in updates.items():
result = await db.execute(select(SiteSetting).where(SiteSetting.key == key))
setting = result.scalar_one_or_none()
if setting:
setting.value = value
else:
db.add(SiteSetting(key=key, value=value))
await db.flush()
return {"message": "Settings updated"}

84
app/api/subscribers.py Normal file
View file

@ -0,0 +1,84 @@
"""Subscribers API endpoints."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, verify_api_key
from app.models.models import Subscriber
router = APIRouter()
@router.get("/")
async def list_subscribers(db: AsyncSession = Depends(get_db)):
"""List all subscribers."""
result = await db.execute(select(Subscriber))
subscribers = result.scalars().all()
return [
{
"id": s.id,
"email": s.email,
"is_confirmed": s.is_confirmed,
"created_at": s.created_at.isoformat() if s.created_at else None,
}
for s in subscribers
]
@router.post("/", status_code=status.HTTP_201_CREATED)
async def create_subscriber(
email: str,
db: AsyncSession = Depends(get_db),
api_key: str = Depends(verify_api_key),
):
"""Add a new subscriber."""
import uuid
subscriber = Subscriber(
email=email,
confirm_token=str(uuid.uuid4()),
)
db.add(subscriber)
await db.flush()
await db.refresh(subscriber)
return {
"id": subscriber.id,
"email": subscriber.email,
"confirm_token": subscriber.confirm_token,
}
@router.delete("/{subscriber_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_subscriber(
subscriber_id: UUID,
db: AsyncSession = Depends(get_db),
api_key: str = Depends(verify_api_key),
):
"""Remove a subscriber."""
result = await db.execute(select(Subscriber).where(Subscriber.id == str(subscriber_id)))
subscriber = result.scalar_one_or_none()
if not subscriber:
raise HTTPException(status_code=404, detail="Subscriber not found")
await db.delete(subscriber)
@router.post("/{subscriber_id}/confirm")
async def confirm_subscriber(
subscriber_id: UUID,
token: str,
db: AsyncSession = Depends(get_db),
):
"""Confirm a subscriber's email address."""
result = await db.execute(select(Subscriber).where(Subscriber.id == str(subscriber_id)))
subscriber = result.scalar_one_or_none()
if not subscriber:
raise HTTPException(status_code=404, detail="Subscriber not found")
if subscriber.confirm_token != token:
raise HTTPException(status_code=400, detail="Invalid confirmation token")
subscriber.is_confirmed = True
subscriber.confirm_token = None
await db.flush()
return {"message": "Subscriber confirmed", "email": subscriber.email}