From d61a77f6243b7301d26a3a97149106838ab315fb Mon Sep 17 00:00:00 2001 From: wind Date: Sun, 1 Mar 2026 12:10:56 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20risk=20acknowledgement=20=E2=80=94?= =?UTF-8?q?=20accept/snooze=20known=20risks=20(#77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Add acknowledged_at (DateTime) and acknowledged_note (String) to Risk model - Migrate existing DBs via _migrate_schema() - Preserve acknowledgements on re-scan: save (check_id → ack) mapping before deleting rows, re-apply when inserting new rows - GET /risks: add ?acknowledged=true/false filter - risks_summary: count only non-acknowledged risks (keeps dashboard accurate) - PATCH /risks/{id}/acknowledge: set acknowledged_at + optional note - PATCH /risks/{id}/unacknowledge: clear acknowledged_at and note Frontend: - Risk type gains acknowledged_at and acknowledged_note fields - useRisks hook gains acknowledged filter option - RisksPage: Active/Accepted tab toggle; active tab is default - Summary cards only shown on Active tab - Risk detail modal: Acknowledge button opens AcknowledgeModal with optional free-text note; accepted risks show 'accepted' badge + note + Reopen button - Acknowledge and Reopen actions refetch both risk list and summary counts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/app/analysis/__init__.py | 13 +- backend/app/api/__init__.py | 61 +++++++- backend/app/db.py | 2 + backend/app/models/risk.py | 2 + frontend/src/hooks/useRisks.ts | 7 +- frontend/src/pages/RisksPage.tsx | 237 +++++++++++++++++++++++++++++-- frontend/src/types/api.ts | 2 + 7 files changed, 306 insertions(+), 18 deletions(-) diff --git a/backend/app/analysis/__init__.py b/backend/app/analysis/__init__.py index 2442115..4457943 100644 --- a/backend/app/analysis/__init__.py +++ b/backend/app/analysis/__init__.py @@ -63,16 +63,23 @@ def run_checks(db: Session, device_id: int) -> list[Risk]: "Check %s raised an exception for device %d", check_fn.__name__, device_id ) - # Build a set of check_ids that fired so we can delete stale entries + # Preserve any existing acknowledgements keyed on check_id before deleting rows + ack_by_check: dict[str, tuple] = { + r.check_id: (r.acknowledged_at, r.acknowledged_note) + for r in existing + if r.acknowledged_at is not None + } + # Delete existing Risk rows for this device (replace fired ones, remove resolved ones) for risk in existing: db.delete(risk) db.flush() - # Insert fresh Risk rows + # Insert fresh Risk rows, re-applying any saved acknowledgements written: list[Risk] = [] for rd in all_findings: + ack_at, ack_note = ack_by_check.get(rd.check_id, (None, None)) risk = Risk( device_id=device_id, severity=rd.severity, @@ -80,6 +87,8 @@ def run_checks(db: Session, device_id: int) -> list[Risk]: title=rd.title, description=rd.description, detected_at=datetime.now(tz=UTC), + acknowledged_at=ack_at, + acknowledged_note=ack_note, ) db.add(risk) written.append(risk) diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index f4adade..70e7e2e 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -191,6 +191,8 @@ class RiskOut(BaseModel): title: str description: str detected_at: str | None + acknowledged_at: str | None + acknowledged_note: str | None model_config = {"from_attributes": True} @@ -211,12 +213,14 @@ def list_risks( db: Annotated[Session, Depends(get_db)], severity: _SeverityParam | None = None, device_id: int | None = None, + acknowledged: bool | None = None, ) -> list[RiskOut]: """Return risk findings, ordered by severity then detected_at. Optional query parameters: - ``severity``: filter to a single severity level (critical/high/medium/low) - ``device_id``: filter to risks belonging to a specific device + - ``acknowledged``: ``true`` → acknowledged only; ``false`` → active only; omit → all """ from app.models.risk import Risk @@ -226,6 +230,10 @@ def list_risks( stmt = stmt.where(Risk.severity == severity) if device_id is not None: stmt = stmt.where(Risk.device_id == device_id) + if acknowledged is True: + stmt = stmt.where(Risk.acknowledged_at.isnot(None)) + elif acknowledged is False: + stmt = stmt.where(Risk.acknowledged_at.is_(None)) risks = db.execute(stmt).scalars().all() risks_sorted = sorted( risks, key=lambda r: (severity_order.get(r.severity, 99), r.detected_at or "") @@ -235,10 +243,12 @@ def list_risks( @router.get("/risks/summary", response_model=RiskSummary) def risks_summary(db: Annotated[Session, Depends(get_db)]) -> RiskSummary: - """Return a count of risks per severity level.""" + """Return a count of active (non-acknowledged) risks per severity level.""" from app.models.risk import Risk - all_risks = db.execute(select(Risk)).scalars().all() + all_risks = db.execute( + select(Risk).where(Risk.acknowledged_at.is_(None)) + ).scalars().all() counts: dict[str, int] = {"critical": 0, "high": 0, "medium": 0, "low": 0} for r in all_risks: if r.severity in counts: @@ -264,6 +274,51 @@ def get_risk(risk_id: int, db: Annotated[Session, Depends(get_db)]) -> RiskOut: return _risk_to_out(risk) +class _AcknowledgeBody(BaseModel): + note: str | None = None + + +@router.patch("/risks/{risk_id}/acknowledge", response_model=RiskOut) +def acknowledge_risk( + risk_id: int, + body: _AcknowledgeBody, + db: Annotated[Session, Depends(get_db)], +) -> RiskOut: + """Mark a risk as acknowledged (accepted). Survives future re-scans.""" + from datetime import UTC, datetime + + from app.models.risk import Risk + + 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") + risk.acknowledged_at = datetime.now(tz=UTC) + risk.acknowledged_note = body.note + db.commit() + db.refresh(risk) + return _risk_to_out(risk) + + +@router.patch("/risks/{risk_id}/unacknowledge", response_model=RiskOut) +def unacknowledge_risk( + risk_id: int, + db: Annotated[Session, Depends(get_db)], +) -> RiskOut: + """Remove the acknowledgement from a risk, returning it to the active list.""" + from app.models.risk import Risk + + 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") + risk.acknowledged_at = None + risk.acknowledged_note = None + db.commit() + db.refresh(risk) + return _risk_to_out(risk) + + @router.get("/devices/{device_id}/risks", response_model=list[RiskOut]) def device_risks(device_id: int, db: Annotated[Session, Depends(get_db)]) -> list[RiskOut]: """Return all risks for a specific device.""" @@ -289,6 +344,8 @@ def _risk_to_out(r) -> RiskOut: # noqa: ANN001 — SQLAlchemy instance title=r.title, description=r.description, detected_at=r.detected_at.isoformat() if r.detected_at else None, + acknowledged_at=r.acknowledged_at.isoformat() if r.acknowledged_at else None, + acknowledged_note=r.acknowledged_note, ) diff --git a/backend/app/db.py b/backend/app/db.py index 8000eac..47f67d8 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -32,6 +32,8 @@ def _migrate_schema(engine) -> None: ("devices", "trusted", "BOOLEAN NOT NULL DEFAULT 0"), ("scans", "current_stage", "TEXT"), ("scans", "warning_message", "TEXT"), + ("risks", "acknowledged_at", "DATETIME"), + ("risks", "acknowledged_note", "TEXT"), ] with engine.connect() as conn: for table, column, col_def in migrations: diff --git a/backend/app/models/risk.py b/backend/app/models/risk.py index 969e660..6b7fee5 100644 --- a/backend/app/models/risk.py +++ b/backend/app/models/risk.py @@ -22,5 +22,7 @@ class Risk(Base): title = Column(String, nullable=False) description = Column(Text, nullable=False) detected_at = Column(DateTime, nullable=False, default=func.now()) + acknowledged_at = Column(DateTime, nullable=True) + acknowledged_note = Column(String, nullable=True) device = relationship("Device", back_populates="risks") diff --git a/frontend/src/hooks/useRisks.ts b/frontend/src/hooks/useRisks.ts index c4f3892..095d7f8 100644 --- a/frontend/src/hooks/useRisks.ts +++ b/frontend/src/hooks/useRisks.ts @@ -8,6 +8,7 @@ import type { Risk, RiskSummary, Severity } from "../types/api"; export interface UseRisksOptions { severity?: Severity; deviceId?: number; + acknowledged?: boolean; } export interface UseRisksResult { @@ -23,7 +24,7 @@ export function useRisks(options: UseRisksOptions = {}): UseRisksResult { const [error, setError] = useState(null); const [tick, setTick] = useState(0); - const { severity, deviceId } = options; + const { severity, deviceId, acknowledged } = options; useEffect(() => { let cancelled = false; @@ -32,6 +33,8 @@ export function useRisks(options: UseRisksOptions = {}): UseRisksResult { const params = new URLSearchParams(); if (severity) params.set("severity", severity); if (deviceId !== undefined) params.set("device_id", String(deviceId)); + if (acknowledged !== undefined) + params.set("acknowledged", String(acknowledged)); const qs = params.toString(); fetch(`/api/risks${qs ? `?${qs}` : ""}`) .then((r) => { @@ -53,7 +56,7 @@ export function useRisks(options: UseRisksOptions = {}): UseRisksResult { return () => { cancelled = true; }; - }, [severity, deviceId, tick]); + }, [severity, deviceId, acknowledged, tick]); return { risks, loading, error, refetch: () => setTick((t) => t + 1) }; } diff --git a/frontend/src/pages/RisksPage.tsx b/frontend/src/pages/RisksPage.tsx index 191bc5b..de0797a 100644 --- a/frontend/src/pages/RisksPage.tsx +++ b/frontend/src/pages/RisksPage.tsx @@ -11,8 +11,125 @@ import type { Risk, Severity } from "../types/api"; const SEVERITIES: Severity[] = SEV_LEVELS; -// Modal for full risk detail -function RiskModal({ risk, onClose }: { risk: Risk; onClose: () => void }) { +type Tab = "active" | "accepted"; + +// ── Acknowledge modal ───────────────────────────────────────────────────────── + +function AcknowledgeModal({ + risk, + onClose, + onDone, +}: { + risk: Risk; + onClose: () => void; + onDone: () => void; +}) { + const [note, setNote] = useState(""); + const [saving, setSaving] = useState(false); + + const submit = () => { + setSaving(true); + fetch(`/api/risks/${risk.id}/acknowledge`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ note: note.trim() || null }), + }) + .then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + }) + .then(onDone) + .catch(() => setSaving(false)); + }; + + return ( +
+
+ e.stopPropagation()} + > +
+

Acknowledge risk

+ +
+

+ Mark {risk.title} as accepted. It will be hidden from + the active list and excluded from risk counts. Acknowledgements + survive re-scans. +

+ +