Add asset-based monitor setup

This commit is contained in:
Keith Smith
2026-05-23 21:07:05 -06:00
parent 8b5dea152e
commit bd6c508c94
9 changed files with 858 additions and 74 deletions
+88 -20
View File
@@ -4,14 +4,42 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.api.credentials import SNMP_CREDENTIAL_TYPE
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, PingMonitorCreate, TcpMonitorCreate, WebsiteMonitorCreate
from app.models import AlertRule, Asset, CheckResult, Credential, Incident, Monitor, User
from app.schemas.core import (
CheckResultRead,
MonitorCreate,
MonitorRead,
MonitorUpdate,
PingMonitorCreate,
SnmpMonitorsCreate,
TcpMonitorCreate,
WebsiteMonitorCreate,
)
router = APIRouter(prefix="/monitors", tags=["monitors"])
def _get_asset_or_404(db: Session, asset_id: int) -> Asset:
asset = db.get(Asset, asset_id)
if asset is None:
raise HTTPException(status_code=404, detail="Asset not found")
return asset
def _resolve_asset_id(db: Session, asset_id: int | None, create_asset: bool, asset: Asset) -> int | None:
if asset_id is not None:
_get_asset_or_404(db, asset_id)
return asset_id
if not create_asset:
return None
db.add(asset)
db.flush()
return asset.id
@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())
@@ -23,6 +51,8 @@ def create_monitor(
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> Monitor:
if payload.asset_id is not None:
_get_asset_or_404(db, payload.asset_id)
monitor = Monitor(**payload.model_dump())
db.add(monitor)
db.commit()
@@ -36,12 +66,12 @@ def create_website_monitor(
_: 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
asset_id = _resolve_asset_id(
db,
payload.asset_id,
payload.create_asset,
Asset(name=payload.name, asset_type="website", address=payload.url, status="unknown", extra={}),
)
monitor = Monitor(
asset_id=asset_id,
@@ -86,12 +116,12 @@ def create_ping_monitor(
_: 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="host", address=payload.host, status="unknown", extra={})
db.add(asset)
db.flush()
asset_id = asset.id
asset_id = _resolve_asset_id(
db,
payload.asset_id,
payload.create_asset,
Asset(name=payload.name, asset_type="host", address=payload.host, status="unknown", extra={}),
)
monitor = Monitor(
asset_id=asset_id,
@@ -129,13 +159,13 @@ def create_tcp_monitor(
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> Monitor:
asset_id: int | None = None
target = f"{payload.host}:{payload.port}"
if payload.create_asset:
asset = Asset(name=payload.name, asset_type="tcp_service", address=target, status="unknown", extra={})
db.add(asset)
db.flush()
asset_id = asset.id
asset_id = _resolve_asset_id(
db,
payload.asset_id,
payload.create_asset,
Asset(name=payload.name, asset_type="tcp_service", address=target, status="unknown", extra={}),
)
monitor = Monitor(
asset_id=asset_id,
@@ -167,6 +197,44 @@ def create_tcp_monitor(
return monitor
@router.post("/snmp/from-discovery", response_model=list[MonitorRead])
def create_snmp_monitors_from_discovery(
payload: SnmpMonitorsCreate,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> list[Monitor]:
asset = _get_asset_or_404(db, payload.asset_id)
profile = db.get(Credential, payload.credential_profile_id)
if profile is None or profile.credential_type != SNMP_CREDENTIAL_TYPE:
raise HTTPException(status_code=404, detail="SNMP credential profile not found")
monitors: list[Monitor] = []
for item in payload.selected_items:
monitor = Monitor(
asset_id=asset.id,
name=f"{asset.name} {item.label}",
monitor_type="snmp",
target=payload.host,
config={
"credential_profile_id": payload.credential_profile_id,
"item_id": item.item_id,
"item_type": item.item_type,
"group": item.group,
"label": item.label,
"unit": item.unit,
},
interval_seconds=payload.interval_seconds,
status="unknown",
)
db.add(monitor)
monitors.append(monitor)
db.commit()
for monitor in monitors:
db.refresh(monitor)
return monitors
@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)
+11
View File
@@ -60,6 +60,7 @@ class MonitorRead(MonitorCreate):
class WebsiteMonitorCreate(BaseModel):
name: str = Field(min_length=1, max_length=160)
url: str = Field(min_length=1, max_length=512)
asset_id: int | None = None
expected_status: int = Field(default=200, ge=100, le=599)
expected_text: str | None = None
unexpected_text: str | None = None
@@ -76,6 +77,7 @@ class WebsiteMonitorCreate(BaseModel):
class PingMonitorCreate(BaseModel):
name: str = Field(min_length=1, max_length=160)
host: str = Field(min_length=1, max_length=255)
asset_id: int | None = None
timeout_seconds: int = Field(default=5, ge=1, le=60)
interval_seconds: int = Field(default=60, ge=10)
create_asset: bool = True
@@ -88,6 +90,7 @@ class TcpMonitorCreate(BaseModel):
name: str = Field(min_length=1, max_length=160)
host: str = Field(min_length=1, max_length=255)
port: int = Field(ge=1, le=65535)
asset_id: int | None = None
timeout_seconds: int = Field(default=5, ge=1, le=60)
interval_seconds: int = Field(default=60, ge=10)
create_asset: bool = True
@@ -234,6 +237,14 @@ class SnmpDiscoveryItemRead(BaseModel):
unit: str | None = None
class SnmpMonitorsCreate(BaseModel):
host: str = Field(min_length=1, max_length=255)
asset_id: int
credential_profile_id: int
selected_items: list[SnmpDiscoveryItemRead] = Field(min_length=1)
interval_seconds: int = Field(default=60, ge=10)
class SnmpDiscoveryRead(BaseModel):
host: str
credential_profile_id: int
+140 -1
View File
@@ -2,7 +2,8 @@ from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import AlertRule, Asset, Monitor
from app.core.secrets import encrypt_secret
from app.models import AlertRule, Asset, Credential, Monitor
def test_create_website_monitor_creates_asset_and_alert_rule(client: TestClient, db_session: Session) -> None:
@@ -72,3 +73,141 @@ def test_create_website_monitor_can_skip_default_alert_rule(client: TestClient,
assert monitor is not None
assert monitor.asset_id is None
assert db_session.scalars(select(AlertRule).where(AlertRule.monitor_id == monitor.id)).all() == []
def test_create_website_monitor_can_attach_existing_asset_without_default_alert(client: TestClient, db_session: Session) -> None:
asset = Asset(name="Existing App", asset_type="application", address="app.example.com", status="unknown", extra={})
db_session.add(asset)
db_session.commit()
response = client.post(
"/monitors/website",
json={
"name": "Existing App HTTPS",
"url": "https://app.example.com",
"asset_id": asset.id,
"create_asset": True,
"alert_enabled": False,
},
)
assert response.status_code == 200
body = response.json()
monitor = db_session.get(Monitor, body["id"])
assert monitor is not None
assert monitor.asset_id == asset.id
assert db_session.scalars(select(AlertRule).where(AlertRule.monitor_id == monitor.id)).all() == []
assert db_session.scalars(select(Asset)).all() == [asset]
def test_create_ping_monitor_rejects_missing_asset(client: TestClient) -> None:
response = client.post(
"/monitors/ping",
json={
"name": "Missing Asset Ping",
"host": "192.0.2.10",
"asset_id": 999,
"create_asset": False,
"alert_enabled": False,
},
)
assert response.status_code == 404
def test_create_tcp_monitor_can_attach_existing_asset(client: TestClient, db_session: Session) -> None:
asset = Asset(name="Router", asset_type="network_device", address="192.0.2.1", status="unknown", extra={})
db_session.add(asset)
db_session.commit()
response = client.post(
"/monitors/tcp",
json={
"name": "Router SSH",
"host": "192.0.2.1",
"port": 22,
"asset_id": asset.id,
"create_asset": False,
"alert_enabled": False,
},
)
assert response.status_code == 200
body = response.json()
assert body["asset_id"] == asset.id
assert body["target"] == "192.0.2.1:22"
def test_create_snmp_monitors_from_discovery_attaches_asset_and_skips_alerts(client: TestClient, db_session: Session) -> None:
asset = Asset(name="Core Switch", asset_type="network_device", address="192.0.2.10", status="unknown", extra={})
profile = Credential(
name="Core Switch Read Only",
credential_type="snmp",
encrypted_secret=encrypt_secret("private-community"),
extra={"version": "2c"},
)
db_session.add_all([asset, profile])
db_session.commit()
response = client.post(
"/monitors/snmp/from-discovery",
json={
"host": "192.0.2.10",
"asset_id": asset.id,
"credential_profile_id": profile.id,
"interval_seconds": 120,
"selected_items": [
{
"item_id": "device.uptime",
"item_type": "device_uptime",
"group": "Device Health",
"label": "Device uptime",
"unit": "seconds",
},
{
"item_id": "interface.1.status",
"item_type": "interface_status",
"group": "Interface uplink",
"label": "uplink status",
"unit": None,
},
],
},
)
assert response.status_code == 200
body = response.json()
assert len(body) == 2
assert {monitor["name"] for monitor in body} == {"Core Switch Device uptime", "Core Switch uplink status"}
assert all(monitor["asset_id"] == asset.id for monitor in body)
assert all(monitor["monitor_type"] == "snmp" for monitor in body)
assert all(monitor["interval_seconds"] == 120 for monitor in body)
assert all("1.3.6" not in str(monitor["config"]) for monitor in body)
monitor_ids = [monitor["id"] for monitor in body]
assert db_session.scalars(select(AlertRule).where(AlertRule.monitor_id.in_(monitor_ids))).all() == []
def test_create_snmp_monitors_rejects_missing_profile(client: TestClient, db_session: Session) -> None:
asset = Asset(name="Core Switch", asset_type="network_device", address="192.0.2.10", status="unknown", extra={})
db_session.add(asset)
db_session.commit()
response = client.post(
"/monitors/snmp/from-discovery",
json={
"host": "192.0.2.10",
"asset_id": asset.id,
"credential_profile_id": 999,
"selected_items": [
{
"item_id": "device.uptime",
"item_type": "device_uptime",
"group": "Device Health",
"label": "Device uptime",
}
],
},
)
assert response.status_code == 404