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,6 +6,8 @@ export interface ThreatMapLocation {
id: string;
name: string;
region: string;
regionCode: string;
providerType: string;
coordinates: [number, number];
totalFindings: number;
riskLevel: "low-high" | "high" | "critical";
@@ -173,8 +175,8 @@ function getRiskLevel(failRate: number): "low-high" | "high" | "critical" {
}
// CSS variables are used for Recharts inline styles, not className
function buildSeverityData(fail: number, pass: number, muted: number) {
const total = fail + pass + muted;
function buildSeverityData(fail: number, pass: number) {
const total = fail + pass;
const pct = (value: number) =>
total > 0 ? Math.round((value / total) * 100) : 0;
@@ -191,12 +193,6 @@ function buildSeverityData(fail: number, pass: number, muted: number) {
percentage: pct(pass),
color: "var(--color-bg-pass)",
},
{
name: "Muted",
value: muted,
percentage: pct(muted),
color: "var(--color-bg-data-muted)",
},
];
}
@@ -254,14 +250,12 @@ export function adaptRegionsOverviewToThreatMap(
id,
name: formatRegionName(attributes.provider_type, attributes.region),
region: providerRegion,
regionCode: attributes.region,
providerType: attributes.provider_type,
coordinates,
totalFindings: attributes.fail,
riskLevel: getRiskLevel(failRate),
severityData: buildSeverityData(
attributes.fail,
attributes.pass,
attributes.muted,
),
severityData: buildSeverityData(attributes.fail, attributes.pass),
});
}

View File

@@ -128,23 +128,41 @@ export function AccountsSelector({ providers }: AccountsSelectorProps) {
</MultiSelectTrigger>
<MultiSelectContent search={false}>
{visibleProviders.length > 0 ? (
visibleProviders.map((p) => {
const id = p.id;
const displayName = p.attributes.alias || p.attributes.uid;
const providerType = p.attributes.provider as ProviderType;
const icon = PROVIDER_ICON[providerType];
return (
<MultiSelectItem
key={id}
value={id}
badgeLabel={displayName}
aria-label={`${displayName} account (${providerType.toUpperCase()})`}
>
<span aria-hidden="true">{icon}</span>
<span className="truncate">{displayName}</span>
</MultiSelectItem>
);
})
<>
<div
role="option"
aria-selected={selectedIds.length === 0}
aria-label="Select all accounts (clears current selection to show all)"
tabIndex={0}
className="text-text-neutral-secondary flex w-full cursor-pointer items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold hover:bg-slate-200 dark:hover:bg-slate-700/50"
onClick={() => handleMultiValueChange([])}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleMultiValueChange([]);
}
}}
>
Select All
</div>
{visibleProviders.map((p) => {
const id = p.id;
const displayName = p.attributes.alias || p.attributes.uid;
const providerType = p.attributes.provider as ProviderType;
const icon = PROVIDER_ICON[providerType];
return (
<MultiSelectItem
key={id}
value={id}
badgeLabel={displayName}
aria-label={`${displayName} account (${providerType.toUpperCase()})`}
>
<span aria-hidden="true">{icon}</span>
<span className="truncate">{displayName}</span>
</MultiSelectItem>
);
})}
</>
) : (
<div className="px-3 py-2 text-sm text-slate-500 dark:text-slate-400">
{selectedTypesList.length > 0

View File

@@ -1,5 +1,4 @@
import { getFindingsByStatus } from "@/actions/overview/overview";
import { getProviders } from "@/actions/providers";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../lib/filter-params";
@@ -12,10 +11,7 @@ export const CheckFindingsSSR = async ({
}) => {
const filters = pickFilterParams(searchParams);
const [findingsByStatus, providersData] = await Promise.all([
getFindingsByStatus({ filters }),
getProviders({ page: 1, pageSize: 200 }),
]);
const findingsByStatus = await getFindingsByStatus({ filters });
if (!findingsByStatus) {
return (
@@ -39,7 +35,6 @@ export const CheckFindingsSSR = async ({
total: pass,
new: pass_new,
}}
providers={providersData?.data}
/>
);
};

View File

@@ -36,7 +36,16 @@ export async function FindingsViewSSR({ searchParams }: FindingsViewSSRProps) {
};
const filters = pickFilterParams(searchParams);
const combinedFilters = { ...defaultFilters, ...filters };
// Map provider_id__in to provider__in for findings API
const mappedFilters = { ...filters };
if (mappedFilters["filter[provider_id__in]"]) {
mappedFilters["filter[provider__in]"] =
mappedFilters["filter[provider_id__in]"];
delete mappedFilters["filter[provider_id__in]"];
}
const combinedFilters = { ...defaultFilters, ...mappedFilters };
const findingsData = await getLatestFindings({
query: undefined,

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useRef, useState } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/shadcn";
@@ -12,9 +12,18 @@ interface GraphsTabsClientProps {
export const GraphsTabsClient = ({ tabsContent }: GraphsTabsClientProps) => {
const [activeTab, setActiveTab] = useState<TabId>("findings");
const contentRef = useRef<HTMLDivElement>(null);
const handleValueChange = (value: string) => {
setActiveTab(value as TabId);
// Scroll to the end of the tab content after a short delay for render
setTimeout(() => {
contentRef.current?.scrollIntoView({
behavior: "smooth",
block: "end",
});
}, 100);
};
return (
@@ -35,17 +44,19 @@ export const GraphsTabsClient = ({ tabsContent }: GraphsTabsClientProps) => {
))}
</TabsList>
{GRAPH_TABS.map((tab) =>
activeTab === tab.id ? (
<TabsContent
key={tab.id}
value={tab.id}
className="mt-10 flex flex-1 overflow-visible"
>
{tabsContent[tab.id]}
</TabsContent>
) : null,
)}
<div ref={contentRef}>
{GRAPH_TABS.map((tab) =>
activeTab === tab.id ? (
<TabsContent
key={tab.id}
value={tab.id}
className="mt-10 flex flex-1 overflow-visible"
>
{tabsContent[tab.id]}
</TabsContent>
) : null,
)}
</div>
</Tabs>
);
};

View File

@@ -1,7 +1,7 @@
export const GRAPH_TABS = [
{
id: "findings",
label: "Findings",
label: "New Findings",
},
{
id: "risk-pipeline",

View File

@@ -195,17 +195,35 @@ export const ProviderTypeSelector = ({
</MultiSelectTrigger>
<MultiSelectContent search={false}>
{availableTypes.length > 0 ? (
availableTypes.map((providerType) => (
<MultiSelectItem
key={providerType}
value={providerType}
badgeLabel={PROVIDER_DATA[providerType].label}
aria-label={`${PROVIDER_DATA[providerType].label} provider`}
<>
<div
role="option"
aria-selected={selectedTypes.length === 0}
aria-label="Select all providers (clears current selection to show all)"
tabIndex={0}
className="text-text-neutral-secondary flex w-full cursor-pointer items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold hover:bg-slate-200 dark:hover:bg-slate-700/50"
onClick={() => handleMultiValueChange([])}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleMultiValueChange([]);
}
}}
>
<span aria-hidden="true">{renderIcon(providerType)}</span>
<span>{PROVIDER_DATA[providerType].label}</span>
</MultiSelectItem>
))
Select All
</div>
{availableTypes.map((providerType) => (
<MultiSelectItem
key={providerType}
value={providerType}
badgeLabel={PROVIDER_DATA[providerType].label}
aria-label={`${PROVIDER_DATA[providerType].label} provider`}
>
<span aria-hidden="true">{renderIcon(providerType)}</span>
<span>{PROVIDER_DATA[providerType].label}</span>
</MultiSelectItem>
))}
</>
) : (
<div className="px-3 py-2 text-sm text-slate-500 dark:text-slate-400">
No connected providers available

View File

@@ -1,5 +1,4 @@
import { getFindingsBySeverity } from "@/actions/overview/overview";
import { getProviders } from "@/actions/providers";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../lib/filter-params";
@@ -12,10 +11,7 @@ export const RiskSeverityChartDetailSSR = async ({
}) => {
const filters = pickFilterParams(searchParams);
const [findingsBySeverity, providersData] = await Promise.all([
getFindingsBySeverity({ filters }),
getProviders({ page: 1, pageSize: 200 }),
]);
const findingsBySeverity = await getFindingsBySeverity({ filters });
if (!findingsBySeverity) {
return (
@@ -40,7 +36,6 @@ export const RiskSeverityChartDetailSSR = async ({
medium={medium}
low={low}
informational={informational}
providers={providersData?.data}
/>
);
};

View File

@@ -11,26 +11,16 @@ import {
CardTitle,
Skeleton,
} from "@/components/shadcn";
import { mapProviderFiltersForFindings } from "@/lib/provider-helpers";
import { calculatePercentage } from "@/lib/utils";
import { SEVERITY_FILTER_MAP } from "@/types/severities";
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 = ({
@@ -39,7 +29,6 @@ export const RiskSeverityChart = ({
medium,
low,
informational,
providers = [],
}: RiskSeverityChartProps) => {
const router = useRouter();
const searchParams = useSearchParams();
@@ -48,25 +37,7 @@ export const RiskSeverityChart = ({
// 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(","));
}
}
mapProviderFiltersForFindings(params);
const severity = SEVERITY_FILTER_MAP[dataPoint.name];
if (severity) {

View File

@@ -14,32 +14,21 @@ import {
ResourceStatsCard,
Skeleton,
} from "@/components/shadcn";
import { mapProviderFiltersForFindings } from "@/lib/provider-helpers";
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: FindingsData;
passFindingsData: FindingsData;
providers?: Provider[];
}
export const StatusChart = ({
failFindingsData,
passFindingsData,
providers = [],
}: StatusChartProps) => {
const router = useRouter();
const searchParams = useSearchParams();
@@ -51,25 +40,7 @@ export const StatusChart = ({
// 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(","));
}
}
mapProviderFiltersForFindings(params);
// Add status filter based on which segment was clicked
if (dataPoint.name === "Fail Findings") {

View File

@@ -1,14 +1,18 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { ServiceOverview } from "@/actions/overview";
import { mapProviderFiltersForFindings } from "@/lib/provider-helpers";
import { SortToggleButton } from "./sort-toggle-button";
import { WatchlistCard } from "./watchlist-card";
import { WatchlistCard, WatchlistItem } from "./watchlist-card";
export const ServiceWatchlist = ({ items }: { items: ServiceOverview[] }) => {
const [isAsc, setIsAsc] = useState(true);
const router = useRouter();
const searchParams = useSearchParams();
const [isAsc, setIsAsc] = useState(false);
const sortedItems = [...items]
.sort((a, b) =>
@@ -24,12 +28,19 @@ export const ServiceWatchlist = ({ items }: { items: ServiceOverview[] }) => {
value: item.attributes.fail,
}));
const handleItemClick = (item: WatchlistItem) => {
const params = new URLSearchParams(searchParams.toString());
mapProviderFiltersForFindings(params);
params.set("filter[service__in]", item.key);
router.push(`/findings?${params.toString()}`);
};
return (
<WatchlistCard
title="Service Watchlist"
items={sortedItems}
ctaLabel="Services Dashboard"
ctaHref="/services"
headerAction={
<SortToggleButton
isAscending={isAsc}
@@ -40,9 +51,8 @@ export const ServiceWatchlist = ({ items }: { items: ServiceOverview[] }) => {
}
emptyState={{
message: "This space is looking empty.",
description: "to add services to your watchlist.",
linkText: "Services Dashboard",
}}
onItemClick={handleItemClick}
/>
);
};

View File

@@ -51,14 +51,15 @@ export interface WatchlistCardProps
extends React.HTMLAttributes<HTMLDivElement> {
title: string;
items: WatchlistItem[];
ctaLabel: string;
ctaHref: string;
ctaLabel?: string;
ctaHref?: string;
headerAction?: React.ReactNode;
emptyState?: {
message?: string;
description?: string;
linkText?: string;
};
onItemClick?: (item: WatchlistItem) => void;
}
export const WatchlistCard = ({
@@ -68,14 +69,12 @@ export const WatchlistCard = ({
ctaHref,
headerAction,
emptyState,
onItemClick,
}: WatchlistCardProps) => {
const isEmpty = items.length === 0;
return (
<Card
variant="base"
className="flex min-h-[405px] min-w-[312px] flex-col justify-between"
>
<Card variant="base" className="flex min-h-[405px] min-w-[312px] flex-col">
<div className="flex items-center justify-between">
<CardTitle>{title}</CardTitle>
{headerAction}
@@ -93,7 +92,7 @@ export const WatchlistCard = ({
{/* Description with link */}
<p className="text-text-neutral-tertiary w-full text-sm leading-6">
{emptyState?.description && (
{emptyState?.description && ctaHref && (
<>
Visit the{" "}
<Button variant="link" size="link-sm" asChild>
@@ -122,12 +121,25 @@ export const WatchlistCard = ({
? getScoreTextColor(numericValue)
: "text-text-neutral-tertiary";
const isClickable = !!onItemClick;
return (
<div
key={item.key}
role={isClickable ? "button" : undefined}
tabIndex={isClickable ? 0 : undefined}
onClick={() => onItemClick?.(item)}
onKeyDown={(e) => {
if (isClickable && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
onItemClick?.(item);
}
}}
className={cn(
"flex h-[54px] items-center justify-between gap-2 px-3 py-[11px]",
!isLast && "border-border-neutral-tertiary border-b",
isClickable &&
"hover:bg-bg-neutral-tertiary cursor-pointer",
)}
>
<div className="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-md bg-white">
@@ -154,11 +166,13 @@ export const WatchlistCard = ({
)}
</CardContent>
<CardFooter className="mb-6">
<Button variant="link" size="link-sm" asChild className="w-full">
<Link href={ctaHref}>{ctaLabel}</Link>
</Button>
</CardFooter>
{ctaLabel && ctaHref && (
<CardFooter className="mb-6">
<Button variant="link" size="link-sm" asChild className="w-full">
<Link href={ctaHref}>{ctaLabel}</Link>
</Button>
</CardFooter>
)}
</Card>
);
};

View File

@@ -24,8 +24,8 @@ import {
hasDateOrScanFilter,
} from "@/lib";
import {
createProviderDetailsMapping,
extractProviderUIDs,
createProviderDetailsMappingById,
extractProviderIds,
} from "@/lib/provider-helpers";
import { FilterEntity, ScanEntity, ScanProps } from "@/types";
import { FindingProps, SearchParamsProps } from "@/types/components";
@@ -59,11 +59,11 @@ export default async function Findings({
const uniqueResourceTypes =
metadataInfoData?.data?.attributes?.resource_types || [];
// Extract provider UIDs and details using helper functions
const providerUIDs = providersData ? extractProviderUIDs(providersData) : [];
// Extract provider IDs and details using helper functions
const providerIds = providersData ? extractProviderIds(providersData) : [];
const providerDetails = providersData
? (createProviderDetailsMapping(providerUIDs, providersData) as {
[uid: string]: FilterEntity;
? (createProviderDetailsMappingById(providerIds, providersData) as {
[id: string]: FilterEntity;
}[])
: [];
@@ -85,7 +85,7 @@ export default async function Findings({
return (
<ContentLayout title="Findings" icon="lucide:tag">
<FindingsFilters
providerUIDs={providerUIDs}
providerIds={providerIds}
providerDetails={providerDetails}
completedScans={completedScans || []}
completedScanIds={completedScanIds}

View File

@@ -19,7 +19,6 @@ import {
ThreatScoreSSR,
} from "./_new-overview/components/threat-score";
import {
ComplianceWatchlistSSR,
ServiceWatchlistSSR,
WatchlistCardSkeleton,
} from "./_new-overview/components/watchlist";
@@ -51,18 +50,13 @@ export default async function Home({
<Suspense fallback={<RiskSeverityChartSkeleton />}>
<RiskSeverityChartSSR searchParams={resolvedSearchParams} />
</Suspense>
<Suspense fallback={<WatchlistCardSkeleton />}>
<ServiceWatchlistSSR searchParams={resolvedSearchParams} />
</Suspense>
</div>
<div className="mt-6 flex flex-col gap-6">
<div className="flex flex-col gap-6 md:flex-row">
<Suspense fallback={<WatchlistCardSkeleton />}>
<ComplianceWatchlistSSR searchParams={resolvedSearchParams} />
</Suspense>
<Suspense fallback={<WatchlistCardSkeleton />}>
<ServiceWatchlistSSR searchParams={resolvedSearchParams} />
</Suspense>
</div>
<div className="mt-6">
<Suspense fallback={<RiskPipelineViewSkeleton />}>
<GraphsTabsWrapper searchParams={resolvedSearchParams} />
</Suspense>

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 (

View File

@@ -12,26 +12,33 @@ import {
} from "@/types";
interface UseRelatedFiltersProps {
providerUIDs: string[];
providerDetails: { [uid: string]: FilterEntity }[];
providerIds?: string[];
providerUIDs?: string[];
providerDetails: { [key: string]: FilterEntity }[];
completedScanIds?: string[];
scanDetails?: { [key: string]: ScanEntity }[];
enableScanRelation?: boolean;
providerFilterType?: FilterType.PROVIDER | FilterType.PROVIDER_UID;
}
export const useRelatedFilters = ({
providerUIDs,
providerIds = [],
providerUIDs = [],
providerDetails,
completedScanIds = [],
scanDetails = [],
enableScanRelation = false,
providerFilterType = FilterType.PROVIDER,
}: UseRelatedFiltersProps) => {
const searchParams = useSearchParams();
const { updateFilter } = useUrlFilters();
const [availableScans, setAvailableScans] =
useState<string[]>(completedScanIds);
const [availableProviderUIDs, setAvailableProviderUIDs] =
useState<string[]>(providerUIDs);
// Use providerIds if provided (for findings), otherwise use providerUIDs (for scans)
const providers = providerIds.length > 0 ? providerIds : providerUIDs;
const [availableProviders, setAvailableProviders] =
useState<string[]>(providers);
const previousProviders = useRef<string[]>([]);
const previousProviderTypes = useRef<ProviderType[]>([]);
const isManualDeselection = useRef(false);
@@ -52,13 +59,13 @@ export const useRelatedFilters = ({
return scanDetail ? scanDetail[scanId]?.providerInfo?.provider : null;
};
const getProviderType = (providerUid: string): ProviderType | null => {
const getProviderType = (providerKey: string): ProviderType | null => {
const providerDetail = providerDetails.find(
(detail) => Object.keys(detail)[0] === providerUid,
(detail) => Object.keys(detail)[0] === providerKey,
);
if (!providerDetail) return null;
const entity = providerDetail[providerUid];
const entity = providerDetail[providerKey];
if (!isScanEntity(entity as ScanEntity)) {
return (entity as ProviderEntity).provider;
}
@@ -69,9 +76,7 @@ export const useRelatedFilters = ({
const scanParam = enableScanRelation
? searchParams.get(`filter[${FilterType.SCAN}]`)
: null;
const providerParam = searchParams.get(
`filter[${FilterType.PROVIDER_UID}]`,
);
const providerParam = searchParams.get(`filter[${providerFilterType}]`);
const providerTypeParam = searchParams.get(
`filter[${FilterType.PROVIDER_TYPE}]`,
);
@@ -162,25 +167,25 @@ export const useRelatedFilters = ({
// Update available providers
if (currentProviderTypes.length > 0) {
const filteredProviderUIDs = providerUIDs.filter((uid) => {
const providerType = getProviderType(uid);
const filteredProviders = providers.filter((key) => {
const providerType = getProviderType(key);
return providerType && currentProviderTypes.includes(providerType);
});
setAvailableProviderUIDs(filteredProviderUIDs);
setAvailableProviders(filteredProviders);
const validProviders = currentProviders.filter((uid) => {
const providerType = getProviderType(uid);
const validProviders = currentProviders.filter((key) => {
const providerType = getProviderType(key);
return providerType && currentProviderTypes.includes(providerType);
});
if (validProviders.length !== currentProviders.length) {
updateFilter(
FilterType.PROVIDER_UID,
providerFilterType,
validProviders.length > 0 ? validProviders : null,
);
}
} else {
setAvailableProviderUIDs(providerUIDs);
setAvailableProviders(providers);
}
// Update available scans
@@ -207,7 +212,8 @@ export const useRelatedFilters = ({
}, [searchParams]);
return {
availableProviderUIDs,
availableProviderIds: providerIds.length > 0 ? availableProviders : [],
availableProviderUIDs: providerUIDs.length > 0 ? availableProviders : [],
availableScans,
};
};

View File

@@ -5,6 +5,22 @@ import {
ProviderType,
} from "@/types/providers";
/**
* Maps overview provider filters to findings page provider filters.
* Converts provider_id__in to provider__in and removes provider_type__in
* since provider__in is more specific.
*/
export const mapProviderFiltersForFindings = (
params: URLSearchParams,
): void => {
const providerIds = params.get("filter[provider_id__in]");
if (providerIds) {
params.delete("filter[provider_id__in]");
params.delete("filter[provider_type__in]");
params.set("filter[provider__in]", providerIds);
}
};
export const extractProviderUIDs = (
providersData: ProvidersApiResponse,
): string[] => {
@@ -19,6 +35,16 @@ export const extractProviderUIDs = (
);
};
export const extractProviderIds = (
providersData: ProvidersApiResponse,
): string[] => {
if (!providersData?.data) return [];
return providersData.data
.map((provider: ProviderProps) => provider.id)
.filter(Boolean);
};
export const createProviderDetailsMapping = (
providerUIDs: string[],
providersData: ProvidersApiResponse,
@@ -40,6 +66,25 @@ export const createProviderDetailsMapping = (
});
};
export const createProviderDetailsMappingById = (
providerIds: string[],
providersData: ProvidersApiResponse,
): Array<{ [id: string]: ProviderEntity }> => {
if (!providersData?.data) return [];
return providerIds.map((id) => {
const provider = providersData.data.find((p: ProviderProps) => p.id === id);
return {
[id]: {
provider: provider?.attributes?.provider || "aws",
uid: provider?.attributes?.uid || "",
alias: provider?.attributes?.alias ?? null,
},
};
});
};
// Helper function to determine which form type to show
export type ProviderFormType =
| "selector"

View File

@@ -24,6 +24,7 @@ export interface CustomDropdownFilterProps {
export enum FilterType {
SCAN = "scan__in",
PROVIDER = "provider__in",
PROVIDER_UID = "provider_uid__in",
PROVIDER_TYPE = "provider_type__in",
REGION = "region__in",