Initial InfraPulse scaffold
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
COPY pyproject.toml ./
|
||||
RUN pip install --no-cache-dir -e .
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,38 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
|
||||
sqlalchemy.url = postgresql+psycopg://infrapulse:infrapulse@postgres:5432/infrapulse
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
@@ -0,0 +1,46 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.base import Base
|
||||
|
||||
config = context.config
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=settings.database_url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,25 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Initial InfraPulse schema.
|
||||
|
||||
Revision ID: 20260522_0001
|
||||
Revises:
|
||||
Create Date: 2026-05-22
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "20260522_0001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("email", sa.String(length=320), nullable=False, unique=True, index=True),
|
||||
sa.Column("display_name", sa.String(length=120), nullable=False),
|
||||
sa.Column("hashed_password", sa.String(length=255), nullable=False),
|
||||
sa.Column("role", sa.String(length=32), nullable=False, server_default="owner"),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_table(
|
||||
"assets",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("name", sa.String(length=160), nullable=False),
|
||||
sa.Column("asset_type", sa.String(length=64), nullable=False),
|
||||
sa.Column("address", sa.String(length=255), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="unknown"),
|
||||
sa.Column("metadata", sa.JSON(), nullable=False, server_default=sa.text("'{}'::json")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_table(
|
||||
"credentials",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("name", sa.String(length=160), nullable=False),
|
||||
sa.Column("credential_type", sa.String(length=64), nullable=False),
|
||||
sa.Column("encrypted_secret", sa.Text(), nullable=True),
|
||||
sa.Column("metadata", sa.JSON(), nullable=False, server_default=sa.text("'{}'::json")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_table(
|
||||
"notification_channels",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("name", sa.String(length=160), nullable=False),
|
||||
sa.Column("channel_type", sa.String(length=64), nullable=False),
|
||||
sa.Column("settings", sa.JSON(), nullable=False, server_default=sa.text("'{}'::json")),
|
||||
sa.Column("encrypted_secret", sa.Text(), nullable=True),
|
||||
sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_table(
|
||||
"monitors",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("asset_id", sa.Integer(), sa.ForeignKey("assets.id", ondelete="CASCADE"), nullable=True),
|
||||
sa.Column("name", sa.String(length=160), nullable=False),
|
||||
sa.Column("monitor_type", sa.String(length=64), nullable=False),
|
||||
sa.Column("target", sa.String(length=512), nullable=False),
|
||||
sa.Column("config", sa.JSON(), nullable=False, server_default=sa.text("'{}'::json")),
|
||||
sa.Column("interval_seconds", sa.Integer(), nullable=False, server_default="60"),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="unknown"),
|
||||
sa.Column("last_checked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_table(
|
||||
"alert_rules",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("monitor_id", sa.Integer(), sa.ForeignKey("monitors.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("name", sa.String(length=160), nullable=False),
|
||||
sa.Column("severity", sa.String(length=32), nullable=False, server_default="warning"),
|
||||
sa.Column("condition", sa.JSON(), nullable=False, server_default=sa.text("'{}'::json")),
|
||||
sa.Column("failure_threshold", sa.Integer(), nullable=False, server_default="3"),
|
||||
sa.Column("cooldown_seconds", sa.Integer(), nullable=False, server_default="300"),
|
||||
sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_table(
|
||||
"check_results",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("monitor_id", sa.Integer(), sa.ForeignKey("monitors.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False),
|
||||
sa.Column("response_time_ms", sa.Integer(), nullable=True),
|
||||
sa.Column("message", sa.Text(), nullable=True),
|
||||
sa.Column("observed_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_table(
|
||||
"metrics",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("monitor_id", sa.Integer(), sa.ForeignKey("monitors.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("name", sa.String(length=120), nullable=False),
|
||||
sa.Column("value", sa.Float(), nullable=False),
|
||||
sa.Column("unit", sa.String(length=32), nullable=True),
|
||||
sa.Column("observed_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_table(
|
||||
"incidents",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("asset_id", sa.Integer(), sa.ForeignKey("assets.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("monitor_id", sa.Integer(), sa.ForeignKey("monitors.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("alert_rule_id", sa.Integer(), sa.ForeignKey("alert_rules.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("title", sa.String(length=240), nullable=False),
|
||||
sa.Column("severity", sa.String(length=32), nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="open"),
|
||||
sa.Column("opened_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("acknowledged_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("silenced_until", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("details", sa.JSON(), nullable=False, server_default=sa.text("'{}'::json")),
|
||||
)
|
||||
op.create_table(
|
||||
"audit_events",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("actor_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("event_type", sa.String(length=120), nullable=False),
|
||||
sa.Column("target_type", sa.String(length=120), nullable=True),
|
||||
sa.Column("target_id", sa.String(length=120), nullable=True),
|
||||
sa.Column("details", sa.JSON(), nullable=False, server_default=sa.text("'{}'::json")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("audit_events")
|
||||
op.drop_table("incidents")
|
||||
op.drop_table("metrics")
|
||||
op.drop_table("check_results")
|
||||
op.drop_table("alert_rules")
|
||||
op.drop_table("monitors")
|
||||
op.drop_table("notification_channels")
|
||||
op.drop_table("credentials")
|
||||
op.drop_table("assets")
|
||||
op.drop_table("users")
|
||||
@@ -0,0 +1 @@
|
||||
"""InfraPulse backend package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""API routers."""
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
"""Authentication helpers."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Core backend configuration."""
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Database helpers."""
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.models.core import Base
|
||||
|
||||
__all__ = ["Base"]
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
"""Pydantic API schemas."""
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -0,0 +1 @@
|
||||
"""Domain services."""
|
||||
@@ -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()
|
||||
@@ -0,0 +1,32 @@
|
||||
[project]
|
||||
name = "infrapulse-backend"
|
||||
version = "0.1.0"
|
||||
description = "InfraPulse FastAPI backend"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"alembic>=1.13.3",
|
||||
"cryptography>=48.0.0",
|
||||
"email-validator>=2.2.0",
|
||||
"fastapi>=0.115.0",
|
||||
"httpx>=0.28.0",
|
||||
"passlib>=1.7.4",
|
||||
"psycopg[binary]>=3.2.0",
|
||||
"pydantic-settings>=2.5.2",
|
||||
"python-jose[cryptography]>=3.3.0",
|
||||
"python-multipart>=0.0.12",
|
||||
"sqlalchemy>=2.0.35",
|
||||
"uvicorn[standard]>=0.30.6",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = [
|
||||
"pytest>=8.3.3",
|
||||
"httpx>=0.27.2",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=75.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
@@ -0,0 +1,10 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def test_health() -> None:
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "ok"
|
||||
Reference in New Issue
Block a user