From f99d670209afeee874a6827f714ed604de1a2c99 Mon Sep 17 00:00:00 2001 From: wind Date: Sat, 28 Feb 2026 23:36:05 +0100 Subject: [PATCH] feat: scan stage progress labels and trusted device flag (#52 #54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #52 — Scan stage progress labels: - Scan model gains current_stage (nullable String) - scan_runner writes 'scanning' before orchestrate_scan() and 'analysing' after _persist_result(); both committed for live polling - ScanOut schema + _scan_to_out() include current_stage - Scan type updated in frontend - ScanBanner now shows human-readable stage label (Scanning network… / Analysing risks…), elapsed timer (useElapsed hook), scanId - DashboardPage passes current_stage + started_at to ScanBanner - Test: GET /api/scans always includes current_stage field #54 — Trusted device flag: - Device model gains trusted: Boolean (default=False, server_default='0') - DeviceOut schema + _device_to_out() include trusted field - PATCH /api/devices/{id}/trusted endpoint toggles flag (body: {trusted: bool}) - run_checks() skips all checks for trusted devices and clears existing risks - Device type gains trusted: boolean in frontend - DeviceDetailPage: 'Mark as trusted' / 'Untrust device' toggle button, 🛡 Trusted badge when trusted, calls PATCH then refetches - DevicesPage: 🛡 icon next to IP on trusted devices (tooltip) - Tests: trusted device → no risks generated; PATCH endpoint roundtrip; 404 on unknown device Closes #52 Closes #54 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/app/analysis/__init__.py | 23 ++++++------ backend/app/api/__init__.py | 27 ++++++++++++++ backend/app/models/device.py | 12 ++++++- backend/app/models/scan.py | 2 ++ backend/app/scan_runner.py | 6 ++++ backend/tests/test_analysis.py | 35 ++++++++++++++++++ backend/tests/test_api.py | 42 ++++++++++++++++++++++ frontend/src/components/ScanBanner.tsx | 47 ++++++++++++++++++++++--- frontend/src/pages/DashboardPage.tsx | 8 ++++- frontend/src/pages/DeviceDetailPage.tsx | 46 ++++++++++++++++++++++-- frontend/src/pages/DevicesPage.tsx | 9 +++++ frontend/src/types/api.ts | 2 ++ 12 files changed, 239 insertions(+), 20 deletions(-) diff --git a/backend/app/analysis/__init__.py b/backend/app/analysis/__init__.py index 4f24b4b..2442115 100644 --- a/backend/app/analysis/__init__.py +++ b/backend/app/analysis/__init__.py @@ -44,6 +44,15 @@ def run_checks(db: Session, device_id: int) -> list[Risk]: logger.warning("run_checks: device %d not found", device_id) return [] + # Trusted devices are acknowledged — clear any existing risks and skip checks + existing_stmt = select(Risk).where(Risk.device_id == device_id) + existing = db.execute(existing_stmt).scalars().all() + if device.trusted: + for risk in existing: + db.delete(risk) + logger.debug("Device %d is trusted — skipping checks and clearing risks", device_id) + return [] + # Collect findings from all checks all_findings: list[RiskData] = [] for check_fn in ALL_CHECKS: @@ -55,19 +64,9 @@ def run_checks(db: Session, device_id: int) -> list[Risk]: ) # Build a set of check_ids that fired so we can delete stale entries - fired_ids = {rd.check_id for rd in all_findings} - - # Delete existing Risk rows for this device that are about to be replaced - existing_stmt = select(Risk).where(Risk.device_id == device_id) - existing = db.execute(existing_stmt).scalars().all() - for risk in existing: - if risk.check_id in fired_ids: - db.delete(risk) - - # Also delete resolved risks (check fired before but not now) + # Delete existing Risk rows for this device (replace fired ones, remove resolved ones) for risk in existing: - if risk.check_id not in fired_ids: - db.delete(risk) + db.delete(risk) db.flush() diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index f6e87c9..f4adade 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -33,6 +33,7 @@ class DeviceOut(BaseModel): vendor: str | None hostname: str | None os_guess: str | None + trusted: bool first_seen: str | None # ISO-8601 string last_seen: str | None ports: list[PortOut] = [] @@ -48,6 +49,7 @@ class ScanOut(BaseModel): finished_at: str | None duration_seconds: float | None devices_found: int | None + current_stage: str | None error_message: str | None warning_message: str | None risks_critical: int | None @@ -88,6 +90,29 @@ def get_device(device_id: int, db: Annotated[Session, Depends(get_db)]) -> Devic return _device_to_out(device) +class _TrustedUpdate(BaseModel): + trusted: bool + + +@router.patch("/devices/{device_id}/trusted", response_model=DeviceOut) +def set_device_trusted( + device_id: int, + body: _TrustedUpdate, + db: Annotated[Session, Depends(get_db)], +) -> DeviceOut: + """Toggle the trusted flag on a device.""" + from app.models.device import Device + + stmt = select(Device).options(selectinload(Device.ports)).where(Device.id == device_id) + device = db.execute(stmt).scalar_one_or_none() + if device is None: + raise HTTPException(status_code=404, detail="Device not found") + device.trusted = body.trusted + db.commit() + db.refresh(device) + return _device_to_out(device) + + def _device_to_out(d) -> DeviceOut: # noqa: ANN001 — SQLAlchemy instance, validated via Pydantic return DeviceOut( id=d.id, @@ -96,6 +121,7 @@ def _device_to_out(d) -> DeviceOut: # noqa: ANN001 — SQLAlchemy instance, val vendor=d.vendor, hostname=d.hostname, os_guess=d.os_guess, + trusted=bool(d.trusted), first_seen=d.first_seen.isoformat() if d.first_seen else None, last_seen=d.last_seen.isoformat() if d.last_seen else None, ports=[ @@ -142,6 +168,7 @@ def _scan_to_out(s) -> ScanOut: # noqa: ANN001 — SQLAlchemy instance finished_at=s.finished_at.isoformat() if s.finished_at else None, duration_seconds=s.duration_seconds, devices_found=s.devices_found, + current_stage=s.current_stage, error_message=s.error_message, warning_message=s.warning_message, risks_critical=s.risks_critical, diff --git a/backend/app/models/device.py b/backend/app/models/device.py index 68775db..eac4b15 100644 --- a/backend/app/models/device.py +++ b/backend/app/models/device.py @@ -1,6 +1,15 @@ """Device and Port SQLAlchemy ORM models.""" -from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, UniqueConstraint, func +from sqlalchemy import ( + Boolean, + Column, + DateTime, + ForeignKey, + Integer, + String, + UniqueConstraint, + func, +) from sqlalchemy.orm import relationship from app.db import Base @@ -21,6 +30,7 @@ class Device(Base): vendor = Column(String, nullable=True) # hardware vendor from arp-scan OUI lookup hostname = Column(String, nullable=True) os_guess = Column(String, nullable=True) + trusted = Column(Boolean, nullable=False, default=False, server_default="0") first_seen = Column(DateTime, default=func.now()) last_seen = Column(DateTime, default=func.now(), onupdate=func.now()) diff --git a/backend/app/models/scan.py b/backend/app/models/scan.py index bdd25ca..97a4f49 100644 --- a/backend/app/models/scan.py +++ b/backend/app/models/scan.py @@ -19,6 +19,8 @@ class Scan(Base): finished_at = Column(DateTime, nullable=True) duration_seconds = Column(Float, nullable=True) devices_found = Column(Integer, nullable=True) + current_stage = Column(String, nullable=True) + # "scanning" | "analysing" — only set while status="running" error_message = Column(Text, nullable=True) warning_message = Column(Text, nullable=True) # "completed" scans may have a warning_message when nmap failed but ARP succeeded diff --git a/backend/app/scan_runner.py b/backend/app/scan_runner.py index b13721b..6cb0543 100644 --- a/backend/app/scan_runner.py +++ b/backend/app/scan_runner.py @@ -37,10 +37,16 @@ def run_scan_and_persist(triggered_by: str = "scheduler") -> int: t0 = time.monotonic() try: + scan.current_stage = "scanning" + db.commit() + result: ScanResult = orchestrate_scan() _persist_result(db, result) devices_found = len(result.hosts) + len(result.arp_only) + scan.current_stage = "analysing" + db.commit() + # Run misconfiguration checks against all discovered devices from app.analysis import ( run_all_checks, # noqa: PLC0415 — deferred to avoid circular import at module level diff --git a/backend/tests/test_analysis.py b/backend/tests/test_analysis.py index fb95d53..2a0451a 100644 --- a/backend/tests/test_analysis.py +++ b/backend/tests/test_analysis.py @@ -429,3 +429,38 @@ def test_run_all_checks_runs_across_all_devices(db_session): total = run_all_checks(db_session) assert total >= 2 + + +@pytest.mark.integration +def test_trusted_device_generates_no_risks(db_session): + """Devices marked trusted must have all risks cleared and no new ones written.""" + from app.analysis import run_checks + from app.db import upsert_device, upsert_port + + device = upsert_device(db_session, ip_address="10.99.1.1") + db_session.flush() + upsert_port(db_session, device_id=device.id, port_number=23, service_name="telnet") + db_session.flush() + + # First pass generates risks + risks_before = run_checks(db_session, device.id) + db_session.flush() + assert any(r.check_id == "telnet_open" for r in risks_before) + + # Mark trusted and re-run + device.trusted = True + db_session.flush() + db_session.expire_all() + + risks_after = run_checks(db_session, device.id) + db_session.flush() + assert risks_after == [] + + # Verify the DB also has no risk rows for this device + from app.models.risk import Risk + from sqlalchemy import select as sa_select + + remaining = ( + db_session.execute(sa_select(Risk).where(Risk.device_id == device.id)).scalars().all() + ) + assert remaining == [] diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 2d54156..e4b7883 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -579,3 +579,45 @@ def test_empty_steps_returns_empty_list(client, seeded_db, seeded_risk, db_engin db2.execute(sqlalchemy.delete(Recommendation).where(Recommendation.id == rec_id)) db2.commit() db2.close() + + +def test_patch_device_trusted_toggles_flag(client, seeded_db): + """PATCH /api/devices/{id}/trusted should toggle the trusted field.""" + device_id = seeded_db["device_id"] + + # Default is untrusted + resp = client.get(f"/api/devices/{device_id}") + assert resp.status_code == 200 + assert resp.json()["trusted"] is False + + # Set trusted=True + resp = client.patch( + f"/api/devices/{device_id}/trusted", + json={"trusted": True}, + ) + assert resp.status_code == 200 + assert resp.json()["trusted"] is True + + # Set back to False + resp = client.patch( + f"/api/devices/{device_id}/trusted", + json={"trusted": False}, + ) + assert resp.status_code == 200 + assert resp.json()["trusted"] is False + + +def test_patch_device_trusted_404_for_unknown(client): + """PATCH /api/devices/{id}/trusted returns 404 for non-existent device.""" + resp = client.patch("/api/devices/999999/trusted", json={"trusted": True}) + assert resp.status_code == 404 + + +def test_scan_response_includes_current_stage(client, seeded_db): + """GET /api/scans response must include the current_stage field (may be null).""" + resp = client.get("/api/scans") + assert resp.status_code == 200 + data = resp.json() + # The field must be present on every scan record (value may be null) + for scan in data: + assert "current_stage" in scan diff --git a/frontend/src/components/ScanBanner.tsx b/frontend/src/components/ScanBanner.tsx index cd74d94..9b035c6 100644 --- a/frontend/src/components/ScanBanner.tsx +++ b/frontend/src/components/ScanBanner.tsx @@ -1,13 +1,47 @@ /** * ScanBanner — top-of-page banner shown while a scan is in progress. - * It pulses gently to indicate activity. + * It pulses gently to indicate activity and shows the current stage + elapsed time. */ +import { useEffect, useState } from "react"; + +const STAGE_LABELS: Record = { + scanning: "Scanning network…", + analysing: "Analysing risks…", +}; + +function useElapsed(startedAt: string | null | undefined): string { + const [elapsed, setElapsed] = useState(0); + + useEffect(() => { + if (!startedAt) return; + const origin = new Date(startedAt).getTime(); + const tick = () => + setElapsed(Math.max(0, Math.floor((Date.now() - origin) / 1000))); + tick(); + const id = setInterval(tick, 1000); + return () => clearInterval(id); + }, [startedAt]); + + const m = Math.floor(elapsed / 60); + const s = elapsed % 60; + return m > 0 ? `${m}m ${s}s` : `${s}s`; +} export interface ScanBannerProps { scanId?: number | null; + currentStage?: string | null; + startedAt?: string | null; } -export function ScanBanner({ scanId }: ScanBannerProps) { +export function ScanBanner({ + scanId, + currentStage, + startedAt, +}: ScanBannerProps) { + const elapsed = useElapsed(startedAt); + const stageLabel = + (currentStage && STAGE_LABELS[currentStage]) ?? "Scan in progress"; + return (
- Scan in progress + {stageLabel} {scanId != null && ( #{scanId} )} - … results will update automatically. + + + {elapsed}
); diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index f3cf438..885781c 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -196,7 +196,13 @@ export function DashboardPage() { return (
- {isRunning && } + {isRunning && ( + + )} diff --git a/frontend/src/pages/DeviceDetailPage.tsx b/frontend/src/pages/DeviceDetailPage.tsx index 17d6e9e..2afc98c 100644 --- a/frontend/src/pages/DeviceDetailPage.tsx +++ b/frontend/src/pages/DeviceDetailPage.tsx @@ -2,7 +2,7 @@ * DeviceDetailPage — ports/services table, risk list, timestamps. * Route: /devices/:id */ -import { memo, useMemo } from "react"; +import { memo, useMemo, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { Card, Badge, SkeletonCard, PageHeader } from "../components"; import { useDevice, useRisks, useDeviceRecommendations } from "../hooks"; @@ -76,10 +76,11 @@ const RiskRecPair = memo(function RiskRecPair({ export function DeviceDetailPage() { const { id } = useParams<{ id: string }>(); const deviceId = Number(id); - const { device, loading, error } = useDevice(deviceId); + const { device, loading, error, refetch } = useDevice(deviceId); const { risks, loading: risksLoading } = useRisks({ deviceId }); const { recommendations, loading: recsLoading } = useDeviceRecommendations(deviceId); + const [togglingTrust, setTogglingTrust] = useState(false); // Build a map of risk_id → recommendation for O(1) lookup const recByRiskId = useMemo(() => { @@ -107,6 +108,21 @@ export function DeviceDetailPage() { (a, b) => SEV_ORDER.indexOf(a.severity) - SEV_ORDER.indexOf(b.severity), ); + const handleToggleTrust = () => { + setTogglingTrust(true); + fetch(`/api/devices/${deviceId}/trusted`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ trusted: !device.trusted }), + }) + .then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + }) + .then(() => refetch()) + .catch(() => {}) + .finally(() => setTogglingTrust(false)); + }; + return (
@@ -124,6 +140,32 @@ export function DeviceDetailPage() { + {device.trusted && ( + + 🛡 Trusted + + )} + +
+ } /> {/* Device metadata */} diff --git a/frontend/src/pages/DevicesPage.tsx b/frontend/src/pages/DevicesPage.tsx index e626882..a90ea11 100644 --- a/frontend/src/pages/DevicesPage.tsx +++ b/frontend/src/pages/DevicesPage.tsx @@ -220,6 +220,15 @@ export function DevicesPage() { > {device.ip_address} + {device.trusted && ( + + 🛡 + + )} {device.hostname ?? "—"} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index ba0eeda..967cf83 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -18,6 +18,7 @@ export interface Device { vendor: string | null; hostname: string | null; os_guess: string | null; + trusted: boolean; first_seen: string | null; // ISO-8601 last_seen: string | null; ports: Port[]; @@ -31,6 +32,7 @@ export interface Scan { finished_at: string | null; duration_seconds: number | null; devices_found: number | null; + current_stage: string | null; error_message: string | null; warning_message: string | null; risks_critical: number | null;