mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-09 21:14:22 +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:
co-authored by
alejandrobailo
parent
880345bebe
commit
4e9dd46a5e
+96
@@ -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";
|
||||
+12
@@ -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>
|
||||
);
|
||||
}
|
||||
+34
-29
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+16
-112
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user