From 961b605f7e2dd7de6893aba8d057489b96d1d31a Mon Sep 17 00:00:00 2001 From: wind Date: Sun, 1 Mar 2026 19:04:56 +0100 Subject: [PATCH 1/3] feat: device security score 0-100 (#88) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add _device_security_score() helper in api/__init__.py (weighted penalty: critical×30, high×15, medium×7, low×3; min 0) - Trusted devices always score 100 - Add security_score field to DeviceOut schema - Load Device.risks via selectinload in all four device endpoints - Add ScoreBadge component (colour-coded: green ≥70, amber 40-69, red <40) - Export ScoreBadge from components/index.ts - DevicesPage: new sortable Score column with ScoreBadge - DeviceDetailPage: Security Score row in metadata grid - DashboardPage: Avg Score stat card (5th card, replaces 4-grid with 5-grid) - Frontend type: add security_score: number to Device interface Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/app/api/__init__.py | 35 +++++++++++++++++--- frontend/src/components/ScoreBadge.tsx | 43 +++++++++++++++++++++++++ frontend/src/components/index.ts | 2 ++ frontend/src/pages/DashboardPage.tsx | 22 ++++++++++++- frontend/src/pages/DeviceDetailPage.tsx | 11 ++++++- frontend/src/pages/DevicesPage.tsx | 11 ++++++- frontend/src/types/api.ts | 1 + 7 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/ScoreBadge.tsx 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..c5b8006 --- /dev/null +++ b/frontend/src/components/ScoreBadge.tsx @@ -0,0 +1,43 @@ +/** + * ScoreBadge — displays a device's 0–100 security score with colour coding. + * ≥70 green, 40–69 amber, <40 red. + */ +import React from "react"; + +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 */} -
+
} to="/risks" /> + 0 ? ( + s + d.security_score, 0) / + devices.length, + )} + size="md" + /> + ) : ( + "—" + ) + } + accentColor={STRIPE.devices} + icon={} + to="/devices" + />
{/* Last scan */} diff --git a/frontend/src/pages/DeviceDetailPage.tsx b/frontend/src/pages/DeviceDetailPage.tsx index f897cb6..7a405c4 100644 --- a/frontend/src/pages/DeviceDetailPage.tsx +++ b/frontend/src/pages/DeviceDetailPage.tsx @@ -4,7 +4,7 @@ */ import { memo, useRef, useMemo, useState } from "react"; import { Link, useParams } from "react-router-dom"; -import { Card, Badge, SkeletonCard, PageHeader } from "../components"; +import { Card, Badge, SkeletonCard, PageHeader, ScoreBadge } from "../components"; import { SEV_LEVELS } from "../constants/severity"; import { useDevice, useRisks, useDeviceRecommendations } from "../hooks"; import type { Risk, Recommendation, Severity } from "../types/api"; @@ -292,6 +292,15 @@ export function DeviceDetailPage() {
))} +
+
+ Security Score +
+
+ + / 100 +
+
diff --git a/frontend/src/pages/DevicesPage.tsx b/frontend/src/pages/DevicesPage.tsx index 6a88a29..bbc1626 100644 --- a/frontend/src/pages/DevicesPage.tsx +++ b/frontend/src/pages/DevicesPage.tsx @@ -4,7 +4,7 @@ */ import { useMemo, useRef, useState } from "react"; import { Link } from "react-router-dom"; -import { Card, Badge, SkeletonTable, PageHeader } from "../components"; +import { Card, Badge, SkeletonTable, PageHeader, ScoreBadge } from "../components"; import { useDevices, useRisks } from "../hooks"; import type { Device } from "../types/api"; @@ -97,6 +97,7 @@ type SortKey = | "os_guess" | "ports" | "risks" + | "score" | "last_seen"; type SortDir = "asc" | "desc"; @@ -130,6 +131,10 @@ function sortDevices( av = riskCounts[a.id] ?? 0; bv = riskCounts[b.id] ?? 0; break; + case "score": + av = a.security_score; + bv = b.security_score; + break; case "last_seen": av = a.last_seen ?? ""; bv = b.last_seen ?? ""; @@ -268,6 +273,7 @@ export function DevicesPage() { ["os_guess", "OS"], ["ports", "Ports"], ["risks", "Risks"], + ["score", "Score"], ["last_seen", "Last Seen"], ] as [SortKey, string][] ).map(([key, label]) => ( @@ -345,6 +351,9 @@ export function DevicesPage() { 0 )} + + + {device.last_seen ? new Date(device.last_seen).toLocaleString() diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index b5d520f..09d1b96 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -23,6 +23,7 @@ export interface Device { first_seen: string | null; // ISO-8601 last_seen: string | null; ports: Port[]; + security_score: number; // 0–100 } export interface Scan { From b97e237c6cc6a27b17f60230ba7e0a0fe54cea7e Mon Sep 17 00:00:00 2001 From: wind Date: Sun, 1 Mar 2026 19:06:59 +0100 Subject: [PATCH 2/3] fix: remove unused React import from ScoreBadge Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- frontend/src/components/ScoreBadge.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/src/components/ScoreBadge.tsx b/frontend/src/components/ScoreBadge.tsx index c5b8006..5710c3c 100644 --- a/frontend/src/components/ScoreBadge.tsx +++ b/frontend/src/components/ScoreBadge.tsx @@ -2,8 +2,6 @@ * ScoreBadge — displays a device's 0–100 security score with colour coding. * ≥70 green, 40–69 amber, <40 red. */ -import React from "react"; - interface ScoreBadgeProps { score: number; size?: "sm" | "md" | "lg"; From 788aabc2474362680203c7d065bd79bc0ff44d57 Mon Sep 17 00:00:00 2001 From: wind Date: Sun, 1 Mar 2026 19:09:05 +0100 Subject: [PATCH 3/3] style: apply prettier formatting to new frontend files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- frontend/src/components/ScoreBadge.tsx | 6 +++++- frontend/src/pages/DeviceDetailPage.tsx | 12 ++++++++++-- frontend/src/pages/DevicesPage.tsx | 8 +++++++- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/ScoreBadge.tsx b/frontend/src/components/ScoreBadge.tsx index 5710c3c..b32d1ca 100644 --- a/frontend/src/components/ScoreBadge.tsx +++ b/frontend/src/components/ScoreBadge.tsx @@ -22,7 +22,11 @@ const sizeClasses = { lg: "text-base px-3 py-1.5 min-w-[3.5rem] font-semibold", }; -export function ScoreBadge({ score, size = "md", className = "" }: ScoreBadgeProps) { +export function ScoreBadge({ + score, + size = "md", + className = "", +}: ScoreBadgeProps) { return (
- / 100 + + / 100 +
diff --git a/frontend/src/pages/DevicesPage.tsx b/frontend/src/pages/DevicesPage.tsx index bbc1626..304537c 100644 --- a/frontend/src/pages/DevicesPage.tsx +++ b/frontend/src/pages/DevicesPage.tsx @@ -4,7 +4,13 @@ */ import { useMemo, useRef, useState } from "react"; import { Link } from "react-router-dom"; -import { Card, Badge, SkeletonTable, PageHeader, ScoreBadge } from "../components"; +import { + Card, + Badge, + SkeletonTable, + PageHeader, + ScoreBadge, +} from "../components"; import { useDevices, useRisks } from "../hooks"; import type { Device } from "../types/api";