diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index ba65dda..5e01006 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -49,6 +49,10 @@ class ScanOut(BaseModel): duration_seconds: float | None devices_found: int | None error_message: str | None + risks_critical: int | None + risks_high: int | None + risks_medium: int | None + risks_low: int | None model_config = {"from_attributes": True} @@ -138,6 +142,10 @@ def _scan_to_out(s) -> ScanOut: # noqa: ANN001 — SQLAlchemy instance duration_seconds=s.duration_seconds, devices_found=s.devices_found, error_message=s.error_message, + risks_critical=s.risks_critical, + risks_high=s.risks_high, + risks_medium=s.risks_medium, + risks_low=s.risks_low, ) diff --git a/backend/app/models/scan.py b/backend/app/models/scan.py index 5f9e321..118e1a7 100644 --- a/backend/app/models/scan.py +++ b/backend/app/models/scan.py @@ -20,3 +20,8 @@ class Scan(Base): duration_seconds = Column(Float, nullable=True) devices_found = Column(Integer, nullable=True) error_message = Column(Text, nullable=True) + # Risk counts snapshotted at scan completion + risks_critical = Column(Integer, nullable=True) + risks_high = Column(Integer, nullable=True) + risks_medium = Column(Integer, nullable=True) + risks_low = Column(Integer, nullable=True) diff --git a/backend/app/scan_runner.py b/backend/app/scan_runner.py index 4fe55cc..121cdd0 100644 --- a/backend/app/scan_runner.py +++ b/backend/app/scan_runner.py @@ -48,6 +48,23 @@ def run_scan_and_persist(triggered_by: str = "scheduler") -> int: run_all_checks(db) + # Snapshot risk counts for trend tracking + from sqlalchemy import func as sqlfunc + from sqlalchemy import select as sa_select + + from app.models.risk import ( + Risk, # noqa: PLC0415 — deferred to avoid circular import at module level + ) + + risk_counts: dict[str, int] = {"critical": 0, "high": 0, "medium": 0, "low": 0} + for sev in risk_counts: + risk_counts[sev] = ( + db.execute( + sa_select(sqlfunc.count()).select_from(Risk).where(Risk.severity == sev) + ).scalar_one() + or 0 + ) + # Generate/update hardening recommendations for all devices from app.recommendations import ( generate_all_recommendations, # noqa: PLC0415 — deferred to avoid circular import at module level @@ -59,6 +76,10 @@ def run_scan_and_persist(triggered_by: str = "scheduler") -> int: scan.finished_at = datetime.now(tz=UTC) scan.duration_seconds = round(time.monotonic() - t0, 2) scan.devices_found = devices_found + scan.risks_critical = risk_counts["critical"] + scan.risks_high = risk_counts["high"] + scan.risks_medium = risk_counts["medium"] + scan.risks_low = risk_counts["low"] db.commit() logger.info("Scan %d completed: %d devices", scan_id, devices_found) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 861a5e4..dfcb84f 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -199,6 +199,10 @@ def test_scan_schema_fields(client, seeded_db): assert "finished_at" in scan assert "duration_seconds" in scan assert "devices_found" in scan + assert "risks_critical" in scan + assert "risks_high" in scan + assert "risks_medium" in scan + assert "risks_low" in scan # ── POST /api/scans/trigger ─────────────────────────────────────────────────── diff --git a/backend/tests/test_scan_runner.py b/backend/tests/test_scan_runner.py index a876a77..b91dbf3 100644 --- a/backend/tests/test_scan_runner.py +++ b/backend/tests/test_scan_runner.py @@ -26,6 +26,8 @@ def in_memory_session_factory(): and therefore sees the same in-memory database. """ import app.models.device # noqa: F401 — side-effect import registers ORM tables + import app.models.recommendation # noqa: F401 — side-effect import registers ORM tables + import app.models.risk # noqa: F401 — side-effect import registers ORM tables import app.models.scan # noqa: F401 — side-effect import registers ORM tables from app.db import Base @@ -206,3 +208,28 @@ def test_run_scan_returns_scan_id(in_memory_session_factory, monkeypatch): result = run_scan_and_persist("scheduler") assert isinstance(result, int) + + +@pytest.mark.integration +def test_run_scan_populates_risk_counts(in_memory_session_factory, monkeypatch): + """Completed scan record includes per-severity risk counts (all zero when no risks).""" + from app.models.scan import Scan + from app.scan_runner import run_scan_and_persist + + monkeypatch.setattr("app.scan_runner.SessionLocal", in_memory_session_factory) + + with patch("app.scan_runner.orchestrate_scan", return_value=_make_scan_result(n_hosts=1)): + scan_id = run_scan_and_persist("manual") + + db = in_memory_session_factory() + scan = db.get(Scan, scan_id) + assert scan.risks_critical is not None # type: ignore[union-attr] + assert scan.risks_high is not None # type: ignore[union-attr] + assert scan.risks_medium is not None # type: ignore[union-attr] + assert scan.risks_low is not None # type: ignore[union-attr] + # All counts must be non-negative integers + assert scan.risks_critical >= 0 # type: ignore[union-attr] + assert scan.risks_high >= 0 # type: ignore[union-attr] + assert scan.risks_medium >= 0 # type: ignore[union-attr] + assert scan.risks_low >= 0 # type: ignore[union-attr] + db.close() diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index bd608f0..82c7aea 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -12,6 +12,7 @@ const navLinks = [ { to: "/", label: "Dashboard", end: true }, { to: "/devices", label: "Devices", end: false }, { to: "/risks", label: "Risks", end: false }, + { to: "/history", label: "History", end: false }, { to: "/recommendations", label: "Recommendations", end: false }, { to: "/settings", label: "Settings", end: false }, ]; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 93e3e09..3961372 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -7,6 +7,7 @@ import { DashboardPage, DevicesPage, DeviceDetailPage, + HistoryPage, RisksPage, RecommendationsPage, RecommendationDetailPage, @@ -22,6 +23,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render( } /> } /> } /> + } /> } /> d.value), 1); + const barW = Math.max(8, Math.floor(320 / Math.max(data.length, 1)) - 4); + const svgW = data.length * (barW + 4) + 4; + + return ( +
+ + {data.map((d, i) => { + const barH = Math.max(2, Math.round((d.value / max) * height)); + const x = i * (barW + 4) + 2; + const y = height - barH; + return ( + + + {`${d.label}: ${d.value} devices`} + + + {d.label} + + + ); + })} + +
+ ); +} + +// ── Stacked bar chart (risk severity trend) ─────────────────────────────────── + +type SevKey = "critical" | "high" | "medium" | "low"; +const SEV_KEYS: SevKey[] = ["critical", "high", "medium", "low"]; + +interface StackedBarChartProps { + data: { + label: string; + critical: number; + high: number; + medium: number; + low: number; + }[]; + height?: number; +} + +function StackedBarChart({ data, height = 120 }: StackedBarChartProps) { + const totals = data.map((d) => d.critical + d.high + d.medium + d.low); + const max = Math.max(...totals, 1); + const barW = Math.max(8, Math.floor(320 / Math.max(data.length, 1)) - 4); + const svgW = data.length * (barW + 4) + 4; + + return ( +
+ + {data.map((d, i) => { + const x = i * (barW + 4) + 2; + let yOffset = height; + return ( + + {SEV_KEYS.map((sev) => { + const val = d[sev]; + const segH = Math.round((val / max) * height); + yOffset -= segH; + return segH > 0 ? ( + + {`${d.label} — ${sev}: ${val}`} + + ) : null; + })} + + {d.label} + + + ); + })} + +
+ ); +} + +// ── Legend ──────────────────────────────────────────────────────────────────── + +function SevLegend() { + return ( +
+ {SEV_KEYS.map((sev) => ( + + + {sev.charAt(0).toUpperCase() + sev.slice(1)} + + ))} +
+ ); +} + +// ── Page ────────────────────────────────────────────────────────────────────── + +export function HistoryPage() { + const { scans, loading, error } = useScans(); + + // Oldest → newest for charts (API returns newest first) + const completed = useMemo( + () => + [...scans] + .filter((s: Scan) => s.status === "completed") + .sort((a, b) => (a.started_at ?? "").localeCompare(b.started_at ?? "")), + [scans], + ); + + const deviceData = useMemo( + () => + completed.map((s) => ({ + label: shortDate(s.started_at), + value: s.devices_found ?? 0, + })), + [completed], + ); + + const riskData = useMemo( + () => + completed.map((s) => ({ + label: shortDate(s.started_at), + critical: s.risks_critical ?? 0, + high: s.risks_high ?? 0, + medium: s.risks_medium ?? 0, + low: s.risks_low ?? 0, + })), + [completed], + ); + + return ( +
+ 0 + ? `${scans.length} scan${scans.length !== 1 ? "s" : ""} recorded` + : undefined + } + /> + + {loading && ( +
+ + +
+ )} + {error && ( +

Error: {error}

+ )} + + {!loading && !error && completed.length === 0 && ( + +

+ No completed scans yet. Run a scan from the Dashboard. +

+
+ )} + + {!loading && !error && completed.length > 0 && ( +
+ {/* Device count trend */} + +

+ Devices discovered per scan +

+ +
+ + {/* Risk trend */} + +

+ Risk counts per scan +

+ + +
+ + {/* Scan table */} + +
+ + + + + + + + + + + + + + + {[...scans].map((s) => ( + + + + + + + + + + + ))} + +
StartedByDurationDevicesCriticalHighMediumLow
+ {shortDate(s.started_at)} + {s.triggered_by} + {s.duration_seconds != null + ? `${s.duration_seconds}s` + : "—"} + + {s.devices_found ?? "—"} + + {s.risks_critical ?? "—"} + + {s.risks_high ?? "—"} + + {s.risks_medium ?? "—"} + + {s.risks_low ?? "—"} +
+
+
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts index 3366a57..842e610 100644 --- a/frontend/src/pages/index.ts +++ b/frontend/src/pages/index.ts @@ -1,3 +1,4 @@ +export { HistoryPage } from "./HistoryPage"; /* Page exports */ export { DashboardPage } from "./DashboardPage"; export { DevicesPage } from "./DevicesPage"; diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 275b60d..42a183b 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -32,6 +32,10 @@ export interface Scan { duration_seconds: number | null; devices_found: number | null; error_message: string | null; + risks_critical: number | null; + risks_high: number | null; + risks_medium: number | null; + risks_low: number | null; } export interface TriggerResponse {