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

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)