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/__init__.py Normal file
View file

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}

44
app/config.py Normal file
View file

@ -0,0 +1,44 @@
from pydantic_settings import BaseSettings
from pathlib import Path
class Settings(BaseSettings):
"""Application settings loaded from environment variables or .env file."""
# App
app_name: str = "Indie Status Page"
database_url: str = "sqlite+aiosqlite:///./data/statuspage.db"
secret_key: str = "change-me-to-a-random-string"
admin_api_key: str = "change-me-to-a-secure-api-key"
debug: bool = False
# Site
site_name: str = "My SaaS Status"
site_url: str = "http://localhost:8000"
site_logo_url: str = ""
site_accent_color: str = "#4f46e5"
# SMTP
smtp_host: str = ""
smtp_port: int = 587
smtp_user: str = ""
smtp_pass: str = ""
smtp_from: str = "noreply@example.com"
# Webhook
webhook_notify_url: str = ""
# Uptime monitoring
monitor_check_interval: int = 60
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
@property
def db_path(self) -> Path:
"""Extract filesystem path from SQLite URL for directory creation."""
# Remove the sqlite+aiosqlite:/// prefix
path_str = self.database_url.replace("sqlite+aiosqlite:///", "")
return Path(path_str)
settings = Settings()

26
app/database.py Normal file
View file

@ -0,0 +1,26 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
engine = create_async_engine(
settings.database_url,
echo=settings.debug,
future=True,
)
async_session_factory = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
class Base(DeclarativeBase):
pass
async def init_db() -> None:
"""Create all tables (used for dev/first run; in production use Alembic)."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

26
app/dependencies.py Normal file
View file

@ -0,0 +1,26 @@
from fastapi import Depends, Header, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import async_session_factory
async def get_db() -> AsyncSession:
"""FastAPI dependency that yields an async database session."""
async with async_session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
async def verify_api_key(x_api_key: str = Header(...)) -> str:
"""Validate the X-API-Key header against the configured admin key."""
if x_api_key != settings.admin_api_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
)
return x_api_key

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

21
app/models/__init__.py Normal file
View file

@ -0,0 +1,21 @@
from app.models.models import (
Service,
Incident,
IncidentUpdate,
Monitor,
MonitorResult,
Subscriber,
NotificationLog,
SiteSetting,
)
__all__ = [
"Service",
"Incident",
"IncidentUpdate",
"Monitor",
"MonitorResult",
"Subscriber",
"NotificationLog",
"SiteSetting",
]

151
app/models/models.py Normal file
View file

@ -0,0 +1,151 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Integer, String, Text, ForeignKey, Float
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def _uuid_str() -> str:
return str(uuid.uuid4())
class Service(Base):
__tablename__ = "services"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid_str)
name: Mapped[str] = mapped_column(String(100), nullable=False)
slug: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
group_name: Mapped[str | None] = mapped_column(String(50), nullable=True)
position: Mapped[int] = mapped_column(Integer, default=0)
is_visible: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)
incidents: Mapped[list["Incident"]] = relationship(back_populates="service")
monitors: Mapped[list["Monitor"]] = relationship(back_populates="service")
class Incident(Base):
__tablename__ = "incidents"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid_str)
service_id: Mapped[str] = mapped_column(
String(36), ForeignKey("services.id"), nullable=False, index=True
)
title: Mapped[str] = mapped_column(String(200), nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
# investigating | identified | monitoring | resolved
severity: Mapped[str] = mapped_column(String(20), nullable=False)
# minor | major | outage
started_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
resolved_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)
service: Mapped["Service"] = relationship(back_populates="incidents")
updates: Mapped[list["IncidentUpdate"]] = relationship(
back_populates="incident", cascade="all, delete-orphan"
)
notifications: Mapped[list["NotificationLog"]] = relationship(back_populates="incident")
class IncidentUpdate(Base):
__tablename__ = "incident_updates"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid_str)
incident_id: Mapped[str] = mapped_column(
String(36), ForeignKey("incidents.id"), nullable=False, index=True
)
status: Mapped[str] = mapped_column(String(20), nullable=False)
body: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
incident: Mapped["Incident"] = relationship(back_populates="updates")
class Monitor(Base):
__tablename__ = "monitors"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid_str)
service_id: Mapped[str] = mapped_column(
String(36), ForeignKey("services.id"), nullable=False, index=True
)
url: Mapped[str] = mapped_column(String(500), nullable=False)
method: Mapped[str] = mapped_column(String(10), default="GET")
expected_status: Mapped[int] = mapped_column(Integer, default=200)
timeout_seconds: Mapped[int] = mapped_column(Integer, default=10)
interval_seconds: Mapped[int] = mapped_column(Integer, default=60)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)
service: Mapped["Service"] = relationship(back_populates="monitors")
results: Mapped[list["MonitorResult"]] = relationship(
back_populates="monitor", cascade="all, delete-orphan"
)
class MonitorResult(Base):
__tablename__ = "monitor_results"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid_str)
monitor_id: Mapped[str] = mapped_column(
String(36), ForeignKey("monitors.id"), nullable=False, index=True
)
status: Mapped[str] = mapped_column(String(20), nullable=False) # up | down | degraded
response_time_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
status_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
checked_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
monitor: Mapped["Monitor"] = relationship(back_populates="results")
class Subscriber(Base):
__tablename__ = "subscribers"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid_str)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
is_confirmed: Mapped[bool] = mapped_column(Boolean, default=False)
confirm_token: Mapped[str | None] = mapped_column(String(100), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
notifications: Mapped[list["NotificationLog"]] = relationship(back_populates="subscriber")
class NotificationLog(Base):
__tablename__ = "notification_logs"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid_str)
incident_id: Mapped[str] = mapped_column(
String(36), ForeignKey("incidents.id"), nullable=False, index=True
)
subscriber_id: Mapped[str] = mapped_column(
String(36), ForeignKey("subscribers.id"), nullable=False
)
channel: Mapped[str] = mapped_column(String(20), nullable=False) # email | webhook
status: Mapped[str] = mapped_column(String(20), nullable=False) # sent | failed
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
incident: Mapped["Incident"] = relationship(back_populates="notifications")
subscriber: Mapped["Subscriber"] = relationship(back_populates="notifications")
class SiteSetting(Base):
__tablename__ = "site_settings"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid_str)
key: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
value: Mapped[str | None] = mapped_column(Text, nullable=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)

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

110
app/services/notifier.py Normal file
View file

@ -0,0 +1,110 @@
"""Notification service — email (SMTP) and webhook dispatch."""
import json
import smtplib
from email.mime.text import MIMEText
from datetime import datetime
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.models import Incident, NotificationLog, Subscriber
async def send_email_notification(
to_email: str,
subject: str,
body: str,
) -> bool:
"""Send an email notification via SMTP. Returns True if successful."""
if not settings.smtp_host:
return False
msg = MIMEText(body, "html")
msg["Subject"] = subject
msg["From"] = settings.smtp_from
msg["To"] = to_email
try:
with smtplib.SMTP(settings.smtp_host, settings.smtp_port) as server:
if settings.smtp_user:
server.starttls()
server.login(settings.smtp_user, settings.smtp_pass)
server.send_message(msg)
return True
except Exception:
return False
async def send_webhook_notification(
payload: dict,
) -> bool:
"""Send a webhook POST notification. Returns True if successful."""
if not settings.webhook_notify_url:
return False
try:
async with httpx.AsyncClient() as client:
response = await client.post(
settings.webhook_notify_url,
json=payload,
timeout=10.0,
)
return response.status_code < 400
except Exception:
return False
async def notify_subscribers(
incident: Incident,
db: AsyncSession,
) -> int:
"""Notify all confirmed subscribers about an incident update. Returns count notified."""
result = await db.execute(
select(Subscriber).where(Subscriber.is_confirmed == True) # noqa: E712
)
subscribers = result.scalars().all()
notified = 0
subject = f"[{incident.severity.upper()}] {incident.title}"
for subscriber in subscribers:
# Email notification
email_sent = await send_email_notification(
to_email=subscriber.email,
subject=subject,
body=f"<p>{incident.title}</p><p>Status: {incident.status}</p>",
)
if email_sent:
log = NotificationLog(
incident_id=incident.id,
subscriber_id=subscriber.id,
channel="email",
status="sent",
)
db.add(log)
notified += 1
# Webhook notification
webhook_sent = await send_webhook_notification(
payload={
"incident_id": incident.id,
"title": incident.title,
"status": incident.status,
"severity": incident.severity,
"started_at": incident.started_at.isoformat() if incident.started_at else None,
}
)
if webhook_sent:
log = NotificationLog(
incident_id=incident.id,
subscriber_id=subscriber.id,
channel="webhook",
status="sent",
)
db.add(log)
await db.flush()
return notified

59
app/services/scheduler.py Normal file
View file

@ -0,0 +1,59 @@
"""Background scheduler for uptime monitoring using APScheduler."""
import asyncio
import logging
from datetime import datetime
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import async_session_factory
from app.models.models import Monitor
from app.services.uptime import check_monitor
logger = logging.getLogger(__name__)
_scheduler: AsyncIOScheduler | None = None
async def _run_monitor_checks() -> None:
"""Check all active monitors."""
async with async_session_factory() as db:
result = await db.execute(select(Monitor).where(Monitor.is_active == True)) # noqa: E712
monitors = result.scalars().all()
for monitor in monitors:
try:
await check_monitor(monitor, db)
except Exception as exc:
logger.error(f"Monitor check failed for {monitor.url}: {exc}")
await db.commit()
def start_scheduler() -> None:
"""Start the APScheduler with periodic monitor checks."""
global _scheduler
if _scheduler is not None:
return
_scheduler = AsyncIOScheduler()
_scheduler.add_job(
_run_monitor_checks,
"interval",
seconds=60,
id="monitor_checks",
replace_existing=True,
)
_scheduler.start()
logger.info("Uptime monitoring scheduler started (interval: 60s)")
def shutdown_scheduler() -> None:
"""Gracefully shut down the scheduler."""
global _scheduler
if _scheduler is not None:
_scheduler.shutdown(wait=False)
_scheduler = None
logger.info("Uptime monitoring scheduler stopped")

59
app/services/uptime.py Normal file
View file

@ -0,0 +1,59 @@
"""Uptime monitoring service — performs HTTP health checks."""
import time
from datetime import datetime
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.models import Monitor, MonitorResult
async def check_monitor(monitor: Monitor, db: AsyncSession) -> MonitorResult:
"""Perform a single HTTP health check for a monitor and store the result."""
start = time.monotonic()
try:
async with httpx.AsyncClient() as client:
response = await client.request(
method=monitor.method,
url=monitor.url,
timeout=monitor.timeout_seconds,
follow_redirects=True,
)
elapsed_ms = int((time.monotonic() - start) * 1000)
if response.status_code == monitor.expected_status:
# Check response time threshold for "degraded"
status = "up" if elapsed_ms < 5000 else "degraded"
result = MonitorResult(
monitor_id=monitor.id,
status=status,
response_time_ms=elapsed_ms,
status_code=response.status_code,
error_message=None,
checked_at=datetime.utcnow(),
)
else:
result = MonitorResult(
monitor_id=monitor.id,
status="down",
response_time_ms=elapsed_ms,
status_code=response.status_code,
error_message=f"Expected {monitor.expected_status}, got {response.status_code}",
checked_at=datetime.utcnow(),
)
except Exception as exc:
elapsed_ms = int((time.monotonic() - start) * 1000)
result = MonitorResult(
monitor_id=monitor.id,
status="down",
response_time_ms=elapsed_ms,
status_code=None,
error_message=str(exc)[:500],
checked_at=datetime.utcnow(),
)
db.add(result)
await db.flush()
return result

172
app/static/css/style.css Normal file
View file

@ -0,0 +1,172 @@
/* Indie Status Page — Minimal responsive styles */
:root {
--accent: #4f46e5;
--up: #16a34a;
--down: #dc2626;
--degraded: #f59e0b;
--bg: #f9fafb;
--text: #111827;
--border: #e5e7eb;
--card-bg: #ffffff;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
}
.container {
max-width: 720px;
margin: 0 auto;
padding: 0 1.5rem;
}
header {
border-bottom: 1px solid var(--border);
padding: 1.5rem 0;
}
header h1 a {
color: var(--text);
text-decoration: none;
font-size: 1.5rem;
}
main {
padding: 2rem 0;
min-height: 70vh;
}
footer {
border-top: 1px solid var(--border);
padding: 1.5rem 0;
color: #6b7280;
font-size: 0.85rem;
}
/* Status banners */
.status-banner {
padding: 1rem;
border-radius: 8px;
text-align: center;
font-weight: 600;
margin-bottom: 2rem;
}
.status-banner--operational {
background: #d1fae5;
color: #065f46;
}
.status-banner--major {
background: #fee2e2;
color: #991b1b;
}
/* Service rows */
.service-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 0.5rem;
}
.service-name {
font-weight: 500;
}
.service-status {
font-size: 0.8rem;
font-weight: 600;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
}
.status-up { background: #d1fae5; color: #065f46; }
.status-down { background: #fee2e2; color: #991b1b; }
.status-degraded { background: #fef3c7; color: #92400e; }
/* Severity badges */
.severity {
font-size: 0.75rem;
font-weight: 600;
padding: 0.15rem 0.5rem;
border-radius: 4px;
margin-right: 0.5rem;
}
.severity-minor { background: #dbeafe; color: #1e40af; }
.severity-major { background: #fef3c7; color: #92400e; }
.severity-outage { background: #fee2e2; color: #991b1b; }
/* Incident cards */
.incident-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 1rem;
margin-bottom: 1rem;
}
.incident-card h3 a { color: var(--text); }
.incident-card h3 a:hover { color: var(--accent); }
.incident-card .timestamp { color: #6b7280; font-size: 0.85rem; }
/* Subscribe form */
.subscribe, .subscribe-page {
margin-top: 2rem;
padding: 1.5rem;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
}
form {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
input[type="email"] {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 4px;
font-size: 0.95rem;
}
button[type="submit"] {
padding: 0.5rem 1.25rem;
background: var(--accent);
color: white;
border: none;
border-radius: 4px;
font-weight: 600;
cursor: pointer;
}
button[type="submit"]:hover { opacity: 0.9; }
/* Timeline */
.timeline-entry {
padding: 1rem 0;
border-left: 3px solid var(--border);
padding-left: 1.5rem;
margin-left: 0.5rem;
}
.timeline-status { font-weight: 600; }
.timeline-body { margin: 0.25rem 0; }
.timeline-time { color: #6b7280; font-size: 0.85rem; }
/* Confirm page */
.confirm-page { text-align: center; padding: 3rem 0; }

22
app/static/js/status.js Normal file
View file

@ -0,0 +1,22 @@
/* Minimal JS for auto-refreshing the status page every 60 seconds */
(function () {
const REFRESH_INTERVAL = 60000;
function autoRefresh() {
setTimeout(function () {
fetch(window.location.href, { headers: { "X-Requested-With": "XMLHttpRequest" } })
.then(function () {
window.location.reload();
})
.catch(function () {
// Silently fail — the page will try again next interval
});
}, REFRESH_INTERVAL);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", autoRefresh);
} else {
autoRefresh();
}
})();

22
app/templates/base.html Normal file
View file

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Status{% endblock %} — {{ site_name }}</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header>
<div class="container">
<h1><a href="/">{{ site_name }}</a></h1>
</div>
</header>
<main class="container">
{% block content %}{% endblock %}
</main>
<footer class="container">
<p>&copy; {{ now.year }} {{ site_name }}. Powered by Indie Status Page.</p>
</footer>
</body>
</html>

View file

@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block title %}Confirmed{% endblock %}
{% block content %}
<div class="confirm-page">
<h1>✅ Subscription Confirmed</h1>
<p>You're now subscribed to status updates. You'll receive notifications when incidents occur.</p>
<a href="/">← Back to Status Page</a>
</div>
{% endblock %}

View file

@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}Incident: {{ incident.title }}{% endblock %}
{% block content %}
<div class="incident-detail">
<a href="/">← Back to Status Page</a>
<h1>{{ incident.title }}</h1>
<div class="incident-meta">
<span class="severity severity-{{ incident.severity }}">{{ incident.severity | title }}</span>
<span class="incident-status">{{ incident.status | title }}</span>
<p>Started: {{ incident.started_at }}</p>
{% if incident.resolved_at %}
<p>Resolved: {{ incident.resolved_at }}</p>
{% endif %}
</div>
<div class="timeline">
{% for update in updates %}
<div class="timeline-entry">
<div class="timeline-status">{{ update.status | title }}</div>
<div class="timeline-body">{{ update.body }}</div>
<div class="timeline-time">{{ update.created_at }}</div>
</div>
{% endfor %}
</div>
</div>
{% endblock %}

49
app/templates/status.html Normal file
View file

@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block title %}Status{% endblock %}
{% block content %}
<div class="status-page">
<section class="overall-status">
{% if has_active_incidents %}
<div class="status-banner status-banner--major">Active Incident</div>
{% else %}
<div class="status-banner status-banner--operational">All Systems Operational</div>
{% endif %}
</section>
<section class="services">
{% for group_name, group_services in services_by_group.items() %}
<h2>{{ group_name or "Services" }}</h2>
{% for service in group_services %}
<div class="service-row">
<span class="service-name">{{ service.name }}</span>
<span class="service-status {% if service.current_status == 'up' %}status-up{% elif service.current_status == 'down' %}status-down{% else %}status-degraded{% endif %}">
{{ service.current_status | title }}
</span>
</div>
{% endfor %}
{% endfor %}
</section>
{% if incidents %}
<section class="incidents">
<h2>Recent Incidents</h2>
{% for incident in incidents %}
<div class="incident-card">
<h3><a href="/incident/{{ incident.id }}">{{ incident.title }}</a></h3>
<span class="severity severity-{{ incident.severity }}">{{ incident.severity | title }}</span>
<span class="incident-status">{{ incident.status | title }}</span>
<p class="timestamp">{{ incident.started_at }}</p>
</div>
{% endfor %}
</section>
{% endif %}
<section class="subscribe">
<p>Get notified when incidents occur:</p>
<form action="/subscribe" method="POST">
<input type="email" name="email" placeholder="you@example.com" required>
<button type="submit">Subscribe</button>
</form>
</section>
</div>
{% endblock %}

View file

@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}Subscribe{% endblock %}
{% block content %}
<div class="subscribe-page">
<h1>Subscribe to Updates</h1>
<p>Get email notifications when we create or update incidents.</p>
<form action="/subscribe" method="POST">
<input type="email" name="email" placeholder="you@example.com" required>
<button type="submit">Subscribe</button>
</form>
</div>
{% endblock %}