- 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
213 lines
No EOL
6.9 KiB
Python
213 lines
No EOL
6.9 KiB
Python
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(),
|
|
},
|
|
) |