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:
commit
902133edd3
4655 changed files with 1342691 additions and 0 deletions
213
app/main.py
Normal file
213
app/main.py
Normal 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(),
|
||||
},
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue