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
16 changes: 13 additions & 3 deletions frontend/src/pages/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
<div
className="relative overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5"
style={{ borderLeft: `3px solid ${accentColor}` }}
className="relative overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5 transition-all duration-150 hover:border-[var(--color-accent-primary)]/50 hover:shadow-md"
style={{
borderLeft: `3px solid ${accentColor}`,
cursor: to ? "pointer" : "default",
}}
>
<div className="flex items-start justify-between">
<div>
Expand All @@ -140,6 +145,7 @@ const StatCard = memo(function StatCard({
</div>
</div>
);
return to ? <Link to={to}>{inner}</Link> : inner;
});

export function DashboardPage() {
Expand Down Expand Up @@ -208,6 +214,7 @@ export function DashboardPage() {
value={devices.length}
accentColor={STRIPE.devices}
icon={<DeviceIcon />}
to="/devices"
/>
<StatCard
label="Critical Risks"
Expand All @@ -218,6 +225,7 @@ export function DashboardPage() {
}
accentColor={STRIPE.critical}
icon={<CriticalIcon />}
to="/risks?severity=critical"
/>
<StatCard
label="High Risks"
Expand All @@ -226,13 +234,15 @@ export function DashboardPage() {
}
accentColor={STRIPE.high}
icon={<WarningIcon />}
to="/risks?severity=high"
/>
<StatCard
label="Medium / Low"
value={(summary?.medium ?? 0) + (summary?.low ?? 0)}
sub="risks"
accentColor={STRIPE.other}
icon={<ShieldIcon />}
to="/risks"
/>
</div>

Expand Down
120 changes: 78 additions & 42 deletions frontend/src/pages/DeviceDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<div className="grid gap-3 lg:grid-cols-2">
<RiskCard risk={risk} />
{rec ? (
<RecCard rec={rec} />
) : (
<Card>
<p className="py-2 text-sm italic text-[var(--color-text-secondary)]">
No remediation available for this risk.
</p>
</Card>
)}
</div>
);
});

export function DeviceDetailPage() {
const { id } = useParams<{ id: string }>();
const deviceId = Number(id);
Expand All @@ -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<number, Recommendation>();
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 <SkeletonCard height="48" />;
if (error)
return <p className="text-[var(--color-accent-danger)]">Error: {error}</p>;
Expand Down Expand Up @@ -169,61 +207,59 @@ export function DeviceDetailPage() {
)}
</Card>

{/* Risks */}
<h2 className="mb-3 text-lg font-semibold">
Risks{" "}
{!risksLoading && (
<span className="text-base font-normal text-[var(--color-text-secondary)]">
({sortedRisks.length})
{/* Risks + Remediations paired side by side */}
<div className="mb-2 flex items-baseline gap-3">
<h2 className="text-lg font-semibold">Risks &amp; Remediations</h2>
{!risksLoading && !recsLoading && (
<span className="text-sm text-[var(--color-text-secondary)]">
{sortedRisks.length} risk{sortedRisks.length !== 1 ? "s" : ""}
</span>
)}
</h2>
{risksLoading ? (
<div className="flex flex-col gap-3">
{Array.from({ length: 2 }).map((_, i) => (
<SkeletonCard key={i} height="20" />
))}
</div>
) : sortedRisks.length === 0 ? (
<Card>
<p className="py-4 text-center text-sm text-[var(--color-text-secondary)]">
No risks detected for this device.
</p>
</Card>
) : (
<div className="flex flex-col gap-3">
{sortedRisks.map((risk) => (
<RiskCard key={risk.id} risk={risk} />
))}
</div>
)}
</div>

{/* Recommendations */}
<h2 className="mb-3 mt-6 text-lg font-semibold">
Recommendations{" "}
{!recsLoading && (
<span className="text-base font-normal text-[var(--color-text-secondary)]">
({recommendations.length})
</span>
)}
</h2>
{recsLoading ? (
{risksLoading || recsLoading ? (
<div className="flex flex-col gap-3">
{Array.from({ length: 2 }).map((_, i) => (
<SkeletonCard key={i} height="20" />
))}
</div>
) : recommendations.length === 0 ? (
) : sortedRisks.length === 0 && orphanRecs.length === 0 ? (
<Card>
<p className="py-4 text-center text-sm text-[var(--color-text-secondary)]">
No recommendations for this device.
No risks or recommendations for this device.
</p>
</Card>
) : (
<div className="flex flex-col gap-3">
{recommendations.map((rec) => (
<RecCard key={rec.id} rec={rec} />
<div className="flex flex-col gap-4">
{/* Header row for desktop */}
{sortedRisks.length > 0 && (
<div className="hidden lg:grid lg:grid-cols-2 lg:gap-3">
<p className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">
Risk
</p>
<p className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">
Remediation
</p>
</div>
)}
{sortedRisks.map((risk) => (
<RiskRecPair
key={risk.id}
risk={risk}
rec={recByRiskId.get(risk.id)}
/>
))}
{/* Orphan recommendations not linked to any discovered risk */}
{orphanRecs.length > 0 && (
<>
<h3 className="mt-2 text-sm font-semibold text-[var(--color-text-secondary)]">
Additional Recommendations
</h3>
{orphanRecs.map((rec) => (
<RecCard key={rec.id} rec={rec} />
))}
</>
)}
</div>
)}
</div>
Expand Down