mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 12:31:54 +00:00
feat: CIS compliance detail view (#7913)
Co-authored-by: Víctor Fernández Poyatos <victor@prowler.com>
This commit is contained in:
@@ -12,6 +12,7 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
- Add GCP credential method (Account Service Key). [(#7872)](https://github.com/prowler-cloud/prowler/pull/7872)
|
||||
- Add compliance detail view: ENS [(#7853)](https://github.com/prowler-cloud/prowler/pull/7853)
|
||||
- Add compliance detail view: ISO [(#7897)](https://github.com/prowler-cloud/prowler/pull/7897)
|
||||
- Add compliance detail view: CIS [(#7913)](https://github.com/prowler-cloud/prowler/pull/7913)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
|
||||
@@ -9,16 +9,24 @@ import {
|
||||
} from "@/actions/compliances";
|
||||
import { getProvider } from "@/actions/providers";
|
||||
import { getScans } from "@/actions/scans";
|
||||
import { ClientAccordionWrapper } from "@/components/compliance/compliance-accordion/client-accordion-wrapper";
|
||||
import { ComplianceHeader } from "@/components/compliance/compliance-header/compliance-header";
|
||||
import { SkeletonAccordion } from "@/components/compliance/compliance-skeleton-accordion";
|
||||
import { FailedSectionsChart } from "@/components/compliance/failed-sections-chart";
|
||||
import { FailedSectionsChartSkeleton } from "@/components/compliance/failed-sections-chart-skeleton";
|
||||
import { RequirementsChart } from "@/components/compliance/requirements-chart";
|
||||
import { RequirementsChartSkeleton } from "@/components/compliance/requirements-chart-skeleton";
|
||||
import {
|
||||
BarChart,
|
||||
BarChartSkeleton,
|
||||
ClientAccordionWrapper,
|
||||
ComplianceHeader,
|
||||
HeatmapChart,
|
||||
HeatmapChartSkeleton,
|
||||
PieChart,
|
||||
PieChartSkeleton,
|
||||
SkeletonAccordion,
|
||||
} from "@/components/compliance";
|
||||
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { getComplianceMapper } from "@/lib/compliance/commons";
|
||||
import {
|
||||
calculateCategoryHeatmapData,
|
||||
calculateRegionHeatmapData,
|
||||
getComplianceMapper,
|
||||
} from "@/lib/compliance/commons";
|
||||
import { ScanProps } from "@/types";
|
||||
import { Framework, RequirementsTotals } from "@/types/compliance";
|
||||
|
||||
@@ -27,17 +35,23 @@ interface ComplianceDetailSearchParams {
|
||||
version?: string;
|
||||
scanId?: string;
|
||||
"filter[region__in]"?: string;
|
||||
"filter[cis_profile_level]"?: string;
|
||||
}
|
||||
|
||||
const ComplianceLogo = ({ logoPath }: { logoPath: string }) => {
|
||||
const ComplianceIconSmall = ({
|
||||
logoPath,
|
||||
title,
|
||||
}: {
|
||||
logoPath: string;
|
||||
title: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className="relative ml-auto hidden h-[200px] w-[200px] flex-shrink-0 md:block">
|
||||
<div className="relative h-6 w-6 flex-shrink-0">
|
||||
<Image
|
||||
src={logoPath}
|
||||
alt="Compliance Logo"
|
||||
alt={`${title} logo`}
|
||||
fill
|
||||
priority
|
||||
className="object-contain"
|
||||
className="h-10 w-10 min-w-10 rounded-md border-1 border-gray-300 bg-white object-contain p-[2px]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -45,17 +59,13 @@ const ComplianceLogo = ({ logoPath }: { logoPath: string }) => {
|
||||
|
||||
const ChartsWrapper = ({
|
||||
children,
|
||||
logoPath,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
logoPath?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className="mb-8 flex w-full">
|
||||
<div className="flex flex-col items-center gap-16 lg:flex-row">
|
||||
{children}
|
||||
</div>
|
||||
{logoPath && <ComplianceLogo logoPath={logoPath} />}
|
||||
<div className="mb-8 flex w-full flex-col items-center justify-between lg:flex-row">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -70,6 +80,7 @@ export default async function ComplianceDetail({
|
||||
const { compliancetitle } = params;
|
||||
const { complianceId, version, scanId } = searchParams;
|
||||
const regionFilter = searchParams["filter[region__in]"];
|
||||
const cisProfileFilter = searchParams["filter[cis_profile_level]"];
|
||||
const logoPath = getComplianceIcon(compliancetitle);
|
||||
|
||||
// Create a key that includes region filter for Suspense
|
||||
@@ -88,31 +99,33 @@ export default async function ComplianceDetail({
|
||||
});
|
||||
|
||||
// Expand scans with provider information
|
||||
const expandedScansData = await Promise.all(
|
||||
scansData.data.map(async (scan: ScanProps) => {
|
||||
const providerId = scan.relationships?.provider?.data?.id;
|
||||
const expandedScansData = scansData?.data?.length
|
||||
? await Promise.all(
|
||||
scansData.data.map(async (scan: ScanProps) => {
|
||||
const providerId = scan.relationships?.provider?.data?.id;
|
||||
|
||||
if (!providerId) {
|
||||
return { ...scan, providerInfo: null };
|
||||
}
|
||||
if (!providerId) {
|
||||
return { ...scan, providerInfo: null };
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("id", providerId);
|
||||
const formData = new FormData();
|
||||
formData.append("id", providerId);
|
||||
|
||||
const providerData = await getProvider(formData);
|
||||
const providerData = await getProvider(formData);
|
||||
|
||||
return {
|
||||
...scan,
|
||||
providerInfo: providerData?.data
|
||||
? {
|
||||
provider: providerData.data.attributes.provider,
|
||||
uid: providerData.data.attributes.uid,
|
||||
alias: providerData.data.attributes.alias,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return {
|
||||
...scan,
|
||||
providerInfo: providerData?.data
|
||||
? {
|
||||
provider: providerData.data.attributes.provider,
|
||||
uid: providerData.data.attributes.uid,
|
||||
alias: providerData.data.attributes.alias,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
)
|
||||
: [];
|
||||
|
||||
const selectedScanId = scanId || expandedScansData[0]?.id || null;
|
||||
|
||||
@@ -126,11 +139,21 @@ export default async function ComplianceDetail({
|
||||
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
|
||||
|
||||
return (
|
||||
<ContentLayout title={pageTitle} icon="fluent-mdl2:compliance-audit">
|
||||
<ContentLayout
|
||||
title={pageTitle}
|
||||
icon={
|
||||
logoPath ? (
|
||||
<ComplianceIconSmall logoPath={logoPath} title={compliancetitle} />
|
||||
) : (
|
||||
"fluent-mdl2:compliance-audit"
|
||||
)
|
||||
}
|
||||
>
|
||||
<ComplianceHeader
|
||||
scans={expandedScansData}
|
||||
uniqueRegions={uniqueRegions}
|
||||
showSearch={false}
|
||||
framework={compliancetitle}
|
||||
/>
|
||||
|
||||
<Suspense
|
||||
@@ -138,8 +161,9 @@ export default async function ComplianceDetail({
|
||||
fallback={
|
||||
<div className="space-y-8">
|
||||
<ChartsWrapper logoPath={logoPath}>
|
||||
<RequirementsChartSkeleton />
|
||||
<FailedSectionsChartSkeleton />
|
||||
<PieChartSkeleton />
|
||||
<BarChartSkeleton />
|
||||
<HeatmapChartSkeleton />
|
||||
</ChartsWrapper>
|
||||
<SkeletonAccordion />
|
||||
</div>
|
||||
@@ -149,18 +173,57 @@ export default async function ComplianceDetail({
|
||||
complianceId={complianceId}
|
||||
scanId={selectedScanId}
|
||||
region={regionFilter}
|
||||
filter={cisProfileFilter}
|
||||
logoPath={logoPath}
|
||||
uniqueRegions={uniqueRegions}
|
||||
isRegionFiltered={
|
||||
!!regionFilter &&
|
||||
regionFilter.split(",").length < uniqueRegions.length
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const getComplianceData = async (
|
||||
complianceId: string,
|
||||
scanId: string,
|
||||
region?: string,
|
||||
): Promise<Framework[]> => {
|
||||
const SSRComplianceContent = async ({
|
||||
complianceId,
|
||||
scanId,
|
||||
region,
|
||||
filter,
|
||||
logoPath,
|
||||
uniqueRegions,
|
||||
isRegionFiltered,
|
||||
}: {
|
||||
complianceId: string;
|
||||
scanId: string;
|
||||
region?: string;
|
||||
filter?: string;
|
||||
logoPath?: string;
|
||||
uniqueRegions: string[];
|
||||
isRegionFiltered: boolean;
|
||||
}) => {
|
||||
if (!scanId) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<ChartsWrapper logoPath={logoPath}>
|
||||
<PieChart pass={0} fail={0} manual={0} />
|
||||
<BarChart sections={[]} />
|
||||
<HeatmapChart
|
||||
regions={[]}
|
||||
categories={[]}
|
||||
isRegionFiltered={
|
||||
!!region && region.split(",").length < uniqueRegions.length
|
||||
}
|
||||
filteredRegionName={region}
|
||||
/>
|
||||
</ChartsWrapper>
|
||||
<ClientAccordionWrapper items={[]} defaultExpandedKeys={[]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Get compliance data and attributes once
|
||||
const [attributesData, requirementsData] = await Promise.all([
|
||||
getComplianceAttributes(complianceId),
|
||||
getComplianceRequirements({
|
||||
@@ -172,40 +235,25 @@ const getComplianceData = async (
|
||||
|
||||
// Determine framework from the first attribute item
|
||||
const framework = attributesData?.data?.[0]?.attributes?.framework;
|
||||
|
||||
// Get the appropriate mapper for this framework
|
||||
const mapper = getComplianceMapper(framework);
|
||||
const mappedData = mapper.mapComplianceData(attributesData, requirementsData);
|
||||
const data = mapper.mapComplianceData(
|
||||
attributesData,
|
||||
requirementsData,
|
||||
filter,
|
||||
);
|
||||
|
||||
return mappedData;
|
||||
};
|
||||
// Calculate region heatmap data using already obtained data
|
||||
const regionHeatmapData = await calculateRegionHeatmapData(
|
||||
complianceId,
|
||||
scanId,
|
||||
uniqueRegions,
|
||||
attributesData,
|
||||
mapper,
|
||||
);
|
||||
const categoryHeatmapData = calculateCategoryHeatmapData(data);
|
||||
|
||||
const SSRComplianceContent = async ({
|
||||
complianceId,
|
||||
scanId,
|
||||
region,
|
||||
logoPath,
|
||||
}: {
|
||||
complianceId: string;
|
||||
scanId: string;
|
||||
region?: string;
|
||||
logoPath?: string;
|
||||
}) => {
|
||||
if (!scanId) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<ChartsWrapper logoPath={logoPath}>
|
||||
<RequirementsChart pass={0} fail={0} manual={0} />
|
||||
<FailedSectionsChart sections={[]} />
|
||||
</ChartsWrapper>
|
||||
<ClientAccordionWrapper items={[]} defaultExpandedKeys={[]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const data = await getComplianceData(complianceId, scanId, region);
|
||||
const totalRequirements: RequirementsTotals = data.reduce(
|
||||
(acc, framework) => ({
|
||||
(acc: RequirementsTotals, framework: Framework) => ({
|
||||
pass: acc.pass + framework.pass,
|
||||
fail: acc.fail + framework.fail,
|
||||
manual: acc.manual + framework.manual,
|
||||
@@ -213,12 +261,6 @@ const SSRComplianceContent = async ({
|
||||
{ pass: 0, fail: 0, manual: 0 },
|
||||
);
|
||||
|
||||
// Get the framework to determine which mapper to use
|
||||
const attributesData = await getComplianceAttributes(complianceId);
|
||||
const framework = attributesData?.data?.[0]?.attributes?.framework;
|
||||
|
||||
// Get the appropriate mapper for this framework
|
||||
const mapper = getComplianceMapper(framework);
|
||||
const accordionItems = mapper.toAccordionItems(data, scanId);
|
||||
const topFailedSections = mapper.getTopFailedSections(data);
|
||||
|
||||
@@ -229,12 +271,18 @@ const SSRComplianceContent = async ({
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<ChartsWrapper logoPath={logoPath}>
|
||||
<RequirementsChart
|
||||
<PieChart
|
||||
pass={totalRequirements.pass}
|
||||
fail={totalRequirements.fail}
|
||||
manual={totalRequirements.manual}
|
||||
/>
|
||||
<FailedSectionsChart sections={topFailedSections} />
|
||||
<BarChart sections={topFailedSections} />
|
||||
<HeatmapChart
|
||||
regions={regionHeatmapData}
|
||||
categories={categoryHeatmapData}
|
||||
isRegionFiltered={isRegionFiltered}
|
||||
filteredRegionName={region}
|
||||
/>
|
||||
</ChartsWrapper>
|
||||
|
||||
<Spacer className="h-1 w-full rounded-full bg-gray-200 dark:bg-gray-800" />
|
||||
|
||||
@@ -11,20 +11,22 @@ import {
|
||||
import { Accordion } from "@/components/ui/accordion/Accordion";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { createDict } from "@/lib";
|
||||
import { getComplianceMapper } from "@/lib/compliance/commons";
|
||||
import { ComplianceId, Requirement } from "@/types/compliance";
|
||||
import { FindingProps, FindingsResponse } from "@/types/components";
|
||||
|
||||
import { ENSCustomDetails } from "../compliance-custom-details/ens-details";
|
||||
import { ISOCustomDetails } from "../compliance-custom-details/iso-details";
|
||||
|
||||
interface ClientAccordionContentProps {
|
||||
requirement: Requirement;
|
||||
scanId: string;
|
||||
framework: string;
|
||||
disableFindings?: boolean;
|
||||
}
|
||||
|
||||
export const ClientAccordionContent = ({
|
||||
requirement,
|
||||
framework,
|
||||
scanId,
|
||||
disableFindings = false,
|
||||
}: ClientAccordionContentProps) => {
|
||||
const [findings, setFindings] = useState<FindingsResponse | null>(null);
|
||||
const [expandedFindings, setExpandedFindings] = useState<FindingProps[]>([]);
|
||||
@@ -41,6 +43,7 @@ export const ClientAccordionContent = ({
|
||||
useEffect(() => {
|
||||
async function loadFindings() {
|
||||
if (
|
||||
!disableFindings &&
|
||||
requirement.check_ids?.length > 0 &&
|
||||
requirement.status !== "No findings" &&
|
||||
(loadedPageRef.current !== pageNumber ||
|
||||
@@ -96,7 +99,29 @@ export const ClientAccordionContent = ({
|
||||
}
|
||||
|
||||
loadFindings();
|
||||
}, [requirement, scanId, pageNumber, sort, region]);
|
||||
}, [requirement, scanId, pageNumber, sort, region, disableFindings]);
|
||||
|
||||
const renderDetails = () => {
|
||||
if (!complianceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mapper = getComplianceMapper(framework);
|
||||
const detailsComponent = mapper.getDetailsComponent(requirement);
|
||||
|
||||
return <div className="w-full">{detailsComponent}</div>;
|
||||
};
|
||||
|
||||
if (disableFindings) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{renderDetails()}
|
||||
<p className="text-sm text-gray-500">
|
||||
This requirement has no checks; therefore, there are no findings.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const checks = requirement.check_ids || [];
|
||||
const checksList = (
|
||||
@@ -142,30 +167,6 @@ export const ClientAccordionContent = ({
|
||||
return <div>There are no findings for this regions</div>;
|
||||
};
|
||||
|
||||
const renderDetails = () => {
|
||||
if (!complianceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (complianceId) {
|
||||
case "ens_rd2022_aws":
|
||||
return (
|
||||
<div className="w-full">
|
||||
<ENSCustomDetails requirement={requirement} />
|
||||
</div>
|
||||
);
|
||||
case "iso27001_2013_aws":
|
||||
case "iso27001_2022_aws":
|
||||
return (
|
||||
<div className="w-full">
|
||||
<ISOCustomDetails requirement={requirement} />
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{renderDetails()}
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ export const ComplianceAccordionRequirementTitle = ({
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<div className="flex w-3/4 items-center gap-1">
|
||||
<span className="whitespace-nowrap text-sm uppercase">{name}</span>
|
||||
<span className="whitespace-nowrap text-sm">{name}</span>
|
||||
</div>
|
||||
<StatusFindingBadge status={status} />
|
||||
</div>
|
||||
|
||||
@@ -73,7 +73,7 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
|
||||
|
||||
const navigateToDetail = () => {
|
||||
// We will unlock this while developing the rest of complainces.
|
||||
if (!id.includes("ens") && !id.includes("iso")) {
|
||||
if (!id.includes("ens") && !id.includes("iso") && !id.includes("cis_")) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+29
-26
@@ -3,7 +3,7 @@
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
BarChart as RechartsBarChart,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
@@ -24,7 +24,7 @@ const title = (
|
||||
</h3>
|
||||
);
|
||||
|
||||
export const FailedSectionsChart = ({ sections }: FailedSectionsListProps) => {
|
||||
export const BarChart = ({ sections }: FailedSectionsListProps) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
@@ -84,16 +84,16 @@ export const FailedSectionsChart = ({ sections }: FailedSectionsListProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-[400px] flex-col items-center justify-between lg:w-[600px]">
|
||||
<div className="mt-4">{title}</div>
|
||||
<div className="flex h-[320px] w-[400px] flex-col items-center justify-between lg:w-[400px]">
|
||||
<div>{title}</div>
|
||||
|
||||
<div className="h-[320px] w-full">
|
||||
<div className="h-full w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
<RechartsBarChart
|
||||
data={chartData}
|
||||
layout="vertical"
|
||||
margin={{ top: 16, right: 30, left: 40, bottom: 5 }}
|
||||
maxBarSize={40}
|
||||
margin={{ top: 12, bottom: 0 }}
|
||||
maxBarSize={32}
|
||||
>
|
||||
<XAxis
|
||||
type="number"
|
||||
@@ -111,10 +111,15 @@ export const FailedSectionsChart = ({ sections }: FailedSectionsListProps) => {
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={100}
|
||||
width={1}
|
||||
tick={{
|
||||
fontSize: 12,
|
||||
fill: theme === "dark" ? "#94a3b8" : "#374151",
|
||||
textAnchor: "start",
|
||||
style: {
|
||||
transform: "translateX(10px) translateY(-26px)",
|
||||
},
|
||||
width: 400,
|
||||
}}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
@@ -157,22 +162,6 @@ export const FailedSectionsChart = ({ sections }: FailedSectionsListProps) => {
|
||||
}}
|
||||
cursor={false}
|
||||
/>
|
||||
{allTypes.length > 1 && (
|
||||
<Legend
|
||||
formatter={(value) => translateType(value)}
|
||||
wrapperStyle={{
|
||||
fontSize: "10px",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
paddingTop: "16px",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
iconType="circle"
|
||||
layout="horizontal"
|
||||
verticalAlign="bottom"
|
||||
/>
|
||||
)}
|
||||
{allTypes.map((type, i) => (
|
||||
<Bar
|
||||
key={type}
|
||||
@@ -182,7 +171,21 @@ export const FailedSectionsChart = ({ sections }: FailedSectionsListProps) => {
|
||||
radius={i === allTypes.length - 1 ? [0, 4, 4, 0] : [0, 0, 0, 0]}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
<Legend
|
||||
formatter={(value) => translateType(value)}
|
||||
wrapperStyle={{
|
||||
fontSize: "10px",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
paddingTop: "16px",
|
||||
marginBottom: "16px",
|
||||
}}
|
||||
iconType="circle"
|
||||
layout="horizontal"
|
||||
verticalAlign="bottom"
|
||||
/>
|
||||
</RechartsBarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { useState } from "react";
|
||||
|
||||
import { CategoryData, RegionData } from "@/types/compliance";
|
||||
|
||||
interface HeatmapChartProps {
|
||||
regions: RegionData[];
|
||||
categories?: CategoryData[];
|
||||
isRegionFiltered?: boolean; // Indicates if a region filter is active
|
||||
filteredRegionName?: string; // Name of the filtered region
|
||||
}
|
||||
|
||||
const getHeatmapColor = (percentage: number): string => {
|
||||
if (percentage === 0) return "#10b981"; // Green for 0% failures
|
||||
if (percentage <= 25) return "#eab308"; // Yellow
|
||||
if (percentage <= 50) return "#f97316"; // Orange
|
||||
if (percentage <= 100) return "#ef4444"; // Red
|
||||
return "#ef4444";
|
||||
};
|
||||
|
||||
const capitalizeFirstLetter = (text: string): string => {
|
||||
const lowerText = text.toLowerCase();
|
||||
const firstLetterIndex = lowerText.search(/[a-zA-Z]/);
|
||||
if (firstLetterIndex === -1) return text; // No letters found
|
||||
|
||||
return (
|
||||
lowerText.slice(0, firstLetterIndex) +
|
||||
lowerText.charAt(firstLetterIndex).toUpperCase() +
|
||||
lowerText.slice(firstLetterIndex + 1)
|
||||
);
|
||||
};
|
||||
|
||||
export const HeatmapChart = ({
|
||||
regions,
|
||||
categories = [],
|
||||
isRegionFiltered = false,
|
||||
}: HeatmapChartProps) => {
|
||||
const { theme } = useTheme();
|
||||
const [hoveredItem, setHoveredItem] = useState<
|
||||
RegionData | CategoryData | null
|
||||
>(null);
|
||||
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
|
||||
|
||||
// Determine what data to show and prepare it
|
||||
const dataToShow = isRegionFiltered ? categories : regions;
|
||||
const heatmapData = dataToShow
|
||||
.filter((item) => item.totalRequirements > 0)
|
||||
.sort((a, b) => b.failurePercentage - a.failurePercentage)
|
||||
.slice(0, 9); // Exactly 9 items for 3x3 grid
|
||||
|
||||
// Check if there are no items with data
|
||||
if (!dataToShow || dataToShow.length === 0 || heatmapData.length === 0) {
|
||||
const noDataMessage = isRegionFiltered
|
||||
? "No category data available"
|
||||
: "No regional data available";
|
||||
|
||||
return (
|
||||
<div className="flex w-[400px] flex-col items-center justify-between lg:w-[400px]">
|
||||
<h3 className="whitespace-nowrap text-xs font-semibold uppercase tracking-wide">
|
||||
{isRegionFiltered
|
||||
? "Categories Failure Rate"
|
||||
: "Failure Rate by Region"}
|
||||
</h3>
|
||||
<div className="flex h-[320px] w-full items-center justify-center">
|
||||
<p className="text-sm text-gray-500">{noDataMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleMouseEnter = (
|
||||
item: RegionData | CategoryData,
|
||||
event: React.MouseEvent,
|
||||
) => {
|
||||
setHoveredItem(item);
|
||||
setMousePosition({ x: event.clientX, y: event.clientY });
|
||||
};
|
||||
|
||||
const handleMouseMove = (event: React.MouseEvent) => {
|
||||
setMousePosition({ x: event.clientX, y: event.clientY });
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHoveredItem(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-[320px] w-[400px] flex-col items-center justify-between lg:w-[400px]">
|
||||
<div>
|
||||
<h3 className="whitespace-nowrap text-xs font-semibold uppercase tracking-wide">
|
||||
{isRegionFiltered
|
||||
? "Categories Failure Rate"
|
||||
: "Failure Rate by Region"}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="h-full w-full p-4">
|
||||
{/* 3x3 Grid */}
|
||||
<div className="grid h-full w-full grid-cols-3 gap-1">
|
||||
{heatmapData.map((item) => (
|
||||
<div
|
||||
key={item.name}
|
||||
className="flex items-center justify-center rounded border"
|
||||
style={{
|
||||
backgroundColor: getHeatmapColor(item.failurePercentage),
|
||||
borderColor: theme === "dark" ? "#374151" : "#e5e7eb",
|
||||
}}
|
||||
onMouseEnter={(e) => handleMouseEnter(item, e)}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div
|
||||
className="text-xs font-semibold"
|
||||
style={{
|
||||
color: theme === "dark" ? "#ffffff" : "#000000",
|
||||
}}
|
||||
>
|
||||
{isRegionFiltered
|
||||
? capitalizeFirstLetter(item.name)
|
||||
: item.name}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs"
|
||||
style={{
|
||||
color: theme === "dark" ? "#ffffff" : "#000000",
|
||||
}}
|
||||
>
|
||||
{item.failurePercentage}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Custom Tooltip */}
|
||||
{hoveredItem && (
|
||||
<div
|
||||
className="pointer-events-none fixed z-50 rounded border px-3 py-2 text-xs shadow-lg"
|
||||
style={{
|
||||
left: mousePosition.x + 10,
|
||||
top: mousePosition.y - 10,
|
||||
backgroundColor: theme === "dark" ? "#1e293b" : "white",
|
||||
borderColor: theme === "dark" ? "#475569" : "rgba(0, 0, 0, 0.1)",
|
||||
color: theme === "dark" ? "white" : "black",
|
||||
}}
|
||||
>
|
||||
<div className="mb-1 font-semibold">
|
||||
{isRegionFiltered
|
||||
? capitalizeFirstLetter(hoveredItem.name)
|
||||
: hoveredItem.name}
|
||||
</div>
|
||||
<div>Failure Rate: {hoveredItem.failurePercentage}%</div>
|
||||
<div>
|
||||
Failed: {hoveredItem.failedRequirements}/
|
||||
{hoveredItem.totalRequirements}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+11
-9
@@ -1,11 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { Cell, Label, Pie, PieChart, Tooltip } from "recharts";
|
||||
import {
|
||||
Cell,
|
||||
Label,
|
||||
Pie,
|
||||
PieChart as RechartsPieChart,
|
||||
Tooltip,
|
||||
} from "recharts";
|
||||
|
||||
import { ChartConfig, ChartContainer } from "@/components/ui/chart/Chart";
|
||||
|
||||
interface RequirementsChartProps {
|
||||
interface PieChartProps {
|
||||
pass: number;
|
||||
fail: number;
|
||||
manual: number;
|
||||
@@ -29,11 +35,7 @@ const chartConfig = {
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export const RequirementsChart = ({
|
||||
pass,
|
||||
fail,
|
||||
manual,
|
||||
}: RequirementsChartProps) => {
|
||||
export const PieChart = ({ pass, fail, manual }: PieChartProps) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const chartData = [
|
||||
@@ -119,7 +121,7 @@ export const RequirementsChart = ({
|
||||
config={chartConfig}
|
||||
className="aspect-square w-[200px] min-w-[200px]"
|
||||
>
|
||||
<PieChart>
|
||||
<RechartsPieChart>
|
||||
<Tooltip
|
||||
cursor={false}
|
||||
content={<CustomTooltip active={false} payload={[]} />}
|
||||
@@ -168,7 +170,7 @@ export const RequirementsChart = ({
|
||||
}}
|
||||
/>
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</RechartsPieChart>
|
||||
</ChartContainer>
|
||||
|
||||
<div className="mt-2 grid grid-cols-3 gap-4">
|
||||
@@ -0,0 +1,150 @@
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
import { Requirement } from "@/types/compliance";
|
||||
|
||||
interface CISDetailsProps {
|
||||
requirement: Requirement;
|
||||
}
|
||||
|
||||
export const CISCustomDetails = ({ requirement }: CISDetailsProps) => {
|
||||
const processReferences = (
|
||||
references: string | number | string[] | undefined,
|
||||
): string[] => {
|
||||
if (typeof references !== "string") return [];
|
||||
|
||||
// Use regex to extract all URLs that start with https://
|
||||
const urlRegex = /https:\/\/[^:]+/g;
|
||||
const urls = references.match(urlRegex);
|
||||
|
||||
return urls || [];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{requirement.profile && (
|
||||
<div>
|
||||
<h4 className="text-muted-foreground mb-1 text-sm font-medium">
|
||||
Profile Level
|
||||
</h4>
|
||||
<p className="text-sm">{requirement.profile}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requirement.subsection && (
|
||||
<div>
|
||||
<h4 className="text-muted-foreground mb-1 text-sm font-medium">
|
||||
SubSection
|
||||
</h4>
|
||||
<p className="text-sm">{requirement.subsection}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requirement.assessment_status && (
|
||||
<div>
|
||||
<h4 className="text-muted-foreground mb-1 text-sm font-medium">
|
||||
Assessment Status
|
||||
</h4>
|
||||
<p className="text-sm">{requirement.assessment_status}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requirement.description && (
|
||||
<div>
|
||||
<h4 className="text-muted-foreground mb-1 text-sm font-medium">
|
||||
Description
|
||||
</h4>
|
||||
<p className="text-sm">{requirement.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requirement.rationale_statement && (
|
||||
<div>
|
||||
<h4 className="text-muted-foreground mb-1 text-sm font-medium">
|
||||
Rationale Statement
|
||||
</h4>
|
||||
<p className="text-sm">{requirement.rationale_statement}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requirement.impact_statement && (
|
||||
<div>
|
||||
<h4 className="text-muted-foreground mb-1 text-sm font-medium">
|
||||
Impact Statement
|
||||
</h4>
|
||||
<p className="text-sm">{requirement.impact_statement}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requirement.remediation_procedure &&
|
||||
typeof requirement.remediation_procedure === "string" && (
|
||||
<div>
|
||||
<h4 className="text-muted-foreground mb-1 text-sm font-medium">
|
||||
Remediation Procedure
|
||||
</h4>
|
||||
{/* Prettier -> "plugins": ["prettier-plugin-tailwindcss"] is not ready yet to "prose": */}
|
||||
{/* eslint-disable-next-line */}
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<ReactMarkdown>{requirement.remediation_procedure}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requirement.audit_procedure &&
|
||||
typeof requirement.audit_procedure === "string" && (
|
||||
<div>
|
||||
<h4 className="text-muted-foreground mb-1 text-sm font-medium">
|
||||
Audit Procedure
|
||||
</h4>
|
||||
{/* eslint-disable-next-line */}
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<ReactMarkdown>{requirement.audit_procedure}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requirement.additional_information && (
|
||||
<div>
|
||||
<h4 className="text-muted-foreground mb-1 text-sm font-medium">
|
||||
Additional Information
|
||||
</h4>
|
||||
<p className="whitespace-pre-wrap text-sm">
|
||||
{requirement.additional_information}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requirement.default_value && (
|
||||
<div>
|
||||
<h4 className="text-muted-foreground mb-1 text-sm font-medium">
|
||||
Default Value
|
||||
</h4>
|
||||
<p className="text-sm">{requirement.default_value}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requirement.references && (
|
||||
<div>
|
||||
<h4 className="text-muted-foreground mb-1 text-sm font-medium">
|
||||
References
|
||||
</h4>
|
||||
<div className="text-sm">
|
||||
{processReferences(requirement.references).map(
|
||||
(url: string, index: number) => (
|
||||
<div key={index}>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="break-all text-blue-600 underline hover:text-blue-800"
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -13,6 +13,7 @@ interface ComplianceHeaderProps {
|
||||
uniqueRegions: string[];
|
||||
showSearch?: boolean;
|
||||
showRegionFilter?: boolean;
|
||||
framework?: string; // Framework name to show specific filters
|
||||
}
|
||||
|
||||
export const ComplianceHeader = ({
|
||||
@@ -20,25 +21,46 @@ export const ComplianceHeader = ({
|
||||
uniqueRegions,
|
||||
showSearch = true,
|
||||
showRegionFilter = true,
|
||||
framework,
|
||||
}: ComplianceHeaderProps) => {
|
||||
const frameworkFilters = [];
|
||||
|
||||
// Add CIS Profile Level filter if framework is CIS
|
||||
if (framework === "CIS") {
|
||||
frameworkFilters.push({
|
||||
key: "cis_profile_level",
|
||||
labelCheckboxGroup: "Level",
|
||||
values: ["Level 1", "Level 2"],
|
||||
index: 0, // Show first
|
||||
showSelectAll: false, // No "Select All" option since Level 2 includes Level 1
|
||||
defaultValues: ["Level 2"], // Default to Level 2 selected (which includes Level 1)
|
||||
});
|
||||
}
|
||||
|
||||
// Prepare region filters
|
||||
const regionFilters = showRegionFilter
|
||||
? [
|
||||
{
|
||||
key: "region__in",
|
||||
labelCheckboxGroup: "Regions",
|
||||
values: uniqueRegions,
|
||||
index: 1, // Show after framework filters
|
||||
defaultToSelectAll: true, // Default to all regions selected
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const allFilters = [...frameworkFilters, ...regionFilters];
|
||||
|
||||
return (
|
||||
<>
|
||||
{showSearch && <FilterControls search />}
|
||||
<Spacer y={8} />
|
||||
<DataCompliance scans={scans} />
|
||||
{showRegionFilter && (
|
||||
{allFilters.length > 0 && (
|
||||
<>
|
||||
<Spacer y={8} />
|
||||
<DataTableFilterCustom
|
||||
filters={[
|
||||
{
|
||||
key: "region__in",
|
||||
labelCheckboxGroup: "Regions",
|
||||
values: uniqueRegions,
|
||||
},
|
||||
]}
|
||||
defaultOpen={true}
|
||||
/>
|
||||
<DataTableFilterCustom filters={allFilters} defaultOpen={true} />
|
||||
</>
|
||||
)}
|
||||
<Spacer y={12} />
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
export * from "./compliance-accordion/client-accordion-content";
|
||||
export * from "./compliance-accordion/client-accordion-wrapper";
|
||||
export * from "./compliance-accordion/compliance-accordion-requeriment-title";
|
||||
export * from "./compliance-accordion/compliance-accordion-title";
|
||||
export * from "./compliance-card";
|
||||
export * from "./compliance-charts/bar-chart";
|
||||
export * from "./compliance-charts/heatmap-chart";
|
||||
export * from "./compliance-charts/pie-chart";
|
||||
export * from "./compliance-custom-details/cis-details";
|
||||
export * from "./compliance-custom-details/ens-details";
|
||||
export * from "./compliance-custom-details/iso-details";
|
||||
export * from "./compliance-header/compliance-header";
|
||||
export * from "./compliance-header/compliance-scan-info";
|
||||
export * from "./compliance-skeleton-grid";
|
||||
export * from "./compliance-header/data-compliance";
|
||||
export * from "./compliance-header/select-scan-compliance-data";
|
||||
export * from "./no-scans-available";
|
||||
export * from "./skeletons/bar-chart-skeleton";
|
||||
export * from "./skeletons/compliance-accordion-skeleton";
|
||||
export * from "./skeletons/compliance-grid-skeleton";
|
||||
export * from "./skeletons/heatmap-chart-skeleton";
|
||||
export * from "./skeletons/pie-chart-skeleton";
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Skeleton } from "@nextui-org/react";
|
||||
|
||||
export const FailedSectionsChartSkeleton = () => {
|
||||
export const BarChartSkeleton = () => {
|
||||
return (
|
||||
<div className="flex w-[400px] flex-col items-center justify-between lg:w-[600px]">
|
||||
{/* Title skeleton */}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { Skeleton } from "@nextui-org/react";
|
||||
|
||||
export const HeatmapChartSkeleton = () => {
|
||||
return (
|
||||
<div className="flex h-[320px] w-[400px] flex-col items-center justify-between lg:w-[400px]">
|
||||
{/* Title skeleton */}
|
||||
<Skeleton className="h-4 w-36 rounded-lg">
|
||||
<div className="h-4 bg-default-200" />
|
||||
</Skeleton>
|
||||
|
||||
{/* Heatmap area skeleton - 3x3 grid like the real component */}
|
||||
<div className="h-full w-full p-4">
|
||||
<div className="grid h-full w-full grid-cols-3 gap-1">
|
||||
{Array.from({ length: 9 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
className="flex items-center justify-center rounded border"
|
||||
>
|
||||
<div className="h-full w-full bg-default-200" />
|
||||
</Skeleton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Skeleton } from "@nextui-org/react";
|
||||
|
||||
export const RequirementsChartSkeleton = () => {
|
||||
export const PieChartSkeleton = () => {
|
||||
return (
|
||||
<div className="flex h-[320px] flex-col items-center justify-between">
|
||||
{/* Title skeleton */}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Suspense, use } from "react";
|
||||
import { ReactNode, Suspense, use } from "react";
|
||||
|
||||
import { getUserInfo } from "@/actions/users/users";
|
||||
|
||||
import { Navbar } from "../nav-bar/navbar";
|
||||
import { SkeletonContentLayout } from "./skeleton-content-layout";
|
||||
|
||||
interface ContentLayoutProps {
|
||||
title: string;
|
||||
icon: string;
|
||||
icon: string | ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,13 @@ import {
|
||||
} from "@nextui-org/react";
|
||||
import { ChevronDown, X } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { CustomDropdownFilterProps } from "@/types";
|
||||
|
||||
@@ -25,6 +31,7 @@ export const CustomDropdownFilter = ({
|
||||
const searchParams = useSearchParams();
|
||||
const [groupSelected, setGroupSelected] = useState(new Set<string>());
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const hasUserInteracted = useRef(false);
|
||||
|
||||
const filterValues = useMemo(() => filter?.values || [], [filter?.values]);
|
||||
const selectedValues = Array.from(groupSelected).filter(
|
||||
@@ -42,24 +49,66 @@ export const CustomDropdownFilter = ({
|
||||
useEffect(() => {
|
||||
if (activeFilterValue.length > 0) {
|
||||
const newSelection = new Set(activeFilterValue);
|
||||
if (newSelection.size === filterValues.length) {
|
||||
if (
|
||||
newSelection.size === filterValues.length &&
|
||||
filter?.showSelectAll !== false
|
||||
) {
|
||||
newSelection.add("all");
|
||||
}
|
||||
setGroupSelected(newSelection);
|
||||
} else {
|
||||
setGroupSelected(new Set());
|
||||
} else if (!hasUserInteracted.current) {
|
||||
// Handle default behavior when no URL params exist
|
||||
// Only apply defaults if user hasn't interacted yet
|
||||
// Only set visual state, don't trigger URL changes automatically
|
||||
if (filter?.defaultToSelectAll && filterValues.length > 0) {
|
||||
const newSelection = new Set(filterValues);
|
||||
if (filter?.showSelectAll !== false) {
|
||||
newSelection.add("all");
|
||||
}
|
||||
setGroupSelected(newSelection);
|
||||
// DON'T notify parent automatically - wait for user interaction
|
||||
} else if (filter?.defaultValues && filter.defaultValues.length > 0) {
|
||||
// Handle specific default values
|
||||
const validDefaultValues = filter.defaultValues.filter((value) =>
|
||||
filterValues.includes(value),
|
||||
);
|
||||
const newSelection = new Set(validDefaultValues);
|
||||
|
||||
// Add "all" if all items are selected and showSelectAll is not false
|
||||
if (
|
||||
validDefaultValues.length === filterValues.length &&
|
||||
filter?.showSelectAll !== false
|
||||
) {
|
||||
newSelection.add("all");
|
||||
}
|
||||
|
||||
setGroupSelected(newSelection);
|
||||
// DON'T notify parent automatically - wait for user interaction
|
||||
} else {
|
||||
setGroupSelected(new Set());
|
||||
}
|
||||
}
|
||||
}, [activeFilterValue, filterValues.length]);
|
||||
}, [
|
||||
activeFilterValue,
|
||||
filterValues,
|
||||
filter?.defaultToSelectAll,
|
||||
filter?.defaultValues,
|
||||
filter?.showSelectAll,
|
||||
]);
|
||||
|
||||
const updateSelection = useCallback(
|
||||
(newValues: string[]) => {
|
||||
// Mark that user has interacted with the filter
|
||||
hasUserInteracted.current = true;
|
||||
|
||||
const actualValues = newValues.filter((key) => key !== "all");
|
||||
const newSelection = new Set(actualValues);
|
||||
|
||||
// Auto-add "all" if all items are selected
|
||||
// Auto-add "all" if all items are selected and showSelectAll is not false
|
||||
if (
|
||||
actualValues.length === filterValues.length &&
|
||||
filterValues.length > 0
|
||||
filterValues.length > 0 &&
|
||||
filter?.showSelectAll !== false
|
||||
) {
|
||||
newSelection.add("all");
|
||||
}
|
||||
@@ -69,7 +118,7 @@ export const CustomDropdownFilter = ({
|
||||
// Notify parent with actual values (excluding "all")
|
||||
onFilterChange?.(filter.key, actualValues);
|
||||
},
|
||||
[filterValues.length, onFilterChange, filter.key],
|
||||
[filterValues.length, onFilterChange, filter.key, filter?.showSelectAll],
|
||||
);
|
||||
|
||||
const onSelectionChange = useCallback(
|
||||
@@ -194,16 +243,20 @@ export const CustomDropdownFilter = ({
|
||||
onValueChange={onSelectionChange}
|
||||
className="font-bold"
|
||||
>
|
||||
<Checkbox
|
||||
classNames={{
|
||||
label: "text-small font-normal",
|
||||
wrapper: "checkbox-update",
|
||||
}}
|
||||
value="all"
|
||||
>
|
||||
Select All
|
||||
</Checkbox>
|
||||
<Divider orientation="horizontal" className="mt-2" />
|
||||
{filter?.showSelectAll !== false && (
|
||||
<>
|
||||
<Checkbox
|
||||
classNames={{
|
||||
label: "text-small font-normal",
|
||||
wrapper: "checkbox-update",
|
||||
}}
|
||||
value="all"
|
||||
>
|
||||
Select All
|
||||
</Checkbox>
|
||||
<Divider orientation="horizontal" className="mt-2" />
|
||||
</>
|
||||
)}
|
||||
<ScrollShadow
|
||||
hideScrollBar
|
||||
className="flex max-h-96 max-w-full flex-col gap-y-2 py-2"
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Icon } from "@iconify/react";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { ThemeSwitch } from "@/components/ThemeSwitch";
|
||||
import { UserProfileProps } from "@/types";
|
||||
|
||||
import { SheetMenu } from "../sidebar/sheet-menu";
|
||||
import { UserNav } from "../user-nav/user-nav";
|
||||
|
||||
interface NavbarProps {
|
||||
title: string;
|
||||
icon: string;
|
||||
icon: string | ReactNode;
|
||||
user: UserProfileProps;
|
||||
}
|
||||
|
||||
@@ -17,12 +19,18 @@ export function Navbar({ title, icon, user }: NavbarProps) {
|
||||
<div className="mx-4 flex h-14 items-center sm:mx-8">
|
||||
<div className="flex items-center space-x-2">
|
||||
<SheetMenu />
|
||||
<Icon
|
||||
className="text-default-500"
|
||||
height={24}
|
||||
icon={icon}
|
||||
width={24}
|
||||
/>
|
||||
{typeof icon === "string" ? (
|
||||
<Icon
|
||||
className="text-default-500"
|
||||
height={24}
|
||||
icon={icon}
|
||||
width={24}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-10 w-10 items-center justify-center [&>*]:h-full [&>*]:w-full">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-sm font-bold text-default-700">{title}</h1>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-end gap-3">
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content";
|
||||
import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title";
|
||||
import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title";
|
||||
import { AccordionItemProps } from "@/components/ui/accordion/Accordion";
|
||||
import { FindingStatus } from "@/components/ui/table/status-finding-badge";
|
||||
import {
|
||||
AttributesData,
|
||||
CISAttributesMetadata,
|
||||
Framework,
|
||||
Requirement,
|
||||
RequirementItemData,
|
||||
RequirementsData,
|
||||
RequirementStatus,
|
||||
} from "@/types/compliance";
|
||||
|
||||
export const mapComplianceData = (
|
||||
attributesData: AttributesData,
|
||||
requirementsData: RequirementsData,
|
||||
filter?: string, // "Level 1" or "Level 2" or undefined (show all)
|
||||
): Framework[] => {
|
||||
const attributes = attributesData?.data || [];
|
||||
const requirements = requirementsData?.data || [];
|
||||
|
||||
// Create a map for quick lookup of requirements by id
|
||||
const requirementsMap = new Map<string, RequirementItemData>();
|
||||
requirements.forEach((req: RequirementItemData) => {
|
||||
requirementsMap.set(req.id, req);
|
||||
});
|
||||
|
||||
const frameworks: Framework[] = [];
|
||||
|
||||
// Process attributes and merge with requirements data
|
||||
for (const attributeItem of attributes) {
|
||||
const id = attributeItem.id;
|
||||
const metadataArray = attributeItem.attributes?.attributes
|
||||
?.metadata as unknown as CISAttributesMetadata[];
|
||||
const attrs = metadataArray?.[0];
|
||||
if (!attrs) continue;
|
||||
|
||||
// Apply profile filter
|
||||
if (filter === "Level 1" && attrs.Profile !== "Level 1") {
|
||||
continue; // Skip Level 2 requirements when Level 1 is selected
|
||||
}
|
||||
|
||||
// Get corresponding requirement data
|
||||
const requirementData = requirementsMap.get(id);
|
||||
if (!requirementData) continue;
|
||||
|
||||
const frameworkName = attributeItem.attributes.framework;
|
||||
const sectionName = attrs.Section;
|
||||
const description = attributeItem.attributes.description;
|
||||
const status = requirementData.attributes.status || "";
|
||||
const checks = attributeItem.attributes.attributes.check_ids || [];
|
||||
const requirementName = id;
|
||||
|
||||
// Find or create framework
|
||||
let framework = frameworks.find((f) => f.name === frameworkName);
|
||||
if (!framework) {
|
||||
framework = {
|
||||
name: frameworkName,
|
||||
pass: 0,
|
||||
fail: 0,
|
||||
manual: 0,
|
||||
categories: [],
|
||||
};
|
||||
frameworks.push(framework);
|
||||
}
|
||||
|
||||
const normalizedSectionName = sectionName.replace(/^(\d+)\s/, "$1. ");
|
||||
let category = framework.categories.find(
|
||||
(c) => c.name === normalizedSectionName,
|
||||
);
|
||||
|
||||
if (!category) {
|
||||
category = {
|
||||
name: normalizedSectionName,
|
||||
pass: 0,
|
||||
fail: 0,
|
||||
manual: 0,
|
||||
controls: [],
|
||||
};
|
||||
framework.categories.push(category);
|
||||
}
|
||||
|
||||
// Create a control for this requirement (each requirement is its own control)
|
||||
const controlLabel = `${id} - ${description}`;
|
||||
const control = {
|
||||
label: controlLabel,
|
||||
pass: 0,
|
||||
fail: 0,
|
||||
manual: 0,
|
||||
requirements: [] as Requirement[],
|
||||
};
|
||||
|
||||
// Create requirement
|
||||
const finalStatus: RequirementStatus = status as RequirementStatus;
|
||||
const requirement: Requirement = {
|
||||
name: requirementName,
|
||||
description: attrs.Description,
|
||||
status: finalStatus,
|
||||
check_ids: checks,
|
||||
pass: finalStatus === "PASS" ? 1 : 0,
|
||||
fail: finalStatus === "FAIL" ? 1 : 0,
|
||||
manual: finalStatus === "MANUAL" ? 1 : 0,
|
||||
profile: attrs.Profile,
|
||||
subsection: attrs.SubSection || "",
|
||||
assessment_status: attrs.AssessmentStatus,
|
||||
rationale_statement: attrs.RationaleStatement,
|
||||
impact_statement: attrs.ImpactStatement,
|
||||
remediation_procedure: attrs.RemediationProcedure,
|
||||
audit_procedure: attrs.AuditProcedure,
|
||||
additional_information: attrs.AdditionalInformation,
|
||||
default_value: attrs.DefaultValue || "",
|
||||
references: attrs.References,
|
||||
};
|
||||
|
||||
control.requirements.push(requirement);
|
||||
|
||||
// Update control counters
|
||||
if (requirement.status === "MANUAL") {
|
||||
control.manual++;
|
||||
} else if (requirement.status === "PASS") {
|
||||
control.pass++;
|
||||
} else if (requirement.status === "FAIL") {
|
||||
control.fail++;
|
||||
}
|
||||
|
||||
category.controls.push(control);
|
||||
}
|
||||
|
||||
// Calculate counters for categories and frameworks
|
||||
frameworks.forEach((framework) => {
|
||||
framework.pass = 0;
|
||||
framework.fail = 0;
|
||||
framework.manual = 0;
|
||||
|
||||
framework.categories.forEach((category) => {
|
||||
category.pass = 0;
|
||||
category.fail = 0;
|
||||
category.manual = 0;
|
||||
|
||||
category.controls.forEach((control) => {
|
||||
category.pass += control.pass;
|
||||
category.fail += control.fail;
|
||||
category.manual += control.manual;
|
||||
});
|
||||
|
||||
framework.pass += category.pass;
|
||||
framework.fail += category.fail;
|
||||
framework.manual += category.manual;
|
||||
});
|
||||
});
|
||||
|
||||
return frameworks;
|
||||
};
|
||||
|
||||
export const toAccordionItems = (
|
||||
data: Framework[],
|
||||
scanId: string | undefined,
|
||||
): AccordionItemProps[] => {
|
||||
return data.flatMap((framework) =>
|
||||
framework.categories.map((category) => {
|
||||
return {
|
||||
key: `${framework.name}-${category.name}`,
|
||||
title: (
|
||||
<ComplianceAccordionTitle
|
||||
label={category.name}
|
||||
pass={category.pass}
|
||||
fail={category.fail}
|
||||
manual={category.manual}
|
||||
isParentLevel={true}
|
||||
/>
|
||||
),
|
||||
content: "",
|
||||
items: category.controls.map((control, i: number) => {
|
||||
const requirement = control.requirements[0]; // Each control has one requirement
|
||||
const itemKey = `${framework.name}-${category.name}-control-${i}`;
|
||||
|
||||
return {
|
||||
key: itemKey,
|
||||
title: (
|
||||
<ComplianceAccordionRequirementTitle
|
||||
type=""
|
||||
name={control.label}
|
||||
status={requirement.status as FindingStatus}
|
||||
/>
|
||||
),
|
||||
content: (
|
||||
<ClientAccordionContent
|
||||
requirement={requirement}
|
||||
scanId={scanId || ""}
|
||||
framework={framework.name}
|
||||
disableFindings={
|
||||
requirement.check_ids.length === 0 && requirement.manual === 0
|
||||
}
|
||||
/>
|
||||
),
|
||||
items: [],
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,23 @@
|
||||
import React from "react";
|
||||
|
||||
import { CISCustomDetails } from "@/components/compliance/compliance-custom-details/cis-details";
|
||||
import { ENSCustomDetails } from "@/components/compliance/compliance-custom-details/ens-details";
|
||||
import { ISOCustomDetails } from "@/components/compliance/compliance-custom-details/iso-details";
|
||||
import { AccordionItemProps } from "@/components/ui/accordion/Accordion";
|
||||
import {
|
||||
AttributesData,
|
||||
CategoryData,
|
||||
FailedSection,
|
||||
Framework,
|
||||
RegionData,
|
||||
Requirement,
|
||||
RequirementsData,
|
||||
} from "@/types/compliance";
|
||||
|
||||
import {
|
||||
mapComplianceData as mapCISComplianceData,
|
||||
toAccordionItems as toCISAccordionItems,
|
||||
} from "./cis";
|
||||
import {
|
||||
mapComplianceData as mapENSComplianceData,
|
||||
toAccordionItems as toENSAccordionItems,
|
||||
@@ -19,12 +31,14 @@ export interface ComplianceMapper {
|
||||
mapComplianceData: (
|
||||
attributesData: AttributesData,
|
||||
requirementsData: RequirementsData,
|
||||
filter?: string,
|
||||
) => Framework[];
|
||||
toAccordionItems: (
|
||||
data: Framework[],
|
||||
scanId: string | undefined,
|
||||
) => AccordionItemProps[];
|
||||
getTopFailedSections: (mappedData: Framework[]) => FailedSection[];
|
||||
getDetailsComponent: (requirement: Requirement) => React.ReactNode;
|
||||
}
|
||||
|
||||
// Common function for getting top failed sections
|
||||
@@ -70,11 +84,22 @@ const complianceMappers: Record<string, ComplianceMapper> = {
|
||||
mapComplianceData: mapENSComplianceData,
|
||||
toAccordionItems: toENSAccordionItems,
|
||||
getTopFailedSections,
|
||||
getDetailsComponent: (requirement: Requirement) =>
|
||||
React.createElement(ENSCustomDetails, { requirement }),
|
||||
},
|
||||
ISO27001: {
|
||||
mapComplianceData: mapISOComplianceData,
|
||||
toAccordionItems: toISOAccordionItems,
|
||||
getTopFailedSections,
|
||||
getDetailsComponent: (requirement: Requirement) =>
|
||||
React.createElement(ISOCustomDetails, { requirement }),
|
||||
},
|
||||
CIS: {
|
||||
mapComplianceData: mapCISComplianceData,
|
||||
toAccordionItems: toCISAccordionItems,
|
||||
getTopFailedSections,
|
||||
getDetailsComponent: (requirement: Requirement) =>
|
||||
React.createElement(CISCustomDetails, { requirement }),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -83,7 +108,7 @@ const defaultMapper: ComplianceMapper = complianceMappers.ENS;
|
||||
|
||||
/**
|
||||
* Get the appropriate compliance mapper based on the framework name
|
||||
* @param framework - The framework name (e.g., "ENS", "ISO27001")
|
||||
* @param framework - The framework name (e.g., "ENS", "ISO27001", "CIS")
|
||||
* @returns ComplianceMapper object with specific functions for the framework
|
||||
*/
|
||||
export const getComplianceMapper = (framework?: string): ComplianceMapper => {
|
||||
@@ -93,3 +118,140 @@ export const getComplianceMapper = (framework?: string): ComplianceMapper => {
|
||||
|
||||
return complianceMappers[framework] || defaultMapper;
|
||||
};
|
||||
|
||||
export const calculateRegionHeatmapData = async (
|
||||
complianceId: string,
|
||||
scanId: string,
|
||||
uniqueRegions: string[],
|
||||
attributesData: AttributesData,
|
||||
mapper: ComplianceMapper,
|
||||
): Promise<RegionData[]> => {
|
||||
if (!complianceId || !scanId || !uniqueRegions?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const { getComplianceRequirements } = await import("@/actions/compliances");
|
||||
|
||||
// Get data for each region in parallel
|
||||
const regionPromises = uniqueRegions.map(async (region) => {
|
||||
try {
|
||||
// Only need to fetch requirements data per region
|
||||
const regionRequirementsData = await getComplianceRequirements({
|
||||
complianceId,
|
||||
scanId,
|
||||
region, // Filter by specific region
|
||||
});
|
||||
|
||||
// Map the data using the provided mapper
|
||||
const mappedData = mapper.mapComplianceData(
|
||||
attributesData,
|
||||
regionRequirementsData,
|
||||
);
|
||||
|
||||
// Calculate totals for this region
|
||||
const regionTotals = mappedData.reduce(
|
||||
(acc, framework) => ({
|
||||
pass: acc.pass + framework.pass,
|
||||
fail: acc.fail + framework.fail,
|
||||
manual: acc.manual + framework.manual,
|
||||
}),
|
||||
{ pass: 0, fail: 0, manual: 0 },
|
||||
);
|
||||
|
||||
const totalRequirements =
|
||||
regionTotals.pass + regionTotals.fail + regionTotals.manual;
|
||||
const failurePercentage =
|
||||
totalRequirements > 0
|
||||
? Math.round((regionTotals.fail / totalRequirements) * 100)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
name: region,
|
||||
failurePercentage,
|
||||
totalRequirements,
|
||||
failedRequirements: regionTotals.fail,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error fetching data for region ${region}:`, error);
|
||||
return {
|
||||
name: region,
|
||||
failurePercentage: 0,
|
||||
totalRequirements: 0,
|
||||
failedRequirements: 0,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const regionData = await Promise.all(regionPromises);
|
||||
|
||||
// Filter, sort and limit to top 9 regions for 3x3 grid
|
||||
const filteredData = regionData
|
||||
.filter((region) => region.totalRequirements > 0)
|
||||
.sort((a, b) => b.failurePercentage - a.failurePercentage)
|
||||
.slice(0, 9);
|
||||
|
||||
return filteredData;
|
||||
} catch (error) {
|
||||
console.error("Error calculating region heatmap data:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const calculateCategoryHeatmapData = (
|
||||
complianceData: Framework[],
|
||||
): CategoryData[] => {
|
||||
if (!complianceData?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const categoryMap = new Map<
|
||||
string,
|
||||
{ pass: number; fail: number; manual: number }
|
||||
>();
|
||||
|
||||
// Aggregate data by category
|
||||
complianceData.forEach((framework) => {
|
||||
framework.categories.forEach((category) => {
|
||||
const existing = categoryMap.get(category.name) || {
|
||||
pass: 0,
|
||||
fail: 0,
|
||||
manual: 0,
|
||||
};
|
||||
categoryMap.set(category.name, {
|
||||
pass: existing.pass + category.pass,
|
||||
fail: existing.fail + category.fail,
|
||||
manual: existing.manual + category.manual,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const categoryData: CategoryData[] = Array.from(categoryMap.entries()).map(
|
||||
([name, stats]) => {
|
||||
const totalRequirements = stats.pass + stats.fail + stats.manual;
|
||||
const failurePercentage =
|
||||
totalRequirements > 0
|
||||
? Math.round((stats.fail / totalRequirements) * 100)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
name,
|
||||
failurePercentage,
|
||||
totalRequirements,
|
||||
failedRequirements: stats.fail,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const filteredData = categoryData
|
||||
.filter((category) => category.totalRequirements > 0)
|
||||
.sort((a, b) => b.failurePercentage - a.failurePercentage)
|
||||
.slice(0, 9); // Show top 9 categories
|
||||
|
||||
return filteredData;
|
||||
} catch (error) {
|
||||
console.error("Error calculating category heatmap data:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -14,6 +14,10 @@ import {
|
||||
} from "@/types/compliance";
|
||||
|
||||
export const translateType = (type: string) => {
|
||||
if (!type) {
|
||||
return "";
|
||||
}
|
||||
|
||||
switch (type.toLowerCase()) {
|
||||
case "requisito":
|
||||
return "Requirement";
|
||||
@@ -223,12 +227,13 @@ export const toAccordionItems = (
|
||||
<ClientAccordionContent
|
||||
requirement={requirement}
|
||||
scanId={scanId || ""}
|
||||
framework={framework.name}
|
||||
disableFindings={
|
||||
requirement.check_ids.length === 0 &&
|
||||
requirement.manual === 0
|
||||
}
|
||||
/>
|
||||
),
|
||||
items: [],
|
||||
isDisabled:
|
||||
requirement.check_ids.length === 0 &&
|
||||
requirement.manual === 0,
|
||||
};
|
||||
}),
|
||||
isDisabled:
|
||||
|
||||
@@ -192,12 +192,14 @@ export const toAccordionItems = (
|
||||
<ClientAccordionContent
|
||||
requirement={requirement}
|
||||
scanId={scanId || ""}
|
||||
framework={framework.name}
|
||||
disableFindings={
|
||||
requirement.check_ids.length === 0 &&
|
||||
requirement.manual === 0
|
||||
}
|
||||
/>
|
||||
),
|
||||
items: [],
|
||||
isDisabled:
|
||||
requirement.check_ids.length === 0 &&
|
||||
requirement.manual === 0,
|
||||
};
|
||||
}),
|
||||
isDisabled:
|
||||
|
||||
Generated
+1219
-7
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@
|
||||
"@radix-ui/react-toast": "^1.2.4",
|
||||
"@react-aria/ssr": "3.9.4",
|
||||
"@react-aria/visually-hidden": "3.8.12",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/react-table": "^8.19.3",
|
||||
"add": "^2.0.6",
|
||||
"alert": "^6.0.2",
|
||||
@@ -35,6 +36,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.52.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^2.15.2",
|
||||
"server-only": "^0.0.1",
|
||||
"shadcn-ui": "^0.2.3",
|
||||
|
||||
@@ -171,9 +171,9 @@ module.exports = {
|
||||
"100%": { left: "100%", width: "100%" },
|
||||
},
|
||||
dropArrow: {
|
||||
'0%': { transform: 'translateY(-8px)', opacity: '0' },
|
||||
'50%': { opacity: '1' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
"0%": { transform: "translateY(-8px)", opacity: "0" },
|
||||
"50%": { opacity: "1" },
|
||||
"100%": { transform: "translateY(0)", opacity: "1" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
@@ -188,6 +188,7 @@ module.exports = {
|
||||
},
|
||||
plugins: [
|
||||
require("tailwindcss-animate"),
|
||||
require("@tailwindcss/typography"),
|
||||
nextui({
|
||||
themes: {
|
||||
dark: {
|
||||
|
||||
+36
-1
@@ -3,7 +3,13 @@ export type RequirementStatus = "PASS" | "FAIL" | "MANUAL" | "No findings";
|
||||
export type ComplianceId =
|
||||
| "ens_rd2022_aws"
|
||||
| "iso27001_2013_aws"
|
||||
| "iso27001_2022_aws";
|
||||
| "iso27001_2022_aws"
|
||||
| "cis_1.4_aws"
|
||||
| "cis_1.5_aws"
|
||||
| "cis_2.0_aws"
|
||||
| "cis_3.0_aws"
|
||||
| "cis_4.0_aws"
|
||||
| "cis_5.0_aws";
|
||||
|
||||
export interface CompliancesOverview {
|
||||
data: ComplianceOverviewData[];
|
||||
@@ -91,6 +97,21 @@ export interface ISO27001AttributesMetadata {
|
||||
Check_Summary: string;
|
||||
}
|
||||
|
||||
export interface CISAttributesMetadata {
|
||||
Section: string;
|
||||
SubSection: string | null;
|
||||
Profile: string; // "Level 1" or "Level 2"
|
||||
AssessmentStatus: string; // "Manual" or "Automated"
|
||||
Description: string;
|
||||
RationaleStatement: string;
|
||||
ImpactStatement: string;
|
||||
RemediationProcedure: string;
|
||||
AuditProcedure: string;
|
||||
AdditionalInformation: string;
|
||||
DefaultValue: string | null;
|
||||
References: string;
|
||||
}
|
||||
|
||||
export interface AttributesItemData {
|
||||
type: "compliance-requirements-attributes";
|
||||
id: string;
|
||||
@@ -123,3 +144,17 @@ export interface AttributesData {
|
||||
export interface RequirementsData {
|
||||
data: RequirementItemData[];
|
||||
}
|
||||
|
||||
export interface RegionData {
|
||||
name: string;
|
||||
failurePercentage: number;
|
||||
totalRequirements: number;
|
||||
failedRequirements: number;
|
||||
}
|
||||
|
||||
export interface CategoryData {
|
||||
name: string;
|
||||
failurePercentage: number;
|
||||
totalRequirements: number;
|
||||
failedRequirements: number;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ export interface FilterOption {
|
||||
values: string[];
|
||||
valueLabelMapping?: Array<{ [uid: string]: ProviderAccountProps }>;
|
||||
index?: number;
|
||||
showSelectAll?: boolean;
|
||||
defaultToSelectAll?: boolean;
|
||||
defaultValues?: string[];
|
||||
}
|
||||
|
||||
export interface CustomDropdownFilterProps {
|
||||
|
||||
Reference in New Issue
Block a user