feat(ui): add Risk Pipeline View with Sankey chart to Overview page (#9320)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Alan Buscaglia
2025-11-26 13:33:58 +01:00
committed by GitHub
parent 880345bebe
commit 4e9dd46a5e
17 changed files with 610 additions and 222 deletions

View File

@@ -2,6 +2,14 @@
All notable changes to the **Prowler UI** are documented in this file.
## [1.15.0] (Unreleased)
### 🚀 Added
- Compliance Watchlist component to Overview page [(#9199)](https://github.com/prowler-cloud/prowler/pull/9199)
- Service Watchlist component to Overview page [(#9316)](https://github.com/prowler-cloud/prowler/pull/9316)
- Risk Pipeline component with Sankey chart to Overview page [(#9317)](https://github.com/prowler-cloud/prowler/pull/9317)
## [1.14.0] (Prowler v5.14.0)
### 🚀 Added
@@ -15,8 +23,6 @@ All notable changes to the **Prowler UI** are documented in this file.
- External resource link to IaC findings for direct navigation to source code in Git repositories [(#9151)](https://github.com/prowler-cloud/prowler/pull/9151)
- New Overview page and new app styles [(#9234)](https://github.com/prowler-cloud/prowler/pull/9234)
- Use branch name as region for IaC findings [(#9296)](https://github.com/prowler-cloud/prowler/pull/9296)
- Compliance Watchlist component to Overview page [(#9199)](https://github.com/prowler-cloud/prowler/pull/9199)
- Service Watchlist component to Overview page [(#9316)](https://github.com/prowler-cloud/prowler/pull/9316)
### 🔄 Changed
@@ -27,7 +33,7 @@ All notable changes to the **Prowler UI** are documented in this file.
---
## [1.13.1]
## [1.13.1] (Prolwer v5.13.1)
### 🔄 Changed

View File

@@ -1,2 +1,3 @@
export * from "./overview";
export * from "./overview.adapter";
export * from "./types";

View File

@@ -0,0 +1,210 @@
import {
FindingsSeverityOverviewResponse,
ProviderOverview,
ProvidersOverviewResponse,
} from "./types";
/**
* Sankey chart node structure
*/
export interface SankeyNode {
name: string;
}
/**
* Sankey chart link structure
*/
export interface SankeyLink {
source: number;
target: number;
value: number;
}
/**
* Sankey chart data structure
*/
export interface SankeyData {
nodes: SankeyNode[];
links: SankeyLink[];
}
/**
* Provider display name mapping
* Maps provider IDs to user-friendly display names
* These names must match the COLOR_MAP keys in sankey-chart.tsx
*/
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
aws: "AWS",
azure: "Azure",
gcp: "Google Cloud",
kubernetes: "Kubernetes",
github: "GitHub",
m365: "Microsoft 365",
iac: "Infrastructure as Code",
oraclecloud: "Oracle Cloud Infrastructure",
};
/**
* Aggregated provider data after grouping by provider type
*/
interface AggregatedProvider {
id: string;
displayName: string;
pass: number;
fail: number;
}
/**
* Provider types to exclude from the Sankey chart
*/
const EXCLUDED_PROVIDERS = new Set(["mongo", "mongodb", "mongodbatlas"]);
/**
* Aggregates multiple provider entries by provider type (id)
* Since the API can return multiple entries for the same provider type,
* we need to sum up their findings
*
* @param providers - Raw provider overview data from API
* @returns Aggregated providers with summed findings
*/
function aggregateProvidersByType(
providers: ProviderOverview[],
): AggregatedProvider[] {
const aggregated = new Map<string, AggregatedProvider>();
for (const provider of providers) {
const { id, attributes } = provider;
// Skip excluded providers
if (EXCLUDED_PROVIDERS.has(id)) {
continue;
}
const existing = aggregated.get(id);
if (existing) {
existing.pass += attributes.findings.pass;
existing.fail += attributes.findings.fail;
} else {
aggregated.set(id, {
id,
displayName: PROVIDER_DISPLAY_NAMES[id] || id,
pass: attributes.findings.pass,
fail: attributes.findings.fail,
});
}
}
return Array.from(aggregated.values());
}
/**
* Severity display names in order
*/
const SEVERITY_ORDER = [
"Critical",
"High",
"Medium",
"Low",
"Informational",
] as const;
/**
* Adapts providers overview and findings severity API responses to Sankey chart format
*
* Creates a 2-level flow visualization:
* - Level 1: Cloud providers (AWS, Azure, GCP, etc.)
* - Level 2: Severity breakdown (Critical, High, Medium, Low, Informational)
*
* The severity distribution is calculated proportionally based on each provider's
* fail count relative to the total fails across all providers.
*
* @param providersResponse - Raw API response from /overviews/providers
* @param severityResponse - Raw API response from /overviews/findings_severity
* @returns Sankey chart data with nodes and links
*/
export function adaptProvidersOverviewToSankey(
providersResponse: ProvidersOverviewResponse | undefined,
severityResponse?: FindingsSeverityOverviewResponse | undefined,
): SankeyData {
if (!providersResponse?.data || providersResponse.data.length === 0) {
return { nodes: [], links: [] };
}
// Aggregate providers by type
const aggregatedProviders = aggregateProvidersByType(providersResponse.data);
// Filter out providers with no findings (only need fail > 0 for severity view)
const providersWithFailures = aggregatedProviders.filter((p) => p.fail > 0);
if (providersWithFailures.length === 0) {
return { nodes: [], links: [] };
}
// Build nodes array: providers first, then severities
const providerNodes: SankeyNode[] = providersWithFailures.map((p) => ({
name: p.displayName,
}));
const severityNodes: SankeyNode[] = SEVERITY_ORDER.map((severity) => ({
name: severity,
}));
const nodes = [...providerNodes, ...severityNodes];
// Calculate severity start index (after provider nodes)
const severityStartIndex = providerNodes.length;
// Build links from each provider to severities
const links: SankeyLink[] = [];
// If we have severity data, distribute proportionally
if (severityResponse?.data?.attributes) {
const { critical, high, medium, low, informational } =
severityResponse.data.attributes;
const severityValues = [critical, high, medium, low, informational];
const totalSeverity = severityValues.reduce((sum, v) => sum + v, 0);
if (totalSeverity > 0) {
// Calculate total fails across all providers
const totalFails = providersWithFailures.reduce(
(sum, p) => sum + p.fail,
0,
);
providersWithFailures.forEach((provider, sourceIndex) => {
// Calculate this provider's proportion of total fails
const providerRatio = provider.fail / totalFails;
severityValues.forEach((severityValue, severityIndex) => {
// Distribute severity proportionally to this provider
const value = Math.round(severityValue * providerRatio);
if (value > 0) {
links.push({
source: sourceIndex,
target: severityStartIndex + severityIndex,
value,
});
}
});
});
}
} else {
// Fallback: if no severity data, just show fail counts to a generic "Fail" node
const failNode: SankeyNode = { name: "Fail" };
nodes.push(failNode);
const failIndex = nodes.length - 1;
providersWithFailures.forEach((provider, sourceIndex) => {
links.push({
source: sourceIndex,
target: failIndex,
value: provider.fail,
});
});
}
return { nodes, links };
}

View File

@@ -4,7 +4,11 @@ import { redirect } from "next/navigation";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiResponse } from "@/lib/server-actions-helper";
import { ServicesOverviewResponse } from "./types";
import {
FindingsSeverityOverviewResponse,
ProvidersOverviewResponse,
ServicesOverviewResponse,
} from "./types";
export const getServicesOverview = async ({
filters = {},
@@ -39,7 +43,12 @@ export const getProvidersOverview = async ({
query = "",
sort = "",
filters = {},
}) => {
}: {
page?: number;
query?: string;
sort?: string;
filters?: Record<string, string | string[] | undefined>;
} = {}): Promise<ProvidersOverviewResponse | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
if (isNaN(Number(page)) || page < 1) redirect("/providers-overview");
@@ -52,7 +61,7 @@ export const getProvidersOverview = async ({
// Handle multiple filters
Object.entries(filters).forEach(([key, value]) => {
if (key !== "filter[search]") {
if (key !== "filter[search]" && value !== undefined) {
url.searchParams.append(key, String(value));
}
});
@@ -111,24 +120,21 @@ export const getFindingsByStatus = async ({
};
export const getFindingsBySeverity = async ({
page = 1,
query = "",
sort = "",
filters = {},
}) => {
}: {
filters?: Record<string, string | string[] | undefined>;
} = {}): Promise<FindingsSeverityOverviewResponse | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
if (isNaN(Number(page)) || page < 1) redirect("/");
const url = new URL(`${apiBaseUrl}/overviews/findings_severity`);
if (page) url.searchParams.append("page[number]", page.toString());
if (query) url.searchParams.append("filter[search]", query);
if (sort) url.searchParams.append("sort", sort);
// Handle multiple filters, but exclude unsupported filters
// The overviews/findings_severity endpoint does not support status or muted filters
Object.entries(filters).forEach(([key, value]) => {
if (key !== "filter[search]" && key !== "filter[muted]") {
if (
key !== "filter[search]" &&
key !== "filter[muted]" &&
value !== undefined
) {
url.searchParams.append(key, String(value));
}
});

View File

@@ -1,3 +1,35 @@
// Providers Overview Types
// Corresponds to the /overviews/providers endpoint
export interface ProviderOverviewFindings {
pass: number;
fail: number;
muted: number;
total: number;
}
export interface ProviderOverviewResources {
total: number;
}
export interface ProviderOverviewAttributes {
findings: ProviderOverviewFindings;
resources: ProviderOverviewResources;
}
export interface ProviderOverview {
type: "providers-overview";
id: string;
attributes: ProviderOverviewAttributes;
}
export interface ProvidersOverviewResponse {
data: ProviderOverview[];
meta: {
version: string;
};
}
// Services Overview Types
// Corresponds to the /overviews/services endpoint
@@ -62,6 +94,30 @@ export interface ThreatScoreResponse {
data: ThreatScoreSnapshot[];
}
// Findings Severity Overview Types
// Corresponds to the /overviews/findings_severity endpoint
export interface FindingsSeverityAttributes {
critical: number;
high: number;
medium: number;
low: number;
informational: number;
}
export interface FindingsSeverityOverview {
type: "findings-severity-overview";
id: string;
attributes: FindingsSeverityAttributes;
}
export interface FindingsSeverityOverviewResponse {
data: FindingsSeverityOverview;
meta: {
version: string;
};
}
// Filters for ThreatScore endpoint
export interface ThreatScoreFilters {
snapshot_id?: string;

View File

@@ -0,0 +1,96 @@
"use server";
import { Spacer } from "@heroui/spacer";
import { getLatestFindings } from "@/actions/findings/findings";
import { LinkToFindings } from "@/components/overview";
import { ColumnNewFindingsToDate } from "@/components/overview/new-findings-table/table/column-new-findings-to-date";
import { DataTable } from "@/components/ui/table";
import { createDict } from "@/lib/helper";
import { FindingProps, SearchParamsProps } from "@/types";
import { LighthouseBanner } from "../../../../../../components/lighthouse/banner";
const FILTER_PREFIX = "filter[";
function pickFilterParams(
params: SearchParamsProps | undefined | null,
): Record<string, string | string[] | undefined> {
if (!params) return {};
return Object.fromEntries(
Object.entries(params).filter(([key]) => key.startsWith(FILTER_PREFIX)),
);
}
interface FindingsViewSSRProps {
searchParams: SearchParamsProps;
}
export async function FindingsViewSSR({ searchParams }: FindingsViewSSRProps) {
const page = 1;
const sort = "severity,-inserted_at";
const defaultFilters = {
"filter[status]": "FAIL",
"filter[delta]": "new",
};
const filters = pickFilterParams(searchParams);
const combinedFilters = { ...defaultFilters, ...filters };
const findingsData = await getLatestFindings({
query: undefined,
page,
sort,
filters: combinedFilters,
});
const resourceDict = createDict("resources", findingsData);
const scanDict = createDict("scans", findingsData);
const providerDict = createDict("providers", findingsData);
const expandedFindings = findingsData?.data
? (findingsData.data as FindingProps[]).map((finding) => {
const scan = scanDict[finding.relationships?.scan?.data?.id];
const resource =
resourceDict[finding.relationships?.resources?.data?.[0]?.id];
const provider = providerDict[scan?.relationships?.provider?.data?.id];
return {
...finding,
relationships: { scan, resource, provider },
};
})
: [];
const expandedResponse = {
...findingsData,
data: expandedFindings,
};
return (
<div className="flex w-full flex-col">
<LighthouseBanner />
<div className="relative flex w-full">
<div className="flex w-full items-center gap-2">
<h3 className="text-sm font-bold uppercase">
Latest new failing findings
</h3>
<p className="text-text-neutral-tertiary text-xs">
Showing the latest 10 new failing findings by severity.
</p>
</div>
<div className="absolute -top-6 right-0">
<LinkToFindings />
</div>
</div>
<Spacer y={4} />
<DataTable
key={`dashboard-findings-${Date.now()}`}
columns={ColumnNewFindingsToDate}
data={(expandedResponse?.data || []) as FindingProps[]}
/>
</div>
);
}

View File

@@ -0,0 +1 @@
export { FindingsViewSSR } from "./findings-view.ssr";

View File

@@ -11,7 +11,7 @@ interface GraphsTabsClientProps {
}
export const GraphsTabsClient = ({ tabsContent }: GraphsTabsClientProps) => {
const [activeTab, setActiveTab] = useState<TabId>("threat-map");
const [activeTab, setActiveTab] = useState<TabId>("findings");
const handleValueChange = (value: string) => {
setActiveTab(value as TabId);
@@ -40,7 +40,7 @@ export const GraphsTabsClient = ({ tabsContent }: GraphsTabsClientProps) => {
<TabsContent
key={tab.id}
value={tab.id}
className="mt-4 flex flex-1 overflow-visible"
className="mt-10 flex flex-1 overflow-visible"
>
{tabsContent[tab.id]}
</TabsContent>

View File

@@ -1,20 +1,25 @@
export const GRAPH_TABS = [
{
id: "threat-map",
label: "Threat Map",
},
{
id: "risk-radar",
label: "Risk Radar",
id: "findings",
label: "Findings",
},
{
id: "risk-pipeline",
label: "Risk Pipeline",
},
{
id: "risk-plot",
label: "Risk Plot",
},
// TODO: Uncomment when ready to enable other tabs
// {
// id: "threat-map",
// label: "Threat Map",
// },
// {
// id: "risk-radar",
// label: "Risk Radar",
// },
// {
// id: "risk-plot",
// label: "Risk Plot",
// },
] as const;
export type TabId = (typeof GRAPH_TABS)[number]["id"];

View File

@@ -3,39 +3,31 @@ import { Suspense } from "react";
import { SearchParamsProps } from "@/types";
import { FindingsViewSSR } from "./findings-view";
import { GraphsTabsClient } from "./graphs-tabs-client";
import { GRAPH_TABS, type TabId } from "./graphs-tabs-config";
import { RiskPipelineViewSSR } from "./risk-pipeline-view/risk-pipeline-view.ssr";
import { RiskPlotView } from "./risk-plot/risk-plot-view";
import { RiskRadarViewSSR } from "./risk-radar-view/risk-radar-view.ssr";
import { ThreatMapViewSSR } from "./threat-map-view/threat-map-view.ssr";
// TODO: Uncomment when ready to enable other tabs
// import { RiskPlotView } from "./risk-plot/risk-plot-view";
// import { RiskRadarViewSSR } from "./risk-radar-view/risk-radar-view.ssr";
// import { ThreatMapViewSSR } from "./threat-map-view/threat-map-view.ssr";
const LoadingFallback = () => (
<div
className="flex w-full flex-col space-y-4 rounded-lg border p-4"
style={{
borderColor: "var(--border-neutral-primary)",
backgroundColor: "var(--bg-neutral-secondary)",
}}
>
<Skeleton
className="h-6 w-1/3 rounded"
style={{ backgroundColor: "var(--bg-neutral-tertiary)" }}
/>
<Skeleton
className="h-[457px] w-full rounded"
style={{ backgroundColor: "var(--bg-neutral-tertiary)" }}
/>
<div className="border-border-neutral-primary bg-bg-neutral-secondary flex w-full flex-col space-y-4 rounded-lg border p-4">
<Skeleton className="bg-bg-neutral-tertiary h-6 w-1/3 rounded" />
<Skeleton className="bg-bg-neutral-tertiary h-[457px] w-full rounded" />
</div>
);
type GraphComponent = React.ComponentType<{ searchParams: SearchParamsProps }>;
const GRAPH_COMPONENTS: Record<TabId, GraphComponent> = {
"threat-map": ThreatMapViewSSR as GraphComponent,
"risk-radar": RiskRadarViewSSR as GraphComponent,
findings: FindingsViewSSR as GraphComponent,
"risk-pipeline": RiskPipelineViewSSR as GraphComponent,
"risk-plot": RiskPlotView as GraphComponent,
// TODO: Uncomment when ready to enable other tabs
// "threat-map": ThreatMapViewSSR as GraphComponent,
// "risk-radar": RiskRadarViewSSR as GraphComponent,
// "risk-plot": RiskPlotView as GraphComponent,
};
interface GraphsTabsWrapperProps {

View File

@@ -0,0 +1,2 @@
export { RiskPipelineViewSSR } from "./risk-pipeline-view.ssr";
export { RiskPipelineViewSkeleton } from "./risk-pipeline-view-skeleton";

View File

@@ -0,0 +1,12 @@
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
export function RiskPipelineViewSkeleton() {
return (
<div className="border-border-neutral-primary bg-bg-neutral-secondary flex h-[460px] w-full flex-col space-y-4 rounded-lg border p-4">
<Skeleton className="h-6 w-1/4 rounded" />
<div className="flex flex-1 items-center justify-center">
<Skeleton className="h-[380px] w-full rounded" />
</div>
</div>
);
}

View File

@@ -1,39 +1,44 @@
import {
adaptProvidersOverviewToSankey,
getFindingsBySeverity,
getProvidersOverview,
} from "@/actions/overview";
import { SankeyChart } from "@/components/graphs/sankey-chart";
import { SearchParamsProps } from "@/types";
// Helper to simulate loading delay
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
import { pickFilterParams } from "../../../lib/filter-params";
// Mock data - replace with actual API call
const mockSankeyData = {
nodes: [
{ name: "AWS" },
{ name: "Azure" },
{ name: "Google Cloud" },
{ name: "Critical" },
{ name: "High" },
{ name: "Medium" },
{ name: "Low" },
],
links: [
{ source: 0, target: 3, value: 45 },
{ source: 0, target: 4, value: 120 },
{ source: 0, target: 5, value: 85 },
{ source: 1, target: 3, value: 28 },
{ source: 1, target: 4, value: 95 },
{ source: 1, target: 5, value: 62 },
{ source: 2, target: 3, value: 18 },
{ source: 2, target: 4, value: 72 },
{ source: 2, target: 5, value: 48 },
],
};
export async function RiskPipelineViewSSR({
searchParams,
}: {
searchParams: SearchParamsProps;
}) {
const filters = pickFilterParams(searchParams);
export async function RiskPipelineViewSSR() {
// TODO: Call server action to fetch sankey chart data
await delay(3000); // Simulating server action fetch time
// Fetch both endpoints in parallel
const [providersResponse, severityResponse] = await Promise.all([
getProvidersOverview({ filters }),
getFindingsBySeverity({ filters }),
]);
const sankeyData = adaptProvidersOverviewToSankey(
providersResponse,
severityResponse,
);
if (sankeyData.nodes.length === 0) {
return (
<div className="flex h-[460px] w-full items-center justify-center">
<p className="text-text-neutral-tertiary text-sm">
No provider data available
</p>
</div>
);
}
return (
<div className="w-full flex-1 overflow-visible">
<SankeyChart data={mockSankeyData} height={460} />
<SankeyChart data={sankeyData} height={460} />
</div>
);
}

View File

@@ -1,19 +1,13 @@
import { Spacer } from "@heroui/spacer";
import { Suspense } from "react";
import { getLatestFindings } from "@/actions/findings/findings";
import { getProviders } from "@/actions/providers";
import { LinkToFindings } from "@/components/overview";
import { ColumnNewFindingsToDate } from "@/components/overview/new-findings-table/table/column-new-findings-to-date";
import { SkeletonTableNewFindings } from "@/components/overview/new-findings-table/table/skeleton-table-new-findings";
import { ContentLayout } from "@/components/ui";
import { DataTable } from "@/components/ui/table";
import { createDict } from "@/lib/helper";
import { FindingProps, SearchParamsProps } from "@/types";
import { SearchParamsProps } from "@/types";
import { LighthouseBanner } from "../../components/lighthouse/banner";
import { AccountsSelector } from "./_new-overview/components/accounts-selector";
import { CheckFindingsSSR } from "./_new-overview/components/check-findings";
import { GraphsTabsWrapper } from "./_new-overview/components/graphs-tabs/graphs-tabs-wrapper";
import { RiskPipelineViewSkeleton } from "./_new-overview/components/graphs-tabs/risk-pipeline-view";
import { ProviderTypeSelector } from "./_new-overview/components/provider-type-selector";
import {
RiskSeverityChartSkeleton,
@@ -30,25 +24,12 @@ import {
WatchlistCardSkeleton,
} from "./_new-overview/components/watchlist";
const FILTER_PREFIX = "filter[";
// Extract only query params that start with "filter[" for API calls
function pickFilterParams(
params: SearchParamsProps | undefined | null,
): Record<string, string | string[] | undefined> {
if (!params) return {};
return Object.fromEntries(
Object.entries(params).filter(([key]) => key.startsWith(FILTER_PREFIX)),
);
}
export default async function Home({
searchParams,
}: {
searchParams: Promise<SearchParamsProps>;
}) {
const resolvedSearchParams = await searchParams;
const searchParamsKey = JSON.stringify(resolvedSearchParams || {});
const providersData = await getProviders({ page: 1, pageSize: 200 });
return (
@@ -59,14 +40,6 @@ export default async function Home({
</div>
<div className="flex flex-col gap-6 md:flex-row md:flex-wrap md:items-stretch">
<Suspense fallback={<WatchlistCardSkeleton />}>
<ComplianceWatchlistSSR searchParams={resolvedSearchParams} />
</Suspense>
<Suspense fallback={<WatchlistCardSkeleton />}>
<ServiceWatchlistSSR searchParams={resolvedSearchParams} />
</Suspense>
<Suspense fallback={<ThreatScoreSkeleton />}>
<ThreatScoreSSR searchParams={resolvedSearchParams} />
</Suspense>
@@ -80,90 +53,21 @@ export default async function Home({
</Suspense>
</div>
<div className="mt-6">
<Spacer y={16} />
<Suspense key={searchParamsKey} fallback={<SkeletonTableNewFindings />}>
<SSRDataNewFindingsTable searchParams={resolvedSearchParams} />
<div className="mt-6 flex flex-col gap-6 md:flex-row md:items-stretch">
<div className="flex flex-col gap-6">
<Suspense fallback={<WatchlistCardSkeleton />}>
<ComplianceWatchlistSSR searchParams={resolvedSearchParams} />
</Suspense>
<Suspense fallback={<WatchlistCardSkeleton />}>
<ServiceWatchlistSSR searchParams={resolvedSearchParams} />
</Suspense>
</div>
<Suspense fallback={<RiskPipelineViewSkeleton />}>
<GraphsTabsWrapper searchParams={resolvedSearchParams} />
</Suspense>
</div>
</ContentLayout>
);
}
const SSRDataNewFindingsTable = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
const page = 1;
const sort = "severity,-inserted_at";
const defaultFilters = {
"filter[status]": "FAIL",
"filter[delta]": "new",
};
const filters = pickFilterParams(searchParams);
const combinedFilters = { ...defaultFilters, ...filters };
const findingsData = await getLatestFindings({
query: undefined,
page,
sort,
filters: combinedFilters,
});
// Create dictionaries for resources, scans, and providers
const resourceDict = createDict("resources", findingsData);
const scanDict = createDict("scans", findingsData);
const providerDict = createDict("providers", findingsData);
// Expand each finding with its corresponding resource, scan, and provider
const expandedFindings = findingsData?.data
? (findingsData.data as FindingProps[]).map((finding) => {
const scan = scanDict[finding.relationships?.scan?.data?.id];
const resource =
resourceDict[finding.relationships?.resources?.data?.[0]?.id];
const provider = providerDict[scan?.relationships?.provider?.data?.id];
return {
...finding,
relationships: { scan, resource, provider },
};
})
: [];
// Create the new object while maintaining the original structure
const expandedResponse = {
...findingsData,
data: expandedFindings,
};
return (
<>
<LighthouseBanner />
<div className="relative flex w-full">
<div className="flex w-full items-center gap-2">
<h3 className="text-sm font-bold uppercase">
Latest new failing findings
</h3>
<p className="text-xs text-gray-500">
Showing the latest 10 new failing findings by severity.
</p>
</div>
<div className="absolute -top-6 right-0">
<LinkToFindings />
</div>
</div>
<Spacer y={4} />
<DataTable
key={`dashboard-${Date.now()}`}
columns={ColumnNewFindingsToDate}
data={(expandedResponse?.data || []) as FindingProps[]}
// metadata={findingsData?.meta}
/>
</>
);
};

View File

@@ -1,10 +1,35 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Rectangle, ResponsiveContainer, Sankey, Tooltip } from "recharts";
import {
AWSProviderBadge,
AzureProviderBadge,
GCPProviderBadge,
GitHubProviderBadge,
IacProviderBadge,
KS8ProviderBadge,
M365ProviderBadge,
OracleCloudProviderBadge,
} from "@/components/icons/providers-badge";
import { IconSvgProps } from "@/types";
import { ChartTooltip } from "./shared/chart-tooltip";
// Map node names to their corresponding provider icon components
const PROVIDER_ICONS: Record<string, React.FC<IconSvgProps>> = {
AWS: AWSProviderBadge,
Azure: AzureProviderBadge,
"Google Cloud": GCPProviderBadge,
Kubernetes: KS8ProviderBadge,
"Microsoft 365": M365ProviderBadge,
GitHub: GitHubProviderBadge,
"Infrastructure as Code": IacProviderBadge,
"Oracle Cloud Infrastructure": OracleCloudProviderBadge,
};
interface SankeyNode {
name: string;
newFindings?: number;
@@ -47,14 +72,33 @@ interface NodeTooltipState {
}
const TOOLTIP_OFFSET_PX = 10;
const MIN_LINK_WIDTH = 4;
// Map severity node names to their filter values for the findings page
const SEVERITY_FILTER_MAP: Record<string, string> = {
Critical: "critical",
High: "high",
Medium: "medium",
Low: "low",
Informational: "informational",
};
// Map color names to CSS variable names defined in globals.css
const COLOR_MAP: Record<string, string> = {
// Status colors
Success: "--color-bg-pass",
Pass: "--color-bg-pass",
Fail: "--color-bg-fail",
// Provider colors
AWS: "--color-bg-data-aws",
Azure: "--color-bg-data-azure",
"Google Cloud": "--color-bg-data-gcp",
Kubernetes: "--color-bg-data-kubernetes",
"Microsoft 365": "--color-bg-data-m365",
GitHub: "--color-bg-data-github",
"Infrastructure as Code": "--color-bg-data-muted",
"Oracle Cloud Infrastructure": "--color-bg-data-muted",
// Severity colors
Critical: "--color-bg-data-critical",
High: "--color-bg-data-high",
Medium: "--color-bg-data-medium",
@@ -130,6 +174,7 @@ interface CustomNodeProps {
onNodeHover?: (data: Omit<NodeTooltipState, "show">) => void;
onNodeMove?: (position: { x: number; y: number }) => void;
onNodeLeave?: () => void;
onNodeClick?: (nodeName: string) => void;
}
interface CustomLinkProps {
@@ -184,12 +229,14 @@ const CustomNode = ({
onNodeHover,
onNodeMove,
onNodeLeave,
onNodeClick,
}: CustomNodeProps) => {
const isOut = x + width + 6 > containerWidth;
const nodeName = payload.name;
const color = colors[nodeName] || "var(--color-text-neutral-tertiary)";
const isHidden = nodeName === "";
const hasTooltip = !isHidden && payload.newFindings;
const isClickable = SEVERITY_FILTER_MAP[nodeName] !== undefined;
const handleMouseEnter = (e: React.MouseEvent) => {
if (!hasTooltip) return;
@@ -227,12 +274,30 @@ const CustomNode = ({
onNodeLeave?.();
};
const handleClick = () => {
if (isClickable) {
onNodeClick?.(nodeName);
}
};
const IconComponent = PROVIDER_ICONS[nodeName];
const hasIcon = IconComponent !== undefined;
const iconSize = 24;
const iconGap = 8;
// Calculate text position accounting for icon
const textOffsetX = isOut ? x - 6 : x + width + 6;
const iconOffsetX = isOut
? textOffsetX - iconSize - iconGap
: textOffsetX + iconGap;
return (
<g
style={{ cursor: hasTooltip ? "pointer" : "default" }}
style={{ cursor: isClickable || hasTooltip ? "pointer" : "default" }}
onMouseEnter={handleMouseEnter}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
>
<Rectangle
x={x}
@@ -244,9 +309,27 @@ const CustomNode = ({
/>
{!isHidden && (
<>
{hasIcon && (
<foreignObject
x={isOut ? iconOffsetX : textOffsetX}
y={y + height / 2 - iconSize / 2 - 2}
width={iconSize}
height={iconSize}
>
<div className="flex items-center justify-center">
<IconComponent width={iconSize} height={iconSize} />
</div>
</foreignObject>
)}
<text
textAnchor={isOut ? "end" : "start"}
x={isOut ? x - 6 : x + width + 6}
x={
hasIcon
? isOut
? iconOffsetX - iconGap
: textOffsetX + iconSize + iconGap * 2
: textOffsetX
}
y={y + height / 2}
fontSize="14"
fill="var(--color-text-neutral-primary)"
@@ -255,7 +338,13 @@ const CustomNode = ({
</text>
<text
textAnchor={isOut ? "end" : "start"}
x={isOut ? x - 6 : x + width + 6}
x={
hasIcon
? isOut
? iconOffsetX - iconGap
: textOffsetX + iconSize + iconGap * 2
: textOffsetX
}
y={y + height / 2 + 13}
fontSize="12"
fill="var(--color-text-neutral-secondary)"
@@ -293,15 +382,18 @@ const CustomLink = ({
const isHovered = hoveredLink !== null && hoveredLink === index;
const hasHoveredLink = hoveredLink !== null;
// Ensure minimum link width for better visibility of small values
const effectiveLinkWidth = Math.max(linkWidth, MIN_LINK_WIDTH);
const pathD = `
M${sourceX},${sourceY + linkWidth / 2}
C${sourceControlX},${sourceY + linkWidth / 2}
${targetControlX},${targetY + linkWidth / 2}
${targetX},${targetY + linkWidth / 2}
L${targetX},${targetY - linkWidth / 2}
C${targetControlX},${targetY - linkWidth / 2}
${sourceControlX},${sourceY - linkWidth / 2}
${sourceX},${sourceY - linkWidth / 2}
M${sourceX},${sourceY + effectiveLinkWidth / 2}
C${sourceControlX},${sourceY + effectiveLinkWidth / 2}
${targetControlX},${targetY + effectiveLinkWidth / 2}
${targetX},${targetY + effectiveLinkWidth / 2}
L${targetX},${targetY - effectiveLinkWidth / 2}
C${targetControlX},${targetY - effectiveLinkWidth / 2}
${sourceControlX},${sourceY - effectiveLinkWidth / 2}
${sourceX},${sourceY - effectiveLinkWidth / 2}
Z
`;
@@ -360,6 +452,7 @@ const CustomLink = ({
};
export function SankeyChart({ data, height = 400 }: SankeyChartProps) {
const router = useRouter();
const [hoveredLink, setHoveredLink] = useState<number | null>(null);
const [colors, setColors] = useState<Record<string, string>>({});
const [linkTooltip, setLinkTooltip] = useState<LinkTooltipState>({
@@ -423,11 +516,18 @@ export function SankeyChart({ data, height = 400 }: SankeyChartProps) {
setNodeTooltip((prev) => ({ ...prev, show: false }));
};
const handleNodeClick = (nodeName: string) => {
const severityFilter = SEVERITY_FILTER_MAP[nodeName];
if (severityFilter) {
router.push(`/findings?filter[severity]=${severityFilter}`);
}
};
// Create callback references that wrap custom props and Recharts-injected props
const wrappedCustomNode = (
props: Omit<
CustomNodeProps,
"colors" | "onNodeHover" | "onNodeMove" | "onNodeLeave"
"colors" | "onNodeHover" | "onNodeMove" | "onNodeLeave" | "onNodeClick"
>,
) => (
<CustomNode
@@ -436,6 +536,7 @@ export function SankeyChart({ data, height = 400 }: SankeyChartProps) {
onNodeHover={handleNodeHover}
onNodeMove={handleNodeMove}
onNodeLeave={handleNodeLeave}
onNodeClick={handleNodeClick}
/>
);

View File

@@ -52,9 +52,6 @@ export const getFindingsBySeverityTool = tool(
async (input) => {
const typedInput = input as z.infer<typeof getFindingsBySeveritySchema>;
return await getFindingsBySeverity({
page: typedInput.page,
query: typedInput.query,
sort: typedInput.sort,
filters: typedInput.filters,
});
},

View File

@@ -89,11 +89,13 @@ export const getFindingsByStatusSchema = z.object({
.optional()
.describe("Date in format YYYY-MM-DD"),
// Boolean filters
// Boolean filters (passed as strings in query params)
"filter[muted_findings]": z
.boolean()
.string()
.optional()
.describe("Default is empty string."),
.describe(
"Boolean as string ('true' or 'false'). Default is empty string.",
),
// Provider filters
"filter[provider_id]": z.string().optional().describe("Provider ID"),
@@ -121,16 +123,6 @@ export const getFindingsByStatusSchema = z.object({
// Get Findings By Severity
export const getFindingsBySeveritySchema = z.object({
page: z
.number()
.int()
.describe("The page number to get. Optional. Default is 1."),
query: z
.string()
.describe("The query to search for. Optional. Default is empty string."),
sort: sortFieldsEnum.describe(
"The sort order to use. Optional. Default is empty string.",
),
filters: z
.object({
// Date filters
@@ -151,11 +143,13 @@ export const getFindingsBySeveritySchema = z.object({
.optional()
.describe("Date in format YYYY-MM-DD"),
// Boolean filters
// Boolean filters (passed as strings in query params)
"filter[muted_findings]": z
.boolean()
.string()
.optional()
.describe("Default is empty string."),
.describe(
"Boolean as string ('true' or 'false'). Default is empty string.",
),
// Provider filters
"filter[provider_id]": z