From 9b7a4ace399cbb357d6e8dddc968289690f0ef1e Mon Sep 17 00:00:00 2001 From: wind Date: Sat, 28 Feb 2026 19:22:49 +0100 Subject: [PATCH] feat: clickable dashboard cards and risks+remediations side by side (#38 #39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #38 — Dashboard StatCards as navigation links: - Added optional 'to' prop to StatCard; wraps content in when set - Devices card → /devices - Critical/High cards → /risks?severity=critical|high - Medium/Low card → /risks - Hover state: subtle border highlight + shadow on all cards #39 — Device detail risks and remediations side by side: - RiskRecPair component: risk card left, linked recommendation right (lg:grid-cols-2) - Recommendations matched to risks via rec.risk_id === risk.id (O(1) Map lookup) - Falls back gracefully: no rec shows placeholder; orphan recs render below - Combined section heading 'Risks & Remediations' replaces two separate headings Closes #38 Closes #39 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- frontend/src/pages/DashboardPage.tsx | 16 +++- frontend/src/pages/DeviceDetailPage.tsx | 120 +++++++++++++++--------- 2 files changed, 91 insertions(+), 45 deletions(-) diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index cfc9ac9..905abca 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -108,17 +108,22 @@ const StatCard = memo(function StatCard({ sub, accentColor, icon, + to, }: { label: string; value: React.ReactNode; sub?: string; accentColor: string; icon: React.ReactNode; + to?: string; }) { - return ( + const inner = (
@@ -140,6 +145,7 @@ const StatCard = memo(function StatCard({
); + return to ? {inner} : inner; }); export function DashboardPage() { @@ -208,6 +214,7 @@ export function DashboardPage() { value={devices.length} accentColor={STRIPE.devices} icon={} + to="/devices" /> } + to="/risks?severity=critical" /> } + to="/risks?severity=high" /> } + to="/risks" />
diff --git a/frontend/src/pages/DeviceDetailPage.tsx b/frontend/src/pages/DeviceDetailPage.tsx index dd3496f..17d6e9e 100644 --- a/frontend/src/pages/DeviceDetailPage.tsx +++ b/frontend/src/pages/DeviceDetailPage.tsx @@ -2,7 +2,7 @@ * DeviceDetailPage — ports/services table, risk list, timestamps. * Route: /devices/:id */ -import { memo } from "react"; +import { memo, useMemo } from "react"; import { Link, useParams } from "react-router-dom"; import { Card, Badge, SkeletonCard, PageHeader } from "../components"; import { useDevice, useRisks, useDeviceRecommendations } from "../hooks"; @@ -49,6 +49,30 @@ const RecCard = memo(function RecCard({ rec }: { rec: Recommendation }) { ); }); +/** A risk paired with its linked recommendation (if any). */ +const RiskRecPair = memo(function RiskRecPair({ + risk, + rec, +}: { + risk: Risk; + rec: Recommendation | undefined; +}) { + return ( +
+ + {rec ? ( + + ) : ( + +

+ No remediation available for this risk. +

+
+ )} +
+ ); +}); + export function DeviceDetailPage() { const { id } = useParams<{ id: string }>(); const deviceId = Number(id); @@ -57,6 +81,20 @@ export function DeviceDetailPage() { const { recommendations, loading: recsLoading } = useDeviceRecommendations(deviceId); + // Build a map of risk_id → recommendation for O(1) lookup + const recByRiskId = useMemo(() => { + const map = new Map(); + recommendations.forEach((r) => map.set(r.risk_id, r)); + return map; + }, [recommendations]); + + // Recommendations not linked to any risk shown in the list + const linkedRiskIds = useMemo(() => new Set(risks.map((r) => r.id)), [risks]); + const orphanRecs = useMemo( + () => recommendations.filter((r) => !linkedRiskIds.has(r.risk_id)), + [recommendations, linkedRiskIds], + ); + if (loading) return ; if (error) return

Error: {error}

; @@ -169,61 +207,59 @@ export function DeviceDetailPage() { )} - {/* Risks */} -

- Risks{" "} - {!risksLoading && ( - - ({sortedRisks.length}) + {/* Risks + Remediations paired side by side */} +
+

Risks & Remediations

+ {!risksLoading && !recsLoading && ( + + {sortedRisks.length} risk{sortedRisks.length !== 1 ? "s" : ""} )} -

- {risksLoading ? ( -
- {Array.from({ length: 2 }).map((_, i) => ( - - ))} -
- ) : sortedRisks.length === 0 ? ( - -

- No risks detected for this device. -

-
- ) : ( -
- {sortedRisks.map((risk) => ( - - ))} -
- )} + - {/* Recommendations */} -

- Recommendations{" "} - {!recsLoading && ( - - ({recommendations.length}) - - )} -

- {recsLoading ? ( + {risksLoading || recsLoading ? (
{Array.from({ length: 2 }).map((_, i) => ( ))}
- ) : recommendations.length === 0 ? ( + ) : sortedRisks.length === 0 && orphanRecs.length === 0 ? (

- No recommendations for this device. + No risks or recommendations for this device.

) : ( -
- {recommendations.map((rec) => ( - +
+ {/* Header row for desktop */} + {sortedRisks.length > 0 && ( +
+

+ Risk +

+

+ Remediation +

+
+ )} + {sortedRisks.map((risk) => ( + ))} + {/* Orphan recommendations not linked to any discovered risk */} + {orphanRecs.length > 0 && ( + <> +

+ Additional Recommendations +

+ {orphanRecs.map((rec) => ( + + ))} + + )}
)}