feat: add risk severity chart to new overview page (#9041)

This commit is contained in:
Alejandro Bailo
2025-10-28 12:07:19 +01:00
committed by GitHub
parent affd0c5ffb
commit 90fbad16b9
7 changed files with 284 additions and 133 deletions

View File

@@ -0,0 +1,71 @@
"use client";
import { HorizontalBarChart } from "@/components/graphs/horizontal-bar-chart";
import { BarDataPoint } from "@/components/graphs/types";
import {
BaseCard,
CardContent,
CardHeader,
CardTitle,
} from "@/components/shadcn";
import { calculatePercentage } from "@/lib/utils";
interface RiskSeverityChartProps {
critical: number;
high: number;
medium: number;
low: number;
informational: number;
}
export const RiskSeverityChart = ({
critical,
high,
medium,
low,
informational,
}: RiskSeverityChartProps) => {
// Calculate total findings
const totalFindings = critical + high + medium + low + informational;
// Transform data to BarDataPoint format
const chartData: BarDataPoint[] = [
{
name: "Critical",
value: critical,
percentage: calculatePercentage(critical, totalFindings),
},
{
name: "High",
value: high,
percentage: calculatePercentage(high, totalFindings),
},
{
name: "Medium",
value: medium,
percentage: calculatePercentage(medium, totalFindings),
},
{
name: "Low",
value: low,
percentage: calculatePercentage(low, totalFindings),
},
{
name: "Info",
value: informational,
percentage: calculatePercentage(informational, totalFindings),
},
];
return (
<BaseCard className="flex h-full flex-col">
<CardHeader>
<CardTitle>Risk Severity</CardTitle>
</CardHeader>
<CardContent className="flex flex-1 items-center justify-start px-6">
<HorizontalBarChart data={chartData} />
</CardContent>
</BaseCard>
);
};

View File

@@ -13,8 +13,9 @@ import {
ResourceStatsCard,
ResourceStatsCardContainer,
} from "@/components/shadcn";
import { calculatePercentage } from "@/lib/utils";
interface CheckFindingsProps {
interface StatusChartProps {
failFindingsData: {
total: number;
new: number;
@@ -27,30 +28,32 @@ interface CheckFindingsProps {
};
}
export const CheckFindings = ({
export const StatusChart = ({
failFindingsData,
passFindingsData,
}: CheckFindingsProps) => {
}: StatusChartProps) => {
// Calculate total findings
const totalFindings = failFindingsData.total + passFindingsData.total;
// Calculate percentages
const failPercentage = Math.round(
(failFindingsData.total / totalFindings) * 100,
const failPercentage = calculatePercentage(
failFindingsData.total,
totalFindings,
);
const passPercentage = Math.round(
(passFindingsData.total / totalFindings) * 100,
const passPercentage = calculatePercentage(
passFindingsData.total,
totalFindings,
);
// Calculate change percentages (new findings as percentage change)
const failChange =
failFindingsData.total > 0
? Math.round((failFindingsData.new / failFindingsData.total) * 100)
: 0;
const passChange =
passFindingsData.total > 0
? Math.round((passFindingsData.new / passFindingsData.total) * 100)
: 0;
const failChange = calculatePercentage(
failFindingsData.new,
failFindingsData.total,
);
const passChange = calculatePercentage(
passFindingsData.new,
passFindingsData.total,
);
// Mock data for DonutChart
const donutData: DonutDataPoint[] = [
@@ -72,13 +75,11 @@ export const CheckFindings = ({
return (
<BaseCard>
{/* Header */}
<CardHeader>
<CardTitle>Check Findings</CardTitle>
</CardHeader>
{/* DonutChart Content */}
<CardContent className="space-y-4">
<CardContent className="space-y-2">
<div className="mx-auto max-h-[200px] max-w-[200px]">
<DonutChart
data={donutData}
@@ -92,8 +93,7 @@ export const CheckFindings = ({
/>
</div>
{/* Footer with ResourceStatsCards */}
<ResourceStatsCardContainer className="flex w-full flex-col items-start justify-center gap-4 sm:flex-row md:w-[480px] md:justify-between">
<ResourceStatsCardContainer className="flex w-full flex-col items-start justify-center gap-4 lg:flex-row lg:justify-between">
<ResourceStatsCard
containerless
badge={{
@@ -111,11 +111,11 @@ export const CheckFindings = ({
? { message: "No failed findings to display" }
: undefined
}
className="flex-1"
className="w-full lg:min-w-0 lg:flex-1"
/>
<div className="flex w-full items-center justify-center sm:w-auto sm:self-stretch sm:px-[46px]">
<div className="h-px w-full bg-slate-300 sm:h-full sm:w-px dark:bg-[rgba(39,39,42,1)]" />
<div className="flex w-full items-center justify-center lg:w-auto lg:self-stretch">
<div className="h-px w-full bg-slate-300 lg:h-full lg:w-px dark:bg-[rgba(39,39,42,1)]" />
</div>
<ResourceStatsCard
@@ -135,7 +135,7 @@ export const CheckFindings = ({
? { message: "No passed findings to display" }
: undefined
}
className="flex-1"
className="w-full lg:min-w-0 lg:flex-1"
/>
</ResourceStatsCardContainer>
</CardContent>

View File

@@ -1,13 +1,17 @@
import { Suspense } from "react";
import { getFindingsByStatus } from "@/actions/overview/overview";
import {
getFindingsBySeverity,
getFindingsByStatus,
} from "@/actions/overview/overview";
import { getProviders } from "@/actions/providers";
import { ContentLayout } from "@/components/ui";
import { SearchParamsProps } from "@/types";
import { AccountsSelector } from "./components/accounts-selector";
import { CheckFindings } from "./components/check-findings";
import { ProviderTypeSelector } from "./components/provider-type-selector";
import { RiskSeverityChart } from "./components/risk-severity-chart";
import { StatusChart } from "./components/status-chart";
const FILTER_PREFIX = "filter[";
@@ -35,16 +39,26 @@ export default async function NewOverviewPage({
<ProviderTypeSelector providers={providersData?.data ?? []} />
<AccountsSelector providers={providersData?.data ?? []} />
</div>
<div className="flex flex-col gap-6 md:flex-row">
<div className="grid auto-rows-fr gap-6 md:grid-cols-2">
<Suspense
fallback={
<div className="flex h-[400px] w-full max-w-md items-center justify-center rounded-xl border border-zinc-900 bg-stone-950">
<div className="flex h-[400px] w-full items-center justify-center rounded-xl border border-zinc-900 bg-stone-950">
<p className="text-zinc-400">Loading...</p>
</div>
}
>
<SSRCheckFindings searchParams={resolvedSearchParams} />
</Suspense>
<Suspense
fallback={
<div className="flex h-[400px] w-full items-center justify-center rounded-xl border border-zinc-900 bg-stone-950">
<p className="text-zinc-400">Loading...</p>
</div>
}
>
<SSRRiskSeverityChart searchParams={resolvedSearchParams} />
</Suspense>
</div>
</ContentLayout>
);
@@ -79,7 +93,7 @@ const SSRCheckFindings = async ({
const mutedTotal = muted_new + muted_changed;
return (
<CheckFindings
<StatusChart
failFindingsData={{
total: fail,
new: fail_new,
@@ -93,3 +107,39 @@ const SSRCheckFindings = async ({
/>
);
};
const SSRRiskSeverityChart = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
const filters = pickFilterParams(searchParams);
const findingsBySeverity = await getFindingsBySeverity({ filters });
if (!findingsBySeverity) {
return (
<div className="flex h-[400px] w-full items-center justify-center rounded-xl border border-zinc-900 bg-stone-950">
<p className="text-zinc-400">Failed to load severity data</p>
</div>
);
}
const {
critical = 0,
high = 0,
medium = 0,
low = 0,
informational = 0,
} = findingsBySeverity?.data?.attributes || {};
return (
<RiskSeverityChart
critical={critical}
high={high}
medium={medium}
low={low}
informational={informational}
/>
);
};

View File

@@ -30,28 +30,29 @@ const CustomTooltip = ({ active, payload }: any) => {
const change = entry.payload?.change;
return (
<div className="rounded-lg border border-slate-200 bg-white p-3 shadow-lg dark:border-slate-600 dark:bg-slate-800">
<div className="flex items-center gap-1">
<div
className="h-3 w-3 rounded-sm"
style={{ backgroundColor: color }}
/>
<span className="text-sm font-semibold text-slate-600 dark:text-zinc-300">
{percentage}%
</span>
<span>{name}</span>
</div>
<p className="mt-1 text-xs text-slate-600 dark:text-zinc-300">
<div className="rounded-xl border border-slate-200 bg-white px-3 py-1.5 shadow-lg dark:border-[#202020] dark:bg-[#121110]">
<div className="flex flex-col gap-0.5">
{/* Title with color chip */}
<div className="flex items-center gap-1">
<div
className="size-3 shrink-0 rounded"
style={{ backgroundColor: color }}
/>
<p className="text-sm leading-5 font-medium text-slate-900 dark:text-[#f4f4f5]">
{percentage}% {name}
</p>
</div>
{/* Change percentage row */}
{change !== undefined && (
<>
<span className="font-bold">
<div className="flex items-start">
<p className="text-sm leading-5 font-medium text-slate-600 dark:text-[#d4d4d8]">
{change > 0 ? "+" : ""}
{change}%
</span>
<span> Since Last Scan</span>
</>
{change}% Since last scan
</p>
</div>
)}
</p>
</div>
</div>
);
};

View File

@@ -16,16 +16,26 @@ interface HorizontalBarChartProps {
export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const sortedData = [...data].sort((a, b) => {
const total = data.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
const isEmpty = total <= 0;
const emptyData: BarDataPoint[] = [
{ name: "Critical", value: 1, percentage: 100 },
{ name: "High", value: 1, percentage: 100 },
{ name: "Medium", value: 1, percentage: 100 },
{ name: "Low", value: 1, percentage: 100 },
];
const sortedData = (isEmpty ? emptyData : [...data]).sort((a, b) => {
const orderA = SEVERITY_ORDER[a.name as keyof typeof SEVERITY_ORDER] ?? 999;
const orderB = SEVERITY_ORDER[b.name as keyof typeof SEVERITY_ORDER] ?? 999;
return orderA - orderB;
});
return (
<div className="w-full">
<div className="w-full space-y-6">
{title && (
<div className="mb-4">
<div>
<h3
className="text-lg font-semibold"
style={{ color: "var(--chart-text-primary)" }}
@@ -37,23 +47,25 @@ export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {
<div className="space-y-6">
{sortedData.map((item, index) => {
const isHovered = hoveredIndex === index;
const isFaded = hoveredIndex !== null && !isHovered;
const barColor =
item.color ||
getSeverityColorByName(item.name) ||
CHART_COLORS.defaultColor;
const isHovered = !isEmpty && hoveredIndex === index;
const isFaded = !isEmpty && hoveredIndex !== null && !isHovered;
const barColor = isEmpty
? CHART_COLORS.gridLine
: item.color ||
getSeverityColorByName(item.name) ||
CHART_COLORS.defaultColor;
return (
<div
key={index}
className="relative flex items-center gap-4"
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}
className="flex items-center gap-6"
onMouseEnter={() => !isEmpty && setHoveredIndex(index)}
onMouseLeave={() => !isEmpty && setHoveredIndex(null)}
>
<div className="w-24 text-right">
{/* Label */}
<div className="w-20 shrink-0">
<span
className="text-sm"
className="text-sm font-medium"
style={{
color: "var(--chart-text-primary)",
opacity: isFaded ? 0.5 : 1,
@@ -64,78 +76,84 @@ export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {
</span>
</div>
{/* Bar - flexible */}
<div className="relative flex-1">
<div className="absolute inset-0 h-8 w-full rounded-lg bg-slate-700/50" />
<div
className="relative h-8 rounded-lg transition-all duration-300"
style={{
width: `${item.percentage || (item.value / Math.max(...data.map((d) => d.value))) * 100}%`,
backgroundColor: barColor,
opacity: isFaded ? 0.5 : 1,
}}
/>
<div className="absolute inset-0 h-[22px] w-full rounded-xl bg-[#FAFAFA] dark:bg-black" />
{(item.value > 0 || isEmpty) && (
<div
className="relative h-[22px] rounded-[4px] border border-black/10 transition-all duration-300"
style={{
width: isEmpty
? `${item.percentage}%`
: `${item.percentage || (item.value / Math.max(...data.map((d) => d.value))) * 100}%`,
backgroundColor: barColor,
opacity: isFaded ? 0.5 : 1,
}}
/>
)}
{isHovered && (
<div
className="absolute top-10 left-0 z-10 min-w-[200px] rounded-lg border p-3 shadow-lg"
style={{
backgroundColor: "var(--chart-background)",
borderColor: "var(--chart-border-emphasis)",
}}
>
<div className="flex items-center gap-2">
<div
className="h-3 w-3 rounded-sm"
style={{ backgroundColor: barColor }}
/>
<span
className="font-semibold"
style={{ color: "var(--chart-text-primary)" }}
>
{item.value.toLocaleString()} {item.name} Risk
</span>
</div>
{item.newFindings !== undefined && (
<div className="mt-2 flex items-center gap-2">
<Bell
size={14}
style={{ color: "var(--chart-fail)" }}
<div className="absolute top-10 left-0 z-10 rounded-xl border border-slate-200 bg-white px-3 py-1.5 shadow-lg dark:border-[#202020] dark:bg-[#121110]">
<div className="flex flex-col gap-0.5">
{/* Title with color chip */}
<div className="flex items-center gap-1">
<div
className="size-3 shrink-0 rounded"
style={{ backgroundColor: barColor }}
/>
<span
className="text-sm"
style={{ color: "var(--chart-text-secondary)" }}
>
{item.newFindings} New Findings
</span>
<p className="text-sm leading-5 font-medium text-slate-900 dark:text-[#f4f4f5]">
{item.value.toLocaleString()} {item.name} Risk
</p>
</div>
)}
{item.change !== undefined && (
<p
className="mt-1 text-sm"
style={{ color: "var(--chart-text-secondary)" }}
>
<span className="font-bold">
{item.change > 0 ? "+" : ""}
{item.change}%
</span>{" "}
Since Last Scan
</p>
)}
{/* New Findings row */}
{item.newFindings !== undefined && (
<div className="flex items-center gap-1">
<Bell
size={12}
className="shrink-0 text-slate-600 dark:text-[#d4d4d8]"
/>
<p className="text-sm leading-5 font-medium text-slate-600 dark:text-[#d4d4d8]">
{item.newFindings} New Findings
</p>
</div>
)}
{/* Change percentage row */}
{item.change !== undefined && (
<div className="flex items-start">
<p className="text-sm leading-5 font-medium text-slate-600 dark:text-[#d4d4d8]">
{item.change > 0 ? "+" : ""}
{item.change}% Since last scan
</p>
</div>
)}
</div>
</div>
)}
</div>
{/* Percentage and Count */}
<div
className="flex w-40 items-center gap-2 text-sm"
className="flex w-[90px] shrink-0 items-center gap-2 text-sm"
style={{
color: "var(--chart-text-primary)",
opacity: isFaded ? 0.5 : 1,
transition: "opacity 0.2s",
}}
>
<span className="font-semibold">{item.percentage}%</span>
<span style={{ color: "var(--chart-text-secondary)" }}></span>
<span className="font-bold">{item.value.toLocaleString()}</span>
<span className="w-[26px] text-right font-medium">
{isEmpty ? "0" : item.percentage}%
</span>
<span
className="font-medium"
style={{ color: "var(--chart-text-secondary)" }}
>
</span>
<span className="font-bold">
{isEmpty ? "0" : item.value.toLocaleString()}
</span>
</div>
</div>
);

View File

@@ -6,3 +6,14 @@ export function cn(...inputs: ClassValue[]) {
}
export const SPECIAL_CHARACTERS = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
/**
* Calculates a percentage and rounds it to the nearest integer
* @param value - The numerator value
* @param total - The denominator value
* @returns The rounded percentage (0-100), or 0 if total is 0
*/
export function calculatePercentage(value: number, total: number): number {
if (total === 0) return 0;
return Math.round((value / total) * 100);
}

View File

@@ -3,11 +3,11 @@
@theme {
/* Chart Severity Colors - Dark Theme */
--chart-info: #2e51b2;
--chart-warning: #fdd34f;
--chart-warning-emphasis: #ff7d19;
--chart-danger: #ff3077;
--chart-danger-emphasis: #971348;
--chart-info: #3C8DFF;
--chart-warning: #FDFBD4;
--chart-warning-emphasis: #FEC94D;
--chart-danger: #F77852;
--chart-danger-emphasis: #FF006A;
/* Chart Status Colors */
--chart-success-color: #86da26;
@@ -37,11 +37,11 @@
@layer base {
:root {
/* Light Theme Chart Colors */
--chart-info: #1e40af;
--chart-warning: #d97706;
--chart-warning-emphasis: #dc2626;
--chart-danger: #dc2626;
--chart-danger-emphasis: #991b1b;
--chart-info: #3C8DFF;
--chart-warning: #FDFBD4;
--chart-warning-emphasis: #FEC94D;
--chart-danger: #F77852;
--chart-danger-emphasis: #FF006A;
--chart-success-color: #16a34a;
--chart-fail: #dc2626;
--chart-radar-primary: #9d174d;
@@ -69,11 +69,11 @@
.dark {
/* Dark Theme Chart Colors */
--chart-info: #2e51b2;
--chart-warning: #fdd34f;
--chart-warning-emphasis: #ff7d19;
--chart-danger: #ff3077;
--chart-danger-emphasis: #971348;
--chart-info: #3C8DFF;
--chart-warning: #FDFBD4;
--chart-warning-emphasis: #FEC94D;
--chart-danger: #F77852;
--chart-danger-emphasis: #FF006A;
--chart-success-color: #86da26;
--chart-fail: #db2b49;
--chart-radar-primary: #b51c80;