diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index ba6be4c..64a0825 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -38,6 +38,7 @@ class DeviceOut(BaseModel): first_seen: str | None # ISO-8601 string last_seen: str | None ports: list[PortOut] = [] + security_score: int # 0–100; 100 = no risks model_config = {"from_attributes": True} @@ -74,7 +75,11 @@ def list_devices(db: Annotated[Session, Depends(get_db)]) -> list[DeviceOut]: """Return all known devices with their open ports.""" from app.models.device import Device - stmt = select(Device).options(selectinload(Device.ports)).order_by(Device.ip_address) + stmt = ( + select(Device) + .options(selectinload(Device.ports), selectinload(Device.risks)) + .order_by(Device.ip_address) + ) devices = db.execute(stmt).scalars().all() return [_device_to_out(d) for d in devices] @@ -84,7 +89,11 @@ def get_device(device_id: int, db: Annotated[Session, Depends(get_db)]) -> Devic """Return a single device by ID, including its ports.""" from app.models.device import Device - stmt = select(Device).options(selectinload(Device.ports)).where(Device.id == device_id) + stmt = ( + select(Device) + .options(selectinload(Device.ports), selectinload(Device.risks)) + .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") @@ -104,7 +113,11 @@ def set_device_trusted( """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) + stmt = ( + select(Device) + .options(selectinload(Device.ports), selectinload(Device.risks)) + .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") @@ -127,7 +140,11 @@ def set_device_label( """Set or clear the user-defined label on a device.""" from app.models.device import Device - stmt = select(Device).options(selectinload(Device.ports)).where(Device.id == device_id) + stmt = ( + select(Device) + .options(selectinload(Device.ports), selectinload(Device.risks)) + .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") @@ -137,6 +154,15 @@ def set_device_label( return _device_to_out(device) +def _device_security_score(d) -> int: # noqa: ANN001 — SQLAlchemy instance + """Compute 0-100 security score from active risks. 100 = clean.""" + if d.trusted: + return 100 + weights = {"critical": 30, "high": 15, "medium": 7, "low": 3} + penalty = sum(weights.get(r.severity, 0) for r in d.risks) + return max(0, 100 - penalty) + + def _device_to_out(d) -> DeviceOut: # noqa: ANN001 — SQLAlchemy instance, validated via Pydantic return DeviceOut( id=d.id, @@ -149,6 +175,7 @@ def _device_to_out(d) -> DeviceOut: # noqa: ANN001 — SQLAlchemy instance, val 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, + security_score=_device_security_score(d), ports=[ PortOut( id=p.id, diff --git a/frontend/src/components/ScoreBadge.tsx b/frontend/src/components/ScoreBadge.tsx new file mode 100644 index 0000000..b32d1ca --- /dev/null +++ b/frontend/src/components/ScoreBadge.tsx @@ -0,0 +1,45 @@ +/** + * ScoreBadge — displays a device's 0–100 security score with colour coding. + * ≥70 green, 40–69 amber, <40 red. + */ +interface ScoreBadgeProps { + score: number; + size?: "sm" | "md" | "lg"; + className?: string; +} + +function scoreColour(score: number): string { + if (score >= 70) + return "text-[var(--color-accent-positive)] border-[var(--color-accent-positive)]/40 bg-[var(--color-accent-positive)]/10"; + if (score >= 40) + return "text-[var(--color-accent-warning)] border-[var(--color-accent-warning)]/40 bg-[var(--color-accent-warning)]/10"; + return "text-[var(--color-accent-danger)] border-[var(--color-accent-danger)]/40 bg-[var(--color-accent-danger)]/10"; +} + +const sizeClasses = { + sm: "text-xs px-1.5 py-0.5 min-w-[2.25rem]", + md: "text-sm px-2 py-1 min-w-[2.75rem]", + lg: "text-base px-3 py-1.5 min-w-[3.5rem] font-semibold", +}; + +export function ScoreBadge({ + score, + size = "md", + className = "", +}: ScoreBadgeProps) { + return ( + + {score} + + ); +} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index deba70a..3cca15e 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -23,3 +23,5 @@ export { ScanBanner } from "./ScanBanner"; export type { ScanBannerProps } from "./ScanBanner"; export { PageHeader } from "./PageHeader"; + +export { ScoreBadge } from "./ScoreBadge"; diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 885781c..e1f966c 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -11,6 +11,7 @@ import { ToastContainer, SkeletonCard, PageHeader, + ScoreBadge, } from "../components"; import { useDevices, useScans, useTriggerScan, useRiskSummary } from "../hooks"; import { useScanStatus } from "../hooks/useScanStatus"; @@ -236,7 +237,7 @@ export function DashboardPage() { ) : ( <> {/* Summary stat cards */} -