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
co-authored by alejandrobailo
parent 880345bebe
commit 4e9dd46a5e
17 changed files with 610 additions and 222 deletions
@@ -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>
);
}
+16 -112
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}
/>
</>
);
};