From 355dbdaa8ea77999bd6fc28ad1d21110c2403dda Mon Sep 17 00:00:00 2001 From: wind Date: Sat, 28 Feb 2026 18:44:45 +0100 Subject: [PATCH] feat: include device ip_address and hostname in risk API response (#40) - Add ip_address and hostname fields to RiskOut schema - Eagerly load Risk.device via selectinload in list/get/device_risks queries - Risk list rows and modal now show hostname (or IP) as a clickable link - Remove N+1 client-side device lookup from RisksPage Closes #40 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/app/api/__init__.py | 10 +++++++--- backend/tests/test_api.py | 13 +++++++++++++ frontend/src/pages/RisksPage.tsx | 14 +++++++++----- frontend/src/types/api.ts | 2 ++ 4 files changed, 31 insertions(+), 8 deletions(-) diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index b317576..ba65dda 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -147,6 +147,8 @@ def _scan_to_out(s) -> ScanOut: # noqa: ANN001 — SQLAlchemy instance class RiskOut(BaseModel): id: int device_id: int + ip_address: str + hostname: str | None severity: str check_id: str title: str @@ -179,7 +181,7 @@ def list_risks( from app.models.risk import Risk severity_order = {"critical": 0, "high": 1, "medium": 2, "low": 3} - stmt = select(Risk) + stmt = select(Risk).options(selectinload(Risk.device)) if severity is not None: stmt = stmt.where(Risk.severity == severity) if device_id is not None: @@ -215,7 +217,7 @@ def get_risk(risk_id: int, db: Annotated[Session, Depends(get_db)]) -> RiskOut: """Return a single risk by ID.""" from app.models.risk import Risk - stmt = select(Risk).where(Risk.id == risk_id) + stmt = select(Risk).options(selectinload(Risk.device)).where(Risk.id == risk_id) risk = db.execute(stmt).scalar_one_or_none() if risk is None: raise HTTPException(status_code=404, detail="Risk not found") @@ -231,7 +233,7 @@ def device_risks(device_id: int, db: Annotated[Session, Depends(get_db)]) -> lis device = db.execute(select(Device).where(Device.id == device_id)).scalar_one_or_none() if device is None: raise HTTPException(status_code=404, detail="Device not found") - stmt = select(Risk).where(Risk.device_id == device_id) + stmt = select(Risk).options(selectinload(Risk.device)).where(Risk.device_id == device_id) risks = db.execute(stmt).scalars().all() return [_risk_to_out(r) for r in risks] @@ -240,6 +242,8 @@ def _risk_to_out(r) -> RiskOut: # noqa: ANN001 — SQLAlchemy instance return RiskOut( id=r.id, device_id=r.device_id, + ip_address=r.device.ip_address, + hostname=r.device.hostname, severity=r.severity, check_id=r.check_id, title=r.title, diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 278c9df..861a5e4 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -292,6 +292,8 @@ def test_risk_schema_fields(client, seeded_risk): risk = next(r for r in response.json() if r["id"] == seeded_risk["risk_id"]) assert "id" in risk assert "device_id" in risk + assert "ip_address" in risk + assert "hostname" in risk assert "severity" in risk assert "check_id" in risk assert "title" in risk @@ -299,6 +301,17 @@ def test_risk_schema_fields(client, seeded_risk): assert "detected_at" in risk +@pytest.mark.integration +def test_risk_includes_device_identity(client, seeded_db, seeded_risk): + """Risk response must include ip_address and hostname from the linked device.""" + response = client.get("/api/risks") + risk = next(r for r in response.json() if r["id"] == seeded_risk["risk_id"]) + # seeded_db device has ip_address="10.0.0.1" + assert risk["ip_address"] == "10.0.0.1" + # hostname is nullable; assert the key is present with the correct type + assert risk["hostname"] is None or isinstance(risk["hostname"], str) + + @pytest.mark.integration def test_get_risk_by_id(client, seeded_risk): risk_id = seeded_risk["risk_id"] diff --git a/frontend/src/pages/RisksPage.tsx b/frontend/src/pages/RisksPage.tsx index ea71947..58c7ddf 100644 --- a/frontend/src/pages/RisksPage.tsx +++ b/frontend/src/pages/RisksPage.tsx @@ -75,7 +75,7 @@ function RiskModal({ risk, onClose }: { risk: Risk; onClose: () => void }) { className="text-[var(--color-accent-positive)] hover:underline" onClick={onClose} > - Device #{risk.device_id} + {risk.hostname || risk.ip_address} @@ -230,7 +230,7 @@ export function RisksPage() {
{risks.map((risk) => { - const device = devices.find((d) => d.id === risk.device_id); + const deviceLabel = risk.hostname || risk.ip_address; return (