mirror of
https://github.com/prowler-cloud/prowler.git
synced 2025-12-19 05:17:47 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./overview";
|
||||
export * from "./overview.adapter";
|
||||
export * from "./types";
|
||||
|
||||
210
ui/actions/overview/overview.adapter.ts
Normal file
210
ui/actions/overview/overview.adapter.ts
Normal 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 };
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { FindingsViewSSR } from "./findings-view.ssr";
|
||||
@@ -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>
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { RiskPipelineViewSSR } from "./risk-pipeline-view.ssr";
|
||||
export { RiskPipelineViewSkeleton } from "./risk-pipeline-view-skeleton";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user