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

213
app/main.py Normal file
View file

@ -0,0 +1,213 @@
from contextlib import asynccontextmanager
from datetime import datetime
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from app.config import settings
from app.database import init_db
from app.api.router import api_v1_router
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan: create DB directories and tables on startup."""
# Ensure the data directory exists for SQLite
db_path = settings.db_path
db_path.parent.mkdir(parents=True, exist_ok=True)
# Create tables (dev mode; use Alembic in production)
await init_db()
# Start the uptime monitoring scheduler
from app.services.scheduler import start_scheduler, shutdown_scheduler
start_scheduler()
yield
# Shutdown scheduler on exit
shutdown_scheduler()
app = FastAPI(
title=settings.app_name,
version="0.1.0",
description="Lightweight status page tool for indie SaaS developers",
lifespan=lifespan,
)
# API routes
app.include_router(api_v1_router, prefix="/api/v1")
# Static files and templates
app.mount("/static", StaticFiles(directory="app/static"), name="static")
templates = Jinja2Templates(directory="app/templates")
@app.get("/health")
async def health_check():
"""Health check endpoint for container orchestration."""
return {"status": "ok", "version": "0.1.0"}
async def _get_service_status(service_id: str, db) -> str:
"""Derive a service's current status from its monitors' latest results."""
from sqlalchemy import select
from app.models.models import Monitor, MonitorResult
# Get all monitors for this service
result = await db.execute(
select(Monitor).where(Monitor.service_id == service_id, Monitor.is_active == True) # noqa: E712
)
monitors = result.scalars().all()
if not monitors:
return "up" # No monitors = assume operational
# For each monitor, get the latest result
worst_status = "up"
status_priority = {"up": 0, "degraded": 1, "down": 2}
for monitor in monitors:
r = await db.execute(
select(MonitorResult)
.where(MonitorResult.monitor_id == monitor.id)
.order_by(MonitorResult.checked_at.desc())
.limit(1)
)
latest = r.scalar_one_or_none()
if latest:
if status_priority.get(latest.status, 0) > status_priority.get(worst_status, 0):
worst_status = latest.status
return worst_status
@app.get("/")
async def status_page(request: Request):
"""Public status page — shows all visible services and recent incidents."""
from sqlalchemy import select
from app.database import async_session_factory
from app.models.models import Service, Incident
async with async_session_factory() as db:
# Get all visible services, ordered by position then name
result = await db.execute(
select(Service).where(Service.is_visible == True).order_by(Service.position, Service.name) # noqa: E712
)
services = result.scalars().all()
# Build services_by_group dict and attach current_status
services_by_group = {}
service_list = []
for s in services:
current_status = await _get_service_status(s.id, db)
svc_data = {
"id": s.id,
"name": s.name,
"slug": s.slug,
"description": s.description,
"group_name": s.group_name,
"position": s.position,
"current_status": current_status,
}
service_list.append(svc_data)
group = s.group_name or "Services"
if group not in services_by_group:
services_by_group[group] = []
services_by_group[group].append(svc_data)
# Get recent unresolved + recently resolved incidents
result = await db.execute(
select(Incident).order_by(Incident.started_at.desc()).limit(20)
)
incidents = result.scalars().all()
incident_list = [
{
"id": str(i.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,
"service_id": str(i.service_id),
}
for i in incidents
]
# Check for active (unresolved) incidents
has_active = any(i["status"] != "resolved" for i in incident_list)
return templates.TemplateResponse(
"status.html",
{
"request": request,
"site_name": settings.site_name,
"services_by_group": services_by_group,
"incidents": incident_list,
"has_active_incidents": has_active,
"now": datetime.utcnow(),
},
)
@app.get("/incident/{incident_id}")
async def incident_detail_page(request: Request, incident_id: str):
"""Public incident detail page with timeline of updates."""
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.database import async_session_factory
from app.models.models import Incident, IncidentUpdate
async with async_session_factory() as db:
result = await db.execute(
select(Incident)
.options(selectinload(Incident.updates))
.where(Incident.id == incident_id)
)
incident = result.scalar_one_or_none()
if not incident:
from fastapi.responses import HTMLResponse
return HTMLResponse("<h1>Incident not found</h1>", status_code=404)
incident_data = {
"id": str(incident.id),
"title": incident.title,
"status": incident.status,
"severity": incident.severity,
"started_at": incident.started_at.isoformat() if incident.started_at else None,
"resolved_at": incident.resolved_at.isoformat() if incident.resolved_at else None,
}
# Eagerly load updates
updates_result = await db.execute(
select(IncidentUpdate)
.where(IncidentUpdate.incident_id == incident_id)
.order_by(IncidentUpdate.created_at.asc())
)
updates = updates_result.scalars().all()
updates_list = [
{
"id": str(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 templates.TemplateResponse(
"incident.html",
{
"request": request,
"site_name": settings.site_name,
"incident": incident_data,
"updates": updates_list,
"now": datetime.utcnow(),
},
)