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/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