feat(ui/overview): add click navigation for charts and threat score improvements (#9281)

This commit is contained in:
Alan Buscaglia
2025-11-20 18:47:42 +01:00
committed by GitHub
parent 46bfe02ee8
commit 58bb66ff27
10 changed files with 226 additions and 61 deletions
@@ -1,4 +1,5 @@
import { getFindingsByStatus } from "@/actions/overview/overview";
import { getProviders } from "@/actions/providers";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../lib/filter-params";
@@ -11,7 +12,10 @@ export const CheckFindingsSSR = async ({
}) => {
const filters = pickFilterParams(searchParams);
const findingsByStatus = await getFindingsByStatus({ filters });
const [findingsByStatus, providersData] = await Promise.all([
getFindingsByStatus({ filters }),
getProviders({ page: 1, pageSize: 200 }),
]);
if (!findingsByStatus) {
return (
@@ -21,29 +25,21 @@ export const CheckFindingsSSR = async ({
);
}
const {
fail = 0,
pass = 0,
muted_new = 0,
muted_changed = 0,
fail_new = 0,
pass_new = 0,
} = findingsByStatus?.data?.attributes || {};
const attributes = findingsByStatus?.data?.attributes || {};
const mutedTotal = muted_new + muted_changed;
const { fail = 0, pass = 0, fail_new = 0, pass_new = 0 } = attributes;
return (
<StatusChart
failFindingsData={{
total: fail,
new: fail_new,
muted: mutedTotal,
}}
passFindingsData={{
total: pass,
new: pass_new,
muted: mutedTotal,
}}
providers={providersData?.data}
/>
);
};
@@ -1,4 +1,5 @@
import { getFindingsBySeverity } from "@/actions/overview/overview";
import { getProviders } from "@/actions/providers";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../lib/filter-params";
@@ -11,7 +12,10 @@ export const RiskSeverityChartDetailSSR = async ({
}) => {
const filters = pickFilterParams(searchParams);
const findingsBySeverity = await getFindingsBySeverity({ filters });
const [findingsBySeverity, providersData] = await Promise.all([
getFindingsBySeverity({ filters }),
getProviders({ page: 1, pageSize: 200 }),
]);
if (!findingsBySeverity) {
return (
@@ -36,6 +40,7 @@ export const RiskSeverityChartDetailSSR = async ({
medium={medium}
low={low}
informational={informational}
providers={providersData?.data}
/>
);
};
@@ -1,5 +1,7 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { HorizontalBarChart } from "@/components/graphs/horizontal-bar-chart";
import { BarDataPoint } from "@/components/graphs/types";
import {
@@ -11,12 +13,23 @@ import {
} from "@/components/shadcn";
import { calculatePercentage } from "@/lib/utils";
interface ProviderAttributes {
uid: string;
provider: string;
}
interface Provider {
id: string;
attributes: ProviderAttributes;
}
interface RiskSeverityChartProps {
critical: number;
high: number;
medium: number;
low: number;
informational: number;
providers?: Provider[];
}
export const RiskSeverityChart = ({
@@ -25,7 +38,55 @@ export const RiskSeverityChart = ({
medium,
low,
informational,
providers = [],
}: RiskSeverityChartProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const handleBarClick = (dataPoint: BarDataPoint) => {
// Build the URL with current filters plus severity and muted
const params = new URLSearchParams(searchParams.toString());
// Convert filter[provider_id__in] to filter[provider_uid__in] for findings page
const providerIds = params.get("filter[provider_id__in]");
if (providerIds) {
params.delete("filter[provider_id__in]");
// Remove provider_type__in since provider_id__in is more specific
params.delete("filter[provider_type__in]");
const ids = providerIds.split(",");
const uids = ids
.map((id) => {
const provider = providers.find((p) => p.id === id);
return provider?.attributes.uid;
})
.filter(Boolean);
if (uids.length > 0) {
params.set("filter[provider_uid__in]", uids.join(","));
}
}
// Map severity name to lowercase for the filter
const severityMap: Record<string, string> = {
Critical: "critical",
High: "high",
Medium: "medium",
Low: "low",
Info: "informational",
};
const severity = severityMap[dataPoint.name];
if (severity) {
params.set("filter[severity__in]", severity);
}
// Add exclude muted findings filter
params.set("filter[muted]", "false");
// Navigate to findings page
router.push(`/findings?${params.toString()}`);
};
// Calculate total findings
const totalFindings = critical + high + medium + low + informational;
@@ -68,7 +129,7 @@ export const RiskSeverityChart = ({
</CardHeader>
<CardContent className="flex flex-1 items-center justify-start px-6">
<HorizontalBarChart data={chartData} />
<HorizontalBarChart data={chartData} onBarClick={handleBarClick} />
</CardContent>
</Card>
);
@@ -1,6 +1,7 @@
"use client";
import { Bell, BellOff, ShieldCheck, TriangleAlert } from "lucide-react";
import { Bell, ShieldCheck, TriangleAlert } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { DonutChart } from "@/components/graphs/donut-chart";
import { DonutDataPoint } from "@/components/graphs/types";
@@ -15,26 +16,76 @@ import {
} from "@/components/shadcn";
import { calculatePercentage } from "@/lib/utils";
interface FindingsData {
total: number;
new: number;
}
interface ProviderAttributes {
uid: string;
provider: string;
}
interface Provider {
id: string;
attributes: ProviderAttributes;
}
interface StatusChartProps {
failFindingsData: {
total: number;
new: number;
muted: number;
};
passFindingsData: {
total: number;
new: number;
muted: number;
};
failFindingsData: FindingsData;
passFindingsData: FindingsData;
providers?: Provider[];
}
export const StatusChart = ({
failFindingsData,
passFindingsData,
providers = [],
}: StatusChartProps) => {
// Calculate total findings
const router = useRouter();
const searchParams = useSearchParams();
// Calculate total from displayed findings (fail + pass)
const totalFindings = failFindingsData.total + passFindingsData.total;
const handleSegmentClick = (dataPoint: DonutDataPoint) => {
// Build the URL with current filters plus status and muted
const params = new URLSearchParams(searchParams.toString());
// Convert filter[provider_id__in] to filter[provider_uid__in] for findings page
const providerIds = params.get("filter[provider_id__in]");
if (providerIds) {
params.delete("filter[provider_id__in]");
// Remove provider_type__in since provider_id__in is more specific
params.delete("filter[provider_type__in]");
const ids = providerIds.split(",");
const uids = ids
.map((id) => {
const provider = providers.find((p) => p.id === id);
return provider?.attributes.uid;
})
.filter(Boolean);
if (uids.length > 0) {
params.set("filter[provider_uid__in]", uids.join(","));
}
}
// Add status filter based on which segment was clicked
if (dataPoint.name === "Fail Findings") {
params.set("filter[status__in]", "FAIL");
} else if (dataPoint.name === "Pass Findings") {
params.set("filter[status__in]", "PASS");
}
// Add exclude muted findings filter
params.set("filter[muted]", "false");
// Navigate to findings page
router.push(`/findings?${params.toString()}`);
};
// Calculate percentages
const failPercentage = calculatePercentage(
failFindingsData.total,
@@ -93,6 +144,7 @@ export const StatusChart = ({
value: totalFindings.toLocaleString(),
label: "Total Findings",
}}
onSegmentClick={handleSegmentClick}
/>
</div>
@@ -109,10 +161,7 @@ export const StatusChart = ({
variant: CardVariant.fail,
}}
label="Fail Findings"
stats={[
{ icon: Bell, label: `${failFindingsData.new} New` },
{ icon: BellOff, label: `${failFindingsData.muted} Muted` },
]}
stats={[{ icon: Bell, label: `${failFindingsData.new} New` }]}
emptyState={
failFindingsData.total === 0
? { message: "No failed findings to display" }
@@ -133,10 +182,7 @@ export const StatusChart = ({
variant: CardVariant.pass,
}}
label="Pass Findings"
stats={[
{ icon: Bell, label: `${passFindingsData.new} New` },
{ icon: BellOff, label: `${passFindingsData.muted} Muted` },
]}
stats={[{ icon: Bell, label: `${passFindingsData.new} New` }]}
emptyState={
passFindingsData.total === 0
? { message: "No passed findings to display" }
@@ -15,25 +15,31 @@ import {
Skeleton,
} from "@/components/shadcn";
// CSS variables are required here as they're passed to RadialChart component
// which uses Recharts library that needs actual color values, not Tailwind classes
const THREAT_COLORS = {
DANGER: "var(--bg-fail-primary)",
WARNING: "var(--bg-warning-primary)",
SUCCESS: "var(--bg-pass-primary)",
NEUTRAL: "var(--bg-neutral-tertiary)",
} as const;
const THREAT_LEVEL_CONFIG = {
DANGER: {
label: "Critical Risk",
color: "var(--bg-fail-primary)",
chartColor: "var(--bg-fail-primary)",
color: THREAT_COLORS.DANGER,
minScore: 0,
maxScore: 30,
},
WARNING: {
label: "Moderate Risk",
color: "var(--bg-warning-primary)",
chartColor: "var(--bg-warning-primary)",
color: THREAT_COLORS.WARNING,
minScore: 31,
maxScore: 60,
},
SUCCESS: {
label: "Secure",
color: "var(--bg-pass-primary)",
chartColor: "var(--bg-pass-primary)",
color: THREAT_COLORS.SUCCESS,
minScore: 61,
maxScore: 100,
},
@@ -68,7 +74,7 @@ function convertSectionScoresToTooltipData(
return Object.entries(sectionScores).map(([name, value]) => {
// Determine color based on the same ranges as THREAT_LEVEL_CONFIG
const threatLevel = getThreatLevel(value);
const color = THREAT_LEVEL_CONFIG[threatLevel].chartColor;
const color = THREAT_LEVEL_CONFIG[threatLevel].color;
return { name, value, color };
});
@@ -126,8 +132,8 @@ export function ThreatScore({
<RadialChart
percentage={displayScore}
label="Score"
color={config.chartColor}
backgroundColor="var(--bg-neutral-tertiary)"
color={config.color}
backgroundColor={THREAT_COLORS.NEUTRAL}
height={206}
innerRadius={90}
outerRadius={115}
@@ -162,8 +168,7 @@ export function ThreatScore({
<div className="flex items-start gap-1.5">
<ThumbsUp
size={16}
className="mt-0.5 shrink-0"
style={{ minWidth: "16px", minHeight: "16px" }}
className="mt-0.5 min-h-4 min-w-4 shrink-0"
/>
<p>
Threat score has{" "}
@@ -178,8 +183,7 @@ export function ThreatScore({
<div className="flex items-start gap-1.5">
<MessageCircleWarning
size={16}
className="mt-0.5 shrink-0"
style={{ minWidth: "16px", minHeight: "16px" }}
className="mt-0.5 min-h-4 min-w-4 shrink-0"
/>
<p>
Major gaps include {gaps.slice(0, 2).join(", ")}