feat(ui): add interactive charts with filter navigation (#9333)

This commit is contained in:
Alan Buscaglia
2025-11-27 16:04:55 +01:00
committed by GitHub
parent 06fa57a949
commit 6af9ff4b4b
23 changed files with 383 additions and 208 deletions

View File

@@ -6,8 +6,8 @@ import { useRelatedFilters } from "@/hooks";
import { FilterEntity, FilterType, ScanEntity, ScanProps } from "@/types";
interface FindingsFiltersProps {
providerUIDs: string[];
providerDetails: { [uid: string]: FilterEntity }[];
providerIds: string[];
providerDetails: { [id: string]: FilterEntity }[];
completedScans: ScanProps[];
completedScanIds: string[];
scanDetails: { [key: string]: ScanEntity }[];
@@ -17,7 +17,7 @@ interface FindingsFiltersProps {
}
export const FindingsFilters = ({
providerUIDs,
providerIds,
providerDetails,
completedScanIds,
scanDetails,
@@ -25,8 +25,8 @@ export const FindingsFilters = ({
uniqueServices,
uniqueResourceTypes,
}: FindingsFiltersProps) => {
const { availableProviderUIDs, availableScans } = useRelatedFilters({
providerUIDs,
const { availableProviderIds, availableScans } = useRelatedFilters({
providerIds,
providerDetails,
completedScanIds,
scanDetails,
@@ -42,9 +42,9 @@ export const FindingsFilters = ({
customFilters={[
...filterFindings,
{
key: FilterType.PROVIDER_UID,
labelCheckboxGroup: "Provider UID",
values: availableProviderUIDs,
key: FilterType.PROVIDER,
labelCheckboxGroup: "Provider",
values: availableProviderIds,
valueLabelMapping: providerDetails,
index: 6,
},

View File

@@ -8,20 +8,57 @@ import { ChartConfig, ChartContainer } from "@/components/ui/chart/Chart";
import { ChartLegend } from "./shared/chart-legend";
import { DonutDataPoint } from "./types";
const CHART_COLORS = {
emptyState: "var(--border-neutral-tertiary)",
};
interface TooltipPayloadData {
percentage?: number;
change?: number;
color?: string;
}
interface TooltipPayloadEntry {
name: string;
color?: string;
payload?: TooltipPayloadData;
}
interface CustomTooltipProps {
active?: boolean;
payload?: TooltipPayloadEntry[];
}
interface LegendPayloadData {
percentage?: number;
}
interface LegendPayloadEntry {
value: string;
color: string;
payload: LegendPayloadData;
}
interface CustomLegendProps {
payload: LegendPayloadEntry[];
}
interface CenterLabel {
value: string | number;
label: string;
}
interface DonutChartProps {
data: DonutDataPoint[];
height?: number;
innerRadius?: number;
outerRadius?: number;
showLegend?: boolean;
centerLabel?: {
value: string | number;
label: string;
};
centerLabel?: CenterLabel;
onSegmentClick?: (dataPoint: DonutDataPoint, index: number) => void;
}
const CustomTooltip = ({ active, payload }: any) => {
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
if (!active || !payload || !payload.length) return null;
const entry = payload[0];
@@ -58,9 +95,9 @@ const CustomTooltip = ({ active, payload }: any) => {
);
};
const CustomLegend = ({ payload }: any) => {
const items = payload.map((entry: any) => ({
label: `${entry.value} (${entry.payload.percentage}%)`,
const CustomLegend = ({ payload }: CustomLegendProps) => {
const items = payload.map((entry: LegendPayloadEntry) => ({
label: `${entry.value} (${entry.payload.percentage ?? 0}%)`,
color: entry.color,
}));
@@ -104,8 +141,8 @@ export function DonutChart({
{
name: "No data",
value: 1,
fill: "var(--border-neutral-tertiary)",
color: "var(--border-neutral-tertiary)",
fill: CHART_COLORS.emptyState,
color: CHART_COLORS.emptyState,
percentage: 0,
change: undefined,
},
@@ -145,9 +182,9 @@ export function DonutChart({
key={`cell-${index}`}
fill={entry.fill}
opacity={opacity}
className={isClickable ? "cursor-pointer" : ""}
style={{
transition: "opacity 0.2s",
cursor: isClickable ? "pointer" : "default",
}}
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}

View File

@@ -3,6 +3,8 @@
import { Bell } from "lucide-react";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { SEVERITY_ORDER } from "./shared/constants";
import { getSeverityColorByName } from "./shared/utils";
import { BarDataPoint } from "./types";
@@ -62,8 +64,10 @@ export function HorizontalBarChart({
return (
<div
key={item.name}
className="flex items-center gap-6"
style={{ cursor: isClickable ? "pointer" : "default" }}
className={cn(
"flex items-center gap-6",
isClickable && "cursor-pointer",
)}
role={isClickable ? "button" : undefined}
tabIndex={isClickable ? 0 : undefined}
onMouseEnter={() => !isEmpty && setHoveredIndex(index)}

View File

@@ -1,15 +1,28 @@
"use client";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { Rectangle, ResponsiveContainer, Sankey, Tooltip } from "recharts";
import { PROVIDER_ICONS } from "@/components/icons/providers-badge";
import { initializeChartColors } from "@/lib/charts/colors";
import { mapProviderFiltersForFindings } from "@/lib/provider-helpers";
import { PROVIDER_DISPLAY_NAMES } from "@/types/providers";
import { SEVERITY_FILTER_MAP } from "@/types/severities";
import { ChartTooltip } from "./shared/chart-tooltip";
// Reverse mapping from display name to provider type for URL filters
const PROVIDER_TYPE_MAP: Record<string, string> = Object.entries(
PROVIDER_DISPLAY_NAMES,
).reduce(
(acc, [type, displayName]) => {
acc[displayName] = type;
return acc;
},
{} as Record<string, string>,
);
interface SankeyNode {
name: string;
newFindings?: number;
@@ -105,6 +118,7 @@ interface CustomLinkProps {
onLinkHover?: (index: number, data: Omit<LinkTooltipState, "show">) => void;
onLinkMove?: (position: { x: number; y: number }) => void;
onLinkLeave?: () => void;
onLinkClick?: (sourceName: string, targetName: string) => void;
}
const CustomTooltip = ({ active, payload }: TooltipProps) => {
@@ -281,6 +295,7 @@ const CustomLink = ({
onLinkHover,
onLinkMove,
onLinkLeave,
onLinkClick,
}: CustomLinkProps) => {
const sourceName = payload.source?.name || "";
const targetName = payload.target?.name || "";
@@ -344,6 +359,12 @@ const CustomLink = ({
onLinkLeave?.();
};
const handleClick = () => {
if (!isHidden && onLinkClick) {
onLinkClick(sourceName, targetName);
}
};
return (
<g>
<path
@@ -355,6 +376,7 @@ const CustomLink = ({
onMouseEnter={handleMouseEnter}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
/>
</g>
);
@@ -362,6 +384,7 @@ const CustomLink = ({
export function SankeyChart({ data, height = 400 }: SankeyChartProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [hoveredLink, setHoveredLink] = useState<number | null>(null);
const [colors, setColors] = useState<Record<string, string>>({});
const [linkTooltip, setLinkTooltip] = useState<LinkTooltipState>({
@@ -428,7 +451,27 @@ export function SankeyChart({ data, height = 400 }: SankeyChartProps) {
const handleNodeClick = (nodeName: string) => {
const severityFilter = SEVERITY_FILTER_MAP[nodeName];
if (severityFilter) {
router.push(`/findings?filter[severity]=${severityFilter}`);
const params = new URLSearchParams(searchParams.toString());
mapProviderFiltersForFindings(params);
params.set("filter[severity__in]", severityFilter);
router.push(`/findings?${params.toString()}`);
}
};
const handleLinkClick = (sourceName: string, targetName: string) => {
const providerType = PROVIDER_TYPE_MAP[sourceName];
const severityFilter = SEVERITY_FILTER_MAP[targetName];
if (providerType && severityFilter) {
const params = new URLSearchParams(searchParams.toString());
mapProviderFiltersForFindings(params);
params.set("filter[provider_type__in]", providerType);
params.set("filter[severity__in]", severityFilter);
router.push(`/findings?${params.toString()}`);
}
};
@@ -452,7 +495,12 @@ export function SankeyChart({ data, height = 400 }: SankeyChartProps) {
const wrappedCustomLink = (
props: Omit<
CustomLinkProps,
"colors" | "hoveredLink" | "onLinkHover" | "onLinkMove" | "onLinkLeave"
| "colors"
| "hoveredLink"
| "onLinkHover"
| "onLinkMove"
| "onLinkLeave"
| "onLinkClick"
>,
) => (
<CustomLink
@@ -462,6 +510,7 @@ export function SankeyChart({ data, height = 400 }: SankeyChartProps) {
onLinkHover={handleLinkHover}
onLinkMove={handleLinkMove}
onLinkLeave={handleLinkLeave}
onLinkClick={handleLinkClick}
/>
);

View File

@@ -8,6 +8,7 @@ import type {
Geometry,
} from "geojson";
import { AlertTriangle, ChevronDown, Info, MapPin } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { feature } from "topojson-client";
import type {
@@ -17,6 +18,7 @@ import type {
} from "topojson-specification";
import { Card } from "@/components/shadcn/card/card";
import { mapProviderFiltersForFindings } from "@/lib/provider-helpers";
import { HorizontalBarChart } from "./horizontal-bar-chart";
import { BarDataPoint } from "./types";
@@ -91,6 +93,8 @@ interface LocationPoint {
id: string;
name: string;
region: string;
regionCode: string;
providerType: string;
coordinates: [number, number];
totalFindings: number;
riskLevel: RiskLevel;
@@ -226,10 +230,17 @@ function LoadingState({ height }: { height: number }) {
);
}
const STATUS_FILTER_MAP: Record<string, string> = {
Fail: "FAIL",
Pass: "PASS",
};
export function ThreatMap({
data,
height = MAP_CONFIG.defaultHeight,
}: ThreatMapProps) {
const router = useRouter();
const searchParams = useSearchParams();
const svgRef = useRef<SVGSVGElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [selectedLocation, setSelectedLocation] =
@@ -559,7 +570,28 @@ export function ThreatMap({
Findings
</p>
</div>
<HorizontalBarChart data={selectedLocation.severityData} />
<HorizontalBarChart
data={selectedLocation.severityData}
onBarClick={(dataPoint) => {
const status = STATUS_FILTER_MAP[dataPoint.name];
if (status && selectedLocation.providerType) {
const params = new URLSearchParams(searchParams.toString());
mapProviderFiltersForFindings(params);
params.set(
"filter[provider_type__in]",
selectedLocation.providerType,
);
params.set(
"filter[region__in]",
selectedLocation.regionCode,
);
params.set("filter[status__in]", status);
router.push(`/findings?${params.toString()}`);
}
}}
/>
</div>
) : (
<EmptyState />

View File

@@ -18,6 +18,7 @@ export const ScansFilters = ({
providerUIDs,
providerDetails,
enableScanRelation: false,
providerFilterType: FilterType.PROVIDER_UID,
});
return (