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/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/api/__init__.py
Normal file
0
app/api/__init__.py
Normal file
195
app/api/incidents.py
Normal file
195
app/api/incidents.py
Normal 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
166
app/api/monitors.py
Normal 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
15
app/api/router.py
Normal 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
120
app/api/services.py
Normal 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
36
app/api/settings.py
Normal 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
84
app/api/subscribers.py
Normal 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
44
app/config.py
Normal 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
26
app/database.py
Normal 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
26
app/dependencies.py
Normal 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
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(),
|
||||
},
|
||||
)
|
||||
21
app/models/__init__.py
Normal file
21
app/models/__init__.py
Normal 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
151
app/models/models.py
Normal 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
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
|
||||
172
app/static/css/style.css
Normal file
172
app/static/css/style.css
Normal 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
22
app/static/js/status.js
Normal 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
22
app/templates/base.html
Normal 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>© {{ now.year }} {{ site_name }}. Powered by Indie Status Page.</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
9
app/templates/confirm.html
Normal file
9
app/templates/confirm.html
Normal 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 %}
|
||||
26
app/templates/incident.html
Normal file
26
app/templates/incident.html
Normal 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
49
app/templates/status.html
Normal 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 %}
|
||||
12
app/templates/subscribe.html
Normal file
12
app/templates/subscribe.html
Normal 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 %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue