Initial InfraPulse scaffold

This commit is contained in:
Keith Smith
2026-05-22 17:36:40 -06:00
commit a707186a5e
92 changed files with 6918 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""InfraPulse backend package."""
+1
View File
@@ -0,0 +1 @@
"""API routers."""
+106
View File
@@ -0,0 +1,106 @@
from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user, require_role
from app.db.session import get_db
from app.models import AlertRule, Incident, User
from app.schemas.core import AlertRuleCreate, AlertRuleRead, AlertRuleUpdate, IncidentRead
router = APIRouter(tags=["alerts"])
@router.get("/alerts/rules", response_model=list[AlertRuleRead])
def list_alert_rules(_: User = Depends(get_current_user), db: Session = Depends(get_db)) -> list[AlertRule]:
return list(db.scalars(select(AlertRule).order_by(AlertRule.name)).all())
@router.post("/alerts/rules", response_model=AlertRuleRead)
def create_alert_rule(
payload: AlertRuleCreate,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> AlertRule:
rule = AlertRule(**payload.model_dump())
db.add(rule)
db.commit()
db.refresh(rule)
return rule
@router.patch("/alerts/rules/{rule_id}", response_model=AlertRuleRead)
def update_alert_rule(
rule_id: int,
payload: AlertRuleUpdate,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> AlertRule:
rule = db.get(AlertRule, rule_id)
if rule is None:
raise HTTPException(status_code=404, detail="Alert rule not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(rule, field, value)
db.commit()
db.refresh(rule)
return rule
@router.delete("/alerts/rules/{rule_id}", status_code=204)
def delete_alert_rule(
rule_id: int,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> None:
rule = db.get(AlertRule, rule_id)
if rule is None:
raise HTTPException(status_code=404, detail="Alert rule not found")
db.delete(rule)
db.commit()
@router.get("/incidents", response_model=list[IncidentRead])
def list_incidents(_: User = Depends(get_current_user), db: Session = Depends(get_db)) -> list[Incident]:
return list(db.scalars(select(Incident).order_by(Incident.opened_at.desc())).all())
@router.get("/incidents/{incident_id}", response_model=IncidentRead)
def get_incident(incident_id: int, _: User = Depends(get_current_user), db: Session = Depends(get_db)) -> Incident:
incident = db.get(Incident, incident_id)
if incident is None:
raise HTTPException(status_code=404, detail="Incident not found")
return incident
@router.post("/incidents/{incident_id}/acknowledge", response_model=IncidentRead)
def acknowledge_incident(
incident_id: int,
_: User = Depends(require_role("operator")),
db: Session = Depends(get_db),
) -> Incident:
incident = db.get(Incident, incident_id)
if incident is None:
raise HTTPException(status_code=404, detail="Incident not found")
incident.acknowledged_at = datetime.now(UTC)
db.commit()
db.refresh(incident)
return incident
@router.post("/incidents/{incident_id}/silence", response_model=IncidentRead)
def silence_incident(
incident_id: int,
minutes: int = 60,
_: User = Depends(require_role("operator")),
db: Session = Depends(get_db),
) -> Incident:
incident = db.get(Incident, incident_id)
if incident is None:
raise HTTPException(status_code=404, detail="Incident not found")
incident.silenced_until = datetime.now(UTC) + timedelta(minutes=minutes)
db.commit()
db.refresh(incident)
return incident
+90
View File
@@ -0,0 +1,90 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user, require_role
from app.db.session import get_db
from app.models import Asset, User
from app.schemas.core import AssetCreate, AssetRead, AssetUpdate
router = APIRouter(prefix="/assets", tags=["assets"])
def _asset_to_read(asset: Asset) -> AssetRead:
return AssetRead(
id=asset.id,
name=asset.name,
asset_type=asset.asset_type,
address=asset.address,
status=asset.status,
metadata=asset.extra,
created_at=asset.created_at,
updated_at=asset.updated_at,
)
@router.get("", response_model=list[AssetRead])
def list_assets(_: User = Depends(get_current_user), db: Session = Depends(get_db)) -> list[AssetRead]:
assets = db.scalars(select(Asset).order_by(Asset.name)).all()
return [_asset_to_read(asset) for asset in assets]
@router.post("", response_model=AssetRead)
def create_asset(
payload: AssetCreate,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> AssetRead:
asset = Asset(
name=payload.name,
asset_type=payload.asset_type,
address=payload.address,
extra=payload.metadata,
)
db.add(asset)
db.commit()
db.refresh(asset)
return _asset_to_read(asset)
@router.get("/{asset_id}", response_model=AssetRead)
def get_asset(asset_id: int, _: User = Depends(get_current_user), db: Session = Depends(get_db)) -> AssetRead:
asset = db.get(Asset, asset_id)
if asset is None:
raise HTTPException(status_code=404, detail="Asset not found")
return _asset_to_read(asset)
@router.patch("/{asset_id}", response_model=AssetRead)
def update_asset(
asset_id: int,
payload: AssetUpdate,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> AssetRead:
asset = db.get(Asset, asset_id)
if asset is None:
raise HTTPException(status_code=404, detail="Asset not found")
changes = payload.model_dump(exclude_unset=True)
if "metadata" in changes:
asset.extra = changes.pop("metadata")
for field, value in changes.items():
setattr(asset, field, value)
db.commit()
db.refresh(asset)
return _asset_to_read(asset)
@router.delete("/{asset_id}", status_code=204)
def delete_asset(
asset_id: int,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> None:
asset = db.get(Asset, asset_id)
if asset is None:
raise HTTPException(status_code=404, detail="Asset not found")
db.delete(asset)
db.commit()
+32
View File
@@ -0,0 +1,32 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user
from app.auth.security import create_access_token, verify_password
from app.db.session import get_db
from app.models import User
from app.schemas.auth import TokenResponse, UserRead
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login", response_model=TokenResponse)
def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)) -> TokenResponse:
user = db.scalar(select(User).where(User.email == form.username))
if user is None or not verify_password(form.password, user.hashed_password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is disabled")
return TokenResponse(access_token=create_access_token(user.email))
@router.post("/logout")
def logout() -> dict[str, str]:
return {"status": "ok"}
@router.get("/me", response_model=UserRead)
def me(user: User = Depends(get_current_user)) -> User:
return user
+8
View File
@@ -0,0 +1,8 @@
from fastapi import APIRouter
router = APIRouter(tags=["health"])
@router.get("/health")
def health() -> dict[str, str]:
return {"status": "ok", "service": "infrapulse-backend"}
+156
View File
@@ -0,0 +1,156 @@
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user, require_role
from app.db.session import get_db
from app.models import AlertRule, Asset, CheckResult, Incident, Monitor, User
from app.schemas.core import CheckResultRead, MonitorCreate, MonitorRead, MonitorUpdate, WebsiteMonitorCreate
router = APIRouter(prefix="/monitors", tags=["monitors"])
@router.get("", response_model=list[MonitorRead])
def list_monitors(_: User = Depends(get_current_user), db: Session = Depends(get_db)) -> list[Monitor]:
return list(db.scalars(select(Monitor).order_by(Monitor.name)).all())
@router.post("", response_model=MonitorRead)
def create_monitor(
payload: MonitorCreate,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> Monitor:
monitor = Monitor(**payload.model_dump())
db.add(monitor)
db.commit()
db.refresh(monitor)
return monitor
@router.post("/website", response_model=MonitorRead)
def create_website_monitor(
payload: WebsiteMonitorCreate,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> Monitor:
asset_id: int | None = None
if payload.create_asset:
asset = Asset(name=payload.name, asset_type="website", address=payload.url, status="unknown", extra={})
db.add(asset)
db.flush()
asset_id = asset.id
monitor = Monitor(
asset_id=asset_id,
name=payload.name,
monitor_type="http",
target=payload.url,
config={
"expected_status": payload.expected_status,
"expected_text": payload.expected_text,
"unexpected_text": payload.unexpected_text,
"timeout_seconds": payload.timeout_seconds,
},
interval_seconds=payload.interval_seconds,
status="unknown",
)
db.add(monitor)
db.flush()
if payload.alert_enabled:
db.add(
AlertRule(
monitor_id=monitor.id,
name=f"{payload.name} website failure",
severity=payload.alert_severity,
condition={"type": "status_not_up"},
failure_threshold=payload.failure_threshold,
cooldown_seconds=300,
is_enabled=True,
)
)
db.commit()
db.refresh(monitor)
return monitor
@router.get("/{monitor_id}", response_model=MonitorRead)
def get_monitor(monitor_id: int, _: User = Depends(get_current_user), db: Session = Depends(get_db)) -> Monitor:
monitor = db.get(Monitor, monitor_id)
if monitor is None:
raise HTTPException(status_code=404, detail="Monitor not found")
return monitor
@router.patch("/{monitor_id}", response_model=MonitorRead)
def update_monitor(
monitor_id: int,
payload: MonitorUpdate,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> Monitor:
monitor = db.get(Monitor, monitor_id)
if monitor is None:
raise HTTPException(status_code=404, detail="Monitor not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(monitor, field, value)
db.commit()
db.refresh(monitor)
return monitor
@router.delete("/{monitor_id}", status_code=204)
def delete_monitor(
monitor_id: int,
cleanup_orphan_website_asset: bool = True,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> None:
monitor = db.get(Monitor, monitor_id)
if monitor is None:
raise HTTPException(status_code=404, detail="Monitor not found")
asset_id = monitor.asset_id
now = datetime.now(UTC)
open_incidents = db.scalars(select(Incident).where(Incident.monitor_id == monitor_id, Incident.status == "open")).all()
for incident in open_incidents:
incident.status = "resolved"
incident.resolved_at = now
incident.details = {**(incident.details or {}), "resolution_reason": "monitor_deleted"}
db.delete(monitor)
db.flush()
if cleanup_orphan_website_asset and asset_id is not None:
remaining = db.scalar(select(func.count(Monitor.id)).where(Monitor.asset_id == asset_id))
asset = db.get(Asset, asset_id)
if remaining == 0 and asset is not None and asset.asset_type == "website":
db.delete(asset)
db.commit()
@router.get("/{monitor_id}/results", response_model=list[CheckResultRead])
def list_monitor_results(
monitor_id: int,
limit: int = 20,
_: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> list[CheckResult]:
monitor = db.get(Monitor, monitor_id)
if monitor is None:
raise HTTPException(status_code=404, detail="Monitor not found")
return list(
db.scalars(
select(CheckResult)
.where(CheckResult.monitor_id == monitor_id)
.order_by(CheckResult.observed_at.desc())
.limit(min(limit, 100))
).all()
)
+117
View File
@@ -0,0 +1,117 @@
from fastapi import APIRouter, Depends, HTTPException
import httpx
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user, require_role
from app.core.secrets import decrypt_secret, encrypt_secret
from app.db.session import get_db
from app.models import NotificationChannel, User
from app.schemas.core import NotificationChannelCreate, NotificationChannelRead, NotificationChannelUpdate
router = APIRouter(prefix="/notifications/channels", tags=["notifications"])
def _channel_to_read(channel: NotificationChannel) -> NotificationChannelRead:
settings = dict(channel.settings or {})
settings.setdefault("username", "InfraPulse")
return NotificationChannelRead(
id=channel.id,
name=channel.name,
channel_type=channel.channel_type,
settings=settings,
has_secret=bool(channel.encrypted_secret),
is_enabled=channel.is_enabled,
created_at=channel.created_at,
updated_at=channel.updated_at,
)
@router.get("", response_model=list[NotificationChannelRead])
def list_channels(_: User = Depends(get_current_user), db: Session = Depends(get_db)) -> list[NotificationChannelRead]:
channels = db.scalars(select(NotificationChannel).order_by(NotificationChannel.name)).all()
return [_channel_to_read(channel) for channel in channels]
@router.post("", response_model=NotificationChannelRead)
def create_channel(
payload: NotificationChannelCreate,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> NotificationChannelRead:
channel_settings = dict(payload.settings or {})
channel_settings.setdefault("username", "InfraPulse")
channel = NotificationChannel(
name=payload.name,
channel_type=payload.channel_type,
settings=channel_settings,
encrypted_secret=encrypt_secret(payload.secret),
is_enabled=payload.is_enabled,
)
db.add(channel)
db.commit()
db.refresh(channel)
return _channel_to_read(channel)
@router.post("/{channel_id}/test")
def test_channel(
channel_id: int,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> dict[str, str]:
channel = db.get(NotificationChannel, channel_id)
if channel is None:
raise HTTPException(status_code=404, detail="Notification channel not found")
url = decrypt_secret(channel.encrypted_secret)
if not url:
raise HTTPException(status_code=400, detail="Notification channel has no usable secret URL")
try:
response = httpx.post(
url,
json={
"username": (channel.settings or {}).get("username") or "InfraPulse",
"text": f"InfraPulse test notification for {channel.name}",
},
timeout=10,
)
response.raise_for_status()
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail="Notification test failed") from exc
return {"status": "sent", "message": "Notification test sent"}
@router.patch("/{channel_id}", response_model=NotificationChannelRead)
def update_channel(
channel_id: int,
payload: NotificationChannelUpdate,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> NotificationChannelRead:
channel = db.get(NotificationChannel, channel_id)
if channel is None:
raise HTTPException(status_code=404, detail="Notification channel not found")
changes = payload.model_dump(exclude_unset=True)
secret = changes.pop("secret", None)
if secret is not None:
channel.encrypted_secret = encrypt_secret(secret)
for field, value in changes.items():
setattr(channel, field, value)
db.commit()
db.refresh(channel)
return _channel_to_read(channel)
@router.delete("/{channel_id}", status_code=204)
def delete_channel(
channel_id: int,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> None:
channel = db.get(NotificationChannel, channel_id)
if channel is None:
raise HTTPException(status_code=404, detail="Notification channel not found")
db.delete(channel)
db.commit()
+1
View File
@@ -0,0 +1 @@
"""Authentication helpers."""
+41
View File
@@ -0,0 +1,41 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.security import decode_access_token
from app.db.session import get_db
from app.models import User
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
ROLE_ORDER = {
"viewer": 10,
"operator": 20,
"admin": 30,
"owner": 40,
}
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
email = decode_access_token(token)
if not email:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication token",
headers={"WWW-Authenticate": "Bearer"},
)
user = db.scalar(select(User).where(User.email == email))
if user is None or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or missing user")
return user
def require_role(minimum_role: str):
def dependency(user: User = Depends(get_current_user)) -> User:
if ROLE_ORDER.get(user.role, 0) < ROLE_ORDER[minimum_role]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role")
return user
return dependency
+31
View File
@@ -0,0 +1,31 @@
from datetime import UTC, datetime, timedelta
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.core.config import settings
ALGORITHM = "HS256"
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(subject: str) -> str:
expires_at = datetime.now(UTC) + timedelta(minutes=settings.access_token_expire_minutes)
payload = {"sub": subject, "exp": expires_at}
return jwt.encode(payload, settings.infrapulse_secret_key, algorithm=ALGORITHM)
def decode_access_token(token: str) -> str | None:
try:
payload = jwt.decode(token, settings.infrapulse_secret_key, algorithms=[ALGORITHM])
return payload.get("sub")
except JWTError:
return None
+1
View File
@@ -0,0 +1 @@
"""Core backend configuration."""
+26
View File
@@ -0,0 +1,26 @@
from functools import lru_cache
from pydantic import AnyHttpUrl, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
infrapulse_env: str = "development"
infrapulse_secret_key: str = Field(default="change-me", min_length=8)
database_url: str = "postgresql+psycopg://infrapulse:infrapulse@postgres:5432/infrapulse"
redis_url: str = "redis://redis:6379/0"
frontend_url: AnyHttpUrl | str = "http://localhost:5173"
backend_url: AnyHttpUrl | str = "http://localhost:8000"
initial_admin_email: str = "admin@example.com"
initial_admin_password: str = "change-me"
access_token_expire_minutes: int = 60 * 12
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()
+26
View File
@@ -0,0 +1,26 @@
import base64
import hashlib
from cryptography.fernet import Fernet, InvalidToken
from app.core.config import settings
def _fernet() -> Fernet:
digest = hashlib.sha256(settings.infrapulse_secret_key.encode("utf-8")).digest()
return Fernet(base64.urlsafe_b64encode(digest))
def encrypt_secret(value: str | None) -> str | None:
if not value:
return None
return _fernet().encrypt(value.encode("utf-8")).decode("utf-8")
def decrypt_secret(value: str | None) -> str | None:
if not value:
return None
try:
return _fernet().decrypt(value.encode("utf-8")).decode("utf-8")
except InvalidToken:
return None
+1
View File
@@ -0,0 +1 @@
"""Database helpers."""
+3
View File
@@ -0,0 +1,3 @@
from app.models.core import Base
__all__ = ["Base"]
+17
View File
@@ -0,0 +1,17 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import settings
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
+47
View File
@@ -0,0 +1,47 @@
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
import logging
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.exc import SQLAlchemyError
from app.api import alerts, assets, auth, health, monitors, notifications
from app.core.config import settings
from app.db.session import SessionLocal
from app.services.bootstrap import ensure_initial_admin
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
try:
with SessionLocal() as db:
ensure_initial_admin(db)
except SQLAlchemyError as exc:
logger.warning("Initial admin bootstrap skipped because the database is unavailable: %s", exc)
yield
app = FastAPI(
title="InfraPulse API",
version="0.1.0",
description="Self-hosted infrastructure monitoring API",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=[str(settings.frontend_url)],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(health.router)
app.include_router(auth.router)
app.include_router(assets.router)
app.include_router(monitors.router)
app.include_router(alerts.router)
app.include_router(notifications.router)
+27
View File
@@ -0,0 +1,27 @@
from app.models.core import (
AlertRule,
Asset,
AuditEvent,
Base,
CheckResult,
Credential,
Incident,
Metric,
Monitor,
NotificationChannel,
User,
)
__all__ = [
"AlertRule",
"Asset",
"AuditEvent",
"Base",
"CheckResult",
"Credential",
"Incident",
"Metric",
"Monitor",
"NotificationChannel",
"User",
]
+138
View File
@@ -0,0 +1,138 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, JSON, String, Text, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class User(TimestampMixin, Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
display_name: Mapped[str] = mapped_column(String(120))
hashed_password: Mapped[str] = mapped_column(String(255))
role: Mapped[str] = mapped_column(String(32), default="owner")
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
class Asset(TimestampMixin, Base):
__tablename__ = "assets"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(160))
asset_type: Mapped[str] = mapped_column(String(64))
address: Mapped[str | None] = mapped_column(String(255), nullable=True)
status: Mapped[str] = mapped_column(String(32), default="unknown")
extra: Mapped[dict] = mapped_column("metadata", JSON, default=dict)
monitors: Mapped[list["Monitor"]] = relationship(back_populates="asset")
class Credential(TimestampMixin, Base):
__tablename__ = "credentials"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(160))
credential_type: Mapped[str] = mapped_column(String(64))
encrypted_secret: Mapped[str | None] = mapped_column(Text, nullable=True)
extra: Mapped[dict] = mapped_column("metadata", JSON, default=dict)
class NotificationChannel(TimestampMixin, Base):
__tablename__ = "notification_channels"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(160))
channel_type: Mapped[str] = mapped_column(String(64))
settings: Mapped[dict] = mapped_column(JSON, default=dict)
encrypted_secret: Mapped[str | None] = mapped_column(Text, nullable=True)
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
class Monitor(TimestampMixin, Base):
__tablename__ = "monitors"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
asset_id: Mapped[int | None] = mapped_column(ForeignKey("assets.id", ondelete="CASCADE"), nullable=True)
name: Mapped[str] = mapped_column(String(160))
monitor_type: Mapped[str] = mapped_column(String(64))
target: Mapped[str] = mapped_column(String(512))
config: Mapped[dict] = mapped_column(JSON, default=dict)
interval_seconds: Mapped[int] = mapped_column(Integer, default=60)
status: Mapped[str] = mapped_column(String(32), default="unknown")
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
asset: Mapped[Asset | None] = relationship(back_populates="monitors")
class CheckResult(Base):
__tablename__ = "check_results"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
monitor_id: Mapped[int] = mapped_column(ForeignKey("monitors.id", ondelete="CASCADE"))
status: Mapped[str] = mapped_column(String(32))
response_time_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
message: Mapped[str | None] = mapped_column(Text, nullable=True)
observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class Metric(Base):
__tablename__ = "metrics"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
monitor_id: Mapped[int] = mapped_column(ForeignKey("monitors.id", ondelete="CASCADE"))
name: Mapped[str] = mapped_column(String(120))
value: Mapped[float] = mapped_column(Float)
unit: Mapped[str | None] = mapped_column(String(32), nullable=True)
observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class AlertRule(TimestampMixin, Base):
__tablename__ = "alert_rules"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
monitor_id: Mapped[int] = mapped_column(ForeignKey("monitors.id", ondelete="CASCADE"))
name: Mapped[str] = mapped_column(String(160))
severity: Mapped[str] = mapped_column(String(32), default="warning")
condition: Mapped[dict] = mapped_column(JSON, default=dict)
failure_threshold: Mapped[int] = mapped_column(Integer, default=3)
cooldown_seconds: Mapped[int] = mapped_column(Integer, default=300)
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
class Incident(Base):
__tablename__ = "incidents"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
asset_id: Mapped[int | None] = mapped_column(ForeignKey("assets.id", ondelete="SET NULL"), nullable=True)
monitor_id: Mapped[int | None] = mapped_column(ForeignKey("monitors.id", ondelete="SET NULL"), nullable=True)
alert_rule_id: Mapped[int | None] = mapped_column(ForeignKey("alert_rules.id", ondelete="SET NULL"), nullable=True)
title: Mapped[str] = mapped_column(String(240))
severity: Mapped[str] = mapped_column(String(32))
status: Mapped[str] = mapped_column(String(32), default="open")
opened_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
acknowledged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
silenced_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
details: Mapped[dict] = mapped_column(JSON, default=dict)
class AuditEvent(Base):
__tablename__ = "audit_events"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
event_type: Mapped[str] = mapped_column(String(120))
target_type: Mapped[str | None] = mapped_column(String(120), nullable=True)
target_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
details: Mapped[dict] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+1
View File
@@ -0,0 +1 @@
"""Pydantic API schemas."""
+16
View File
@@ -0,0 +1,16 @@
from pydantic import BaseModel, EmailStr
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class UserRead(BaseModel):
id: int
email: EmailStr
display_name: str
role: str
is_active: bool
model_config = {"from_attributes": True}
+156
View File
@@ -0,0 +1,156 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
class AssetCreate(BaseModel):
name: str = Field(min_length=1, max_length=160)
asset_type: str = Field(min_length=1, max_length=64)
address: str | None = Field(default=None, max_length=255)
metadata: dict[str, Any] = Field(default_factory=dict)
class AssetUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=160)
asset_type: str | None = Field(default=None, min_length=1, max_length=64)
address: str | None = Field(default=None, max_length=255)
status: str | None = Field(default=None, max_length=32)
metadata: dict[str, Any] | None = None
class AssetRead(AssetCreate):
id: int
status: str
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class MonitorCreate(BaseModel):
name: str = Field(min_length=1, max_length=160)
monitor_type: str = Field(default="http", max_length=64)
target: str = Field(min_length=1, max_length=512)
asset_id: int | None = None
config: dict[str, Any] = Field(default_factory=dict)
interval_seconds: int = Field(default=60, ge=10)
class MonitorUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=160)
monitor_type: str | None = Field(default=None, max_length=64)
target: str | None = Field(default=None, min_length=1, max_length=512)
asset_id: int | None = None
config: dict[str, Any] | None = None
interval_seconds: int | None = Field(default=None, ge=10)
status: str | None = Field(default=None, max_length=32)
class MonitorRead(MonitorCreate):
id: int
status: str
last_checked_at: datetime | None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class WebsiteMonitorCreate(BaseModel):
name: str = Field(min_length=1, max_length=160)
url: str = Field(min_length=1, max_length=512)
expected_status: int = Field(default=200, ge=100, le=599)
expected_text: str | None = None
unexpected_text: str | None = None
timeout_seconds: int = Field(default=10, ge=1, le=120)
interval_seconds: int = Field(default=60, ge=10)
create_asset: bool = True
alert_enabled: bool = True
alert_severity: str = "critical"
failure_threshold: int = Field(default=3, ge=1, le=20)
class CheckResultRead(BaseModel):
id: int
monitor_id: int
status: str
response_time_ms: int | None
message: str | None
observed_at: datetime
model_config = {"from_attributes": True}
class AlertRuleCreate(BaseModel):
monitor_id: int
name: str = Field(min_length=1, max_length=160)
severity: str = "warning"
condition: dict[str, Any] = Field(default_factory=dict)
failure_threshold: int = Field(default=3, ge=1)
cooldown_seconds: int = Field(default=300, ge=0)
is_enabled: bool = True
class AlertRuleUpdate(BaseModel):
monitor_id: int | None = None
name: str | None = Field(default=None, min_length=1, max_length=160)
severity: str | None = None
condition: dict[str, Any] | None = None
failure_threshold: int | None = Field(default=None, ge=1)
cooldown_seconds: int | None = Field(default=None, ge=0)
is_enabled: bool | None = None
class AlertRuleRead(AlertRuleCreate):
id: int
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class IncidentRead(BaseModel):
id: int
asset_id: int | None
monitor_id: int | None
alert_rule_id: int | None
title: str
severity: str
status: str
opened_at: datetime
resolved_at: datetime | None
acknowledged_at: datetime | None
silenced_until: datetime | None
details: dict[str, Any]
model_config = {"from_attributes": True}
class NotificationChannelCreate(BaseModel):
name: str = Field(min_length=1, max_length=160)
channel_type: str = Field(min_length=1, max_length=64)
settings: dict[str, Any] = Field(default_factory=dict)
secret: str | None = None
is_enabled: bool = True
class NotificationChannelUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=160)
channel_type: str | None = Field(default=None, min_length=1, max_length=64)
settings: dict[str, Any] | None = None
secret: str | None = None
is_enabled: bool | None = None
class NotificationChannelRead(BaseModel):
id: int
name: str
channel_type: str
settings: dict[str, Any]
has_secret: bool
is_enabled: bool
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
+1
View File
@@ -0,0 +1 @@
"""Domain services."""
+22
View File
@@ -0,0 +1,22 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.security import hash_password
from app.core.config import settings
from app.models import User
def ensure_initial_admin(db: Session) -> None:
existing = db.scalar(select(User).where(User.email == settings.initial_admin_email))
if existing:
return
user = User(
email=settings.initial_admin_email,
display_name="Initial Owner",
hashed_password=hash_password(settings.initial_admin_password),
role="owner",
is_active=True,
)
db.add(user)
db.commit()