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
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
110
app/services/notifier.py
Normal file
110
app/services/notifier.py
Normal 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
59
app/services/scheduler.py
Normal 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
59
app/services/uptime.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue