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
35 changes: 31 additions & 4 deletions backend/app/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down Expand Up @@ -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]

Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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,
Expand All @@ -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,
Expand Down
45 changes: 45 additions & 0 deletions frontend/src/components/ScoreBadge.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span
title={`Security score: ${score}/100`}
className={[
"inline-flex items-center justify-center rounded-full border font-mono font-medium",
sizeClasses[size],
scoreColour(score),
className,
]
.filter(Boolean)
.join(" ")}
>
{score}
</span>
);
}
2 changes: 2 additions & 0 deletions frontend/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,5 @@ export { ScanBanner } from "./ScanBanner";
export type { ScanBannerProps } from "./ScanBanner";

export { PageHeader } from "./PageHeader";

export { ScoreBadge } from "./ScoreBadge";
22 changes: 21 additions & 1 deletion frontend/src/pages/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
ToastContainer,
SkeletonCard,
PageHeader,
ScoreBadge,
} from "../components";
import { useDevices, useScans, useTriggerScan, useRiskSummary } from "../hooks";
import { useScanStatus } from "../hooks/useScanStatus";
Expand Down Expand Up @@ -236,7 +237,7 @@ export function DashboardPage() {
) : (
<>
{/* Summary stat cards */}
<div className="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
<StatCard
label="Total Devices"
value={devices.length}
Expand Down Expand Up @@ -272,6 +273,25 @@ export function DashboardPage() {
icon={<ShieldIcon />}
to="/risks"
/>
<StatCard
label="Avg Score"
value={
devices.length > 0 ? (
<ScoreBadge
score={Math.round(
devices.reduce((s, d) => s + d.security_score, 0) /
devices.length,
)}
size="md"
/>
) : (
"—"
)
}
accentColor={STRIPE.devices}
icon={<ShieldIcon />}
to="/devices"
/>
</div>

{/* Last scan */}
Expand Down
19 changes: 18 additions & 1 deletion frontend/src/pages/DeviceDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@
*/
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";
Expand Down Expand Up @@ -292,6 +298,17 @@ export function DeviceDetailPage() {
</dd>
</div>
))}
<div className="flex flex-col">
<dt className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">
Security Score
</dt>
<dd className="mt-1">
<ScoreBadge score={device.security_score} size="lg" />
<span className="ml-2 text-xs text-[var(--color-text-secondary)]">
/ 100
</span>
</dd>
</div>
</dl>
</Card>

Expand Down
17 changes: 16 additions & 1 deletion frontend/src/pages/DevicesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@
*/
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";

Expand Down Expand Up @@ -97,6 +103,7 @@ type SortKey =
| "os_guess"
| "ports"
| "risks"
| "score"
| "last_seen";
type SortDir = "asc" | "desc";

Expand Down Expand Up @@ -130,6 +137,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 ?? "";
Expand Down Expand Up @@ -268,6 +279,7 @@ export function DevicesPage() {
["os_guess", "OS"],
["ports", "Ports"],
["risks", "Risks"],
["score", "Score"],
["last_seen", "Last Seen"],
] as [SortKey, string][]
).map(([key, label]) => (
Expand Down Expand Up @@ -345,6 +357,9 @@ export function DevicesPage() {
<Badge variant="neutral">0</Badge>
)}
</td>
<td className="px-4 py-3">
<ScoreBadge score={device.security_score} size="sm" />
</td>
<td className="px-4 py-3 text-[var(--color-text-secondary)]">
{device.last_seen
? new Date(device.last_seen).toLocaleString()
Expand Down
1 change: 1 addition & 0 deletions frontend/src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading