mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-01-25 02:08:11 +00:00
feat(ui): add interactive charts with filter navigation (#9333)
This commit is contained in:
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -18,6 +18,7 @@ export const ScansFilters = ({
|
||||
providerUIDs,
|
||||
providerDetails,
|
||||
enableScanRelation: false,
|
||||
providerFilterType: FilterType.PROVIDER_UID,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user