Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions backend/app/analysis/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,23 +63,32 @@ 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,
check_id=rd.check_id,
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)
Expand Down
59 changes: 57 additions & 2 deletions backend/app/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand All @@ -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

Expand All @@ -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 "")
Expand All @@ -235,10 +243,10 @@ 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:
Expand All @@ -264,6 +272,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."""
Expand All @@ -289,6 +342,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,
)


Expand Down
2 changes: 2 additions & 0 deletions backend/app/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions backend/app/models/risk.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
7 changes: 5 additions & 2 deletions frontend/src/hooks/useRisks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { Risk, RiskSummary, Severity } from "../types/api";
export interface UseRisksOptions {
severity?: Severity;
deviceId?: number;
acknowledged?: boolean;
}

export interface UseRisksResult {
Expand All @@ -23,7 +24,7 @@ export function useRisks(options: UseRisksOptions = {}): UseRisksResult {
const [error, setError] = useState<string | null>(null);
const [tick, setTick] = useState(0);

const { severity, deviceId } = options;
const { severity, deviceId, acknowledged } = options;

useEffect(() => {
let cancelled = false;
Expand All @@ -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) => {
Expand All @@ -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) };
}
Expand Down
Loading
Loading