mirror of
https://github.com/prowler-cloud/prowler.git
synced 2025-12-19 05:17:47 +00:00
feat: compliance detail view + ENS (#7853)
Co-authored-by: Víctor Fernández Poyatos <victor@prowler.com>
This commit is contained in:
@@ -22,7 +22,8 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"no-console": 1,
|
||||
// console.error are allowed but no console.log
|
||||
"no-console": ["error", { allow: ["error"] }],
|
||||
eqeqeq: 2,
|
||||
quotes: ["error", "double", "avoid-escape"],
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
|
||||
@@ -22,6 +22,7 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
### 🐞 Fixes
|
||||
|
||||
- Download report behaviour updated to show feedback based on API response. [(#7758)](https://github.com/prowler-cloud/prowler/pull/7758)
|
||||
- Compliace detail page, now available for ENS. [(#7853)](https://github.com/prowler-cloud/prowler/pull/7853)
|
||||
- Missing KISA and ProwlerThreat icons added to the compliance page. [(#7860)(https://github.com/prowler-cloud/prowler/pull/7860)]
|
||||
- Retrieve more than 10 scans in /compliance page. [(#7865)](https://github.com/prowler-cloud/prowler/pull/7865)
|
||||
- Improve CustomDropdownFilter component. [(#7868)(https://github.com/prowler-cloud/prowler/pull/7868)]
|
||||
|
||||
@@ -30,7 +30,6 @@ export const getCompliancesOverview = async ({
|
||||
});
|
||||
const data = await compliances.json();
|
||||
const parsedData = parseStringify(data);
|
||||
|
||||
revalidatePath("/compliance");
|
||||
return parsedData;
|
||||
} catch (error) {
|
||||
@@ -79,3 +78,77 @@ export const getComplianceOverviewMetadataInfo = async ({
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const getComplianceAttributes = async (complianceId: string) => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
try {
|
||||
const url = new URL(`${apiBaseUrl}/compliance-overviews/attributes`);
|
||||
url.searchParams.append("filter[compliance_id]", complianceId);
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch compliance attributes: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const parsedData = parseStringify(data);
|
||||
return parsedData;
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error fetching compliance attributes:", error);
|
||||
return undefined;
|
||||
}
|
||||
// */
|
||||
};
|
||||
|
||||
export const getComplianceRequirements = async ({
|
||||
complianceId,
|
||||
scanId,
|
||||
region,
|
||||
}: {
|
||||
complianceId: string;
|
||||
scanId: string;
|
||||
region?: string | string[];
|
||||
}) => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
try {
|
||||
const url = new URL(`${apiBaseUrl}/compliance-overviews/requirements`);
|
||||
url.searchParams.append("filter[compliance_id]", complianceId);
|
||||
url.searchParams.append("filter[scan_id]", scanId);
|
||||
|
||||
if (region) {
|
||||
const regionValue = Array.isArray(region) ? region.join(",") : region;
|
||||
url.searchParams.append("filter[region__in]", regionValue);
|
||||
//remove page param
|
||||
}
|
||||
url.searchParams.delete("page");
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch compliance requirements: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const parsedData = parseStringify(data);
|
||||
|
||||
return parsedData;
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error fetching compliance requirements:", error);
|
||||
return undefined;
|
||||
}
|
||||
// */
|
||||
};
|
||||
|
||||
269
ui/app/(prowler)/compliance/[compliancetitle]/page.tsx
Normal file
269
ui/app/(prowler)/compliance/[compliancetitle]/page.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import { Spacer } from "@nextui-org/react";
|
||||
import Image from "next/image";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import {
|
||||
getComplianceAttributes,
|
||||
getComplianceOverviewMetadataInfo,
|
||||
getComplianceRequirements,
|
||||
} 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 { ContentLayout } from "@/components/ui";
|
||||
import { mapComplianceData, toAccordionItems } from "@/lib/compliance/ens";
|
||||
import { ScanProps } from "@/types";
|
||||
import {
|
||||
FailedSection,
|
||||
MappedComplianceData,
|
||||
RequirementsTotals,
|
||||
} from "@/types/compliance";
|
||||
|
||||
interface ComplianceDetailSearchParams {
|
||||
complianceId: string;
|
||||
version?: string;
|
||||
scanId?: string;
|
||||
"filter[region__in]"?: string;
|
||||
}
|
||||
|
||||
const Logo = ({ logoPath }: { logoPath: string }) => {
|
||||
return (
|
||||
<div className="relative ml-auto hidden h-[200px] w-[200px] flex-shrink-0 md:block">
|
||||
<Image
|
||||
src={logoPath}
|
||||
alt="Compliance Logo"
|
||||
fill
|
||||
priority
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 && <Logo logoPath={logoPath} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default async function ComplianceDetail({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: { compliancetitle: string };
|
||||
searchParams: ComplianceDetailSearchParams;
|
||||
}) {
|
||||
const { compliancetitle } = params;
|
||||
const { complianceId, version, scanId } = searchParams;
|
||||
const regionFilter = searchParams["filter[region__in]"];
|
||||
|
||||
const logoPath = `/${compliancetitle.toLowerCase()}.png`;
|
||||
|
||||
// Create a key that includes region filter for Suspense
|
||||
const searchParamsKey = JSON.stringify(searchParams || {});
|
||||
|
||||
const formattedTitle = compliancetitle.split("-").join(" ");
|
||||
const pageTitle = version
|
||||
? `Compliance Details: ${formattedTitle} - ${version}`
|
||||
: `Compliance Details: ${formattedTitle}`;
|
||||
|
||||
// Fetch scans data
|
||||
const scansData = await getScans({
|
||||
filters: {
|
||||
"filter[state]": "completed",
|
||||
},
|
||||
});
|
||||
|
||||
// Expand scans with provider information
|
||||
const expandedScansData = await Promise.all(
|
||||
scansData.data.map(async (scan: ScanProps) => {
|
||||
const providerId = scan.relationships?.provider?.data?.id;
|
||||
|
||||
if (!providerId) {
|
||||
return { ...scan, providerInfo: null };
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("id", providerId);
|
||||
|
||||
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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const selectedScanId = scanId || expandedScansData[0]?.id || null;
|
||||
|
||||
// Fetch metadata info for regions
|
||||
const metadataInfoData = await getComplianceOverviewMetadataInfo({
|
||||
filters: {
|
||||
"filter[scan_id]": selectedScanId,
|
||||
},
|
||||
});
|
||||
|
||||
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
|
||||
|
||||
return (
|
||||
<ContentLayout title={pageTitle} icon="fluent-mdl2:compliance-audit">
|
||||
<ComplianceHeader
|
||||
scans={expandedScansData}
|
||||
uniqueRegions={uniqueRegions}
|
||||
showSearch={false}
|
||||
/>
|
||||
|
||||
<Suspense
|
||||
key={searchParamsKey}
|
||||
fallback={
|
||||
<div className="space-y-8">
|
||||
<ChartsWrapper logoPath={logoPath}>
|
||||
<RequirementsChartSkeleton />
|
||||
<FailedSectionsChartSkeleton />
|
||||
</ChartsWrapper>
|
||||
<SkeletonAccordion />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SSRComplianceContent
|
||||
complianceId={complianceId}
|
||||
scanId={selectedScanId}
|
||||
region={regionFilter}
|
||||
logoPath={logoPath}
|
||||
/>
|
||||
</Suspense>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const getComplianceData = async (
|
||||
complianceId: string,
|
||||
scanId: string,
|
||||
region?: string,
|
||||
): Promise<MappedComplianceData> => {
|
||||
const [attributesData, requirementsData] = await Promise.all([
|
||||
getComplianceAttributes(complianceId),
|
||||
getComplianceRequirements({
|
||||
complianceId,
|
||||
scanId,
|
||||
region,
|
||||
}),
|
||||
]);
|
||||
|
||||
const mappedData = mapComplianceData(attributesData, requirementsData);
|
||||
return mappedData;
|
||||
};
|
||||
|
||||
const getTopFailedSections = (
|
||||
mappedData: MappedComplianceData,
|
||||
): FailedSection[] => {
|
||||
const failedSectionMap = new Map();
|
||||
|
||||
mappedData.forEach((framework) => {
|
||||
framework.categories.forEach((category) => {
|
||||
category.controls.forEach((control) => {
|
||||
control.requirements.forEach((requirement) => {
|
||||
if (requirement.status === "FAIL") {
|
||||
const sectionName = category.name;
|
||||
|
||||
if (!failedSectionMap.has(sectionName)) {
|
||||
failedSectionMap.set(sectionName, { total: 0, types: {} });
|
||||
}
|
||||
|
||||
const sectionData = failedSectionMap.get(sectionName);
|
||||
sectionData.total += 1;
|
||||
|
||||
const type = requirement.type;
|
||||
sectionData.types[type] = (sectionData.types[type] || 0) + 1;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Convert in descending order and slice top 5
|
||||
return Array.from(failedSectionMap.entries())
|
||||
.map(([name, data]) => ({ name, ...data }))
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, 5); // Top 5
|
||||
};
|
||||
|
||||
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) => ({
|
||||
pass: acc.pass + framework.pass,
|
||||
fail: acc.fail + framework.fail,
|
||||
manual: acc.manual + framework.manual,
|
||||
}),
|
||||
{ pass: 0, fail: 0, manual: 0 },
|
||||
);
|
||||
const topFailedSections = getTopFailedSections(data);
|
||||
const accordionItems = toAccordionItems(data, scanId);
|
||||
const defaultKeys = accordionItems.slice(0, 2).map((item) => item.key);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<ChartsWrapper logoPath={logoPath}>
|
||||
<RequirementsChart
|
||||
pass={totalRequirements.pass}
|
||||
fail={totalRequirements.fail}
|
||||
manual={totalRequirements.manual}
|
||||
/>
|
||||
<FailedSectionsChart sections={topFailedSections} />
|
||||
</ChartsWrapper>
|
||||
|
||||
<Spacer className="h-1 w-full rounded-full bg-gray-200 dark:bg-gray-800" />
|
||||
<ClientAccordionWrapper
|
||||
items={accordionItems}
|
||||
defaultExpandedKeys={defaultKeys}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,4 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { Spacer } from "@nextui-org/react";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getCompliancesOverview } from "@/actions/compliances";
|
||||
@@ -12,11 +10,10 @@ import {
|
||||
ComplianceSkeletonGrid,
|
||||
NoScansAvailable,
|
||||
} from "@/components/compliance";
|
||||
import { DataCompliance } from "@/components/compliance/data-compliance";
|
||||
import { FilterControls } from "@/components/filters";
|
||||
import { ComplianceHeader } from "@/components/compliance/compliance-header/compliance-header";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { DataTableFilterCustom } from "@/components/ui/table/data-table-filter-custom";
|
||||
import { ComplianceOverviewData, ScanProps, SearchParamsProps } from "@/types";
|
||||
import { ScanProps, SearchParamsProps } from "@/types";
|
||||
import { ComplianceOverviewData } from "@/types/compliance";
|
||||
|
||||
export default async function Compliance({
|
||||
searchParams,
|
||||
@@ -84,21 +81,10 @@ export default async function Compliance({
|
||||
<ContentLayout title="Compliance" icon="fluent-mdl2:compliance-audit">
|
||||
{selectedScanId ? (
|
||||
<>
|
||||
<FilterControls search />
|
||||
<Spacer y={8} />
|
||||
<DataCompliance scans={expandedScansData} />
|
||||
<Spacer y={8} />
|
||||
<DataTableFilterCustom
|
||||
filters={[
|
||||
{
|
||||
key: "region__in",
|
||||
labelCheckboxGroup: "Regions",
|
||||
values: uniqueRegions,
|
||||
},
|
||||
]}
|
||||
defaultOpen={true}
|
||||
<ComplianceHeader
|
||||
scans={expandedScansData}
|
||||
uniqueRegions={uniqueRegions}
|
||||
/>
|
||||
<Spacer y={12} />
|
||||
<Suspense key={searchParamsKey} fallback={<ComplianceSkeletonGrid />}>
|
||||
<SSRComplianceGrid searchParams={searchParams} />
|
||||
</Suspense>
|
||||
@@ -133,7 +119,11 @@ const SSRComplianceGrid = async ({
|
||||
});
|
||||
|
||||
// Check if the response contains no data
|
||||
if (!compliancesData || compliancesData?.data?.length === 0) {
|
||||
if (
|
||||
!compliancesData ||
|
||||
!compliancesData.data ||
|
||||
compliancesData.data.length === 0
|
||||
) {
|
||||
return (
|
||||
<div className="flex h-full items-center">
|
||||
<div className="text-sm text-default-500">
|
||||
@@ -155,25 +145,22 @@ const SSRComplianceGrid = async ({
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
|
||||
{compliancesData.data.map((compliance: ComplianceOverviewData) => {
|
||||
const { attributes } = compliance;
|
||||
const {
|
||||
framework,
|
||||
version,
|
||||
requirements_status: { passed, total },
|
||||
compliance_id,
|
||||
} = attributes;
|
||||
const { attributes, id } = compliance;
|
||||
const { framework, version, requirements_passed, total_requirements } =
|
||||
attributes;
|
||||
|
||||
return (
|
||||
<ComplianceCard
|
||||
key={compliance.id}
|
||||
key={id}
|
||||
title={framework}
|
||||
version={version}
|
||||
passingRequirements={passed}
|
||||
totalRequirements={total}
|
||||
prevPassingRequirements={passed}
|
||||
prevTotalRequirements={total}
|
||||
passingRequirements={requirements_passed}
|
||||
totalRequirements={total_requirements}
|
||||
prevPassingRequirements={requirements_passed}
|
||||
prevTotalRequirements={total_requirements}
|
||||
scanId={scanId}
|
||||
complianceId={compliance_id}
|
||||
complianceId={id}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -27,7 +27,8 @@ import {
|
||||
createProviderDetailsMapping,
|
||||
extractProviderUIDs,
|
||||
} from "@/lib/provider-helpers";
|
||||
import { FindingProps, ScanProps, SearchParamsProps } from "@/types/components";
|
||||
import { ScanProps } from "@/types";
|
||||
import { FindingProps, SearchParamsProps } from "@/types/components";
|
||||
|
||||
export default async function Findings({
|
||||
searchParams,
|
||||
@@ -124,6 +125,7 @@ export default async function Findings({
|
||||
defaultOpen={true}
|
||||
/>
|
||||
<Spacer y={8} />
|
||||
|
||||
<Suspense key={searchParamsKey} fallback={<SkeletonTableFindings />}>
|
||||
<SSRDataTable searchParams={searchParams} />
|
||||
</Suspense>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { MembershipsCard } from "@/components/users/profile/memberships-card";
|
||||
import { RolesCard } from "@/components/users/profile/roles-card";
|
||||
import { SkeletonUserInfo } from "@/components/users/profile/skeleton-user-info";
|
||||
import { isUserOwnerAndHasManageAccount } from "@/lib/permissions";
|
||||
import { RoleDetail, TenantDetailData } from "@/types/users/users";
|
||||
import { RoleDetail, TenantDetailData } from "@/types/users";
|
||||
|
||||
export default async function Profile() {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { getFindings } from "@/actions/findings/findings";
|
||||
import {
|
||||
ColumnFindings,
|
||||
SkeletonTableFindings,
|
||||
} from "@/components/findings/table";
|
||||
import { Accordion } from "@/components/ui/accordion/Accordion";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { createDict } from "@/lib";
|
||||
import { ComplianceId, Requirement } from "@/types/compliance";
|
||||
import { FindingProps, FindingsResponse } from "@/types/components";
|
||||
|
||||
import { ComplianceCustomDetails } from "../compliance-custom-details/ens-details";
|
||||
|
||||
interface ClientAccordionContentProps {
|
||||
requirement: Requirement;
|
||||
scanId: string;
|
||||
}
|
||||
|
||||
export const ClientAccordionContent = ({
|
||||
requirement,
|
||||
scanId,
|
||||
}: ClientAccordionContentProps) => {
|
||||
const [findings, setFindings] = useState<FindingsResponse | null>(null);
|
||||
const [expandedFindings, setExpandedFindings] = useState<FindingProps[]>([]);
|
||||
const searchParams = useSearchParams();
|
||||
const pageNumber = searchParams.get("page") || "1";
|
||||
const complianceId = searchParams.get("complianceId") as ComplianceId;
|
||||
const defaultSort = "severity,status,-inserted_at";
|
||||
const sort = searchParams.get("sort") || defaultSort;
|
||||
const loadedPageRef = useRef<string | null>(null);
|
||||
const loadedSortRef = useRef<string | null>(null);
|
||||
const isExpandedRef = useRef(false);
|
||||
const region = searchParams.get("filter[region__in]") || "";
|
||||
|
||||
useEffect(() => {
|
||||
async function loadFindings() {
|
||||
if (
|
||||
requirement.check_ids?.length > 0 &&
|
||||
requirement.status !== "No findings" &&
|
||||
(loadedPageRef.current !== pageNumber ||
|
||||
loadedSortRef.current !== sort ||
|
||||
!isExpandedRef.current)
|
||||
) {
|
||||
loadedPageRef.current = pageNumber;
|
||||
loadedSortRef.current = sort;
|
||||
isExpandedRef.current = true;
|
||||
|
||||
try {
|
||||
const checkIds = requirement.check_ids;
|
||||
const encodedSort = sort.replace(/^\+/, "");
|
||||
const findingsData = await getFindings({
|
||||
filters: {
|
||||
"filter[check_id__in]": checkIds.join(","),
|
||||
"filter[scan]": scanId,
|
||||
...(region && { "filter[region__in]": region }),
|
||||
},
|
||||
page: parseInt(pageNumber, 10),
|
||||
sort: encodedSort,
|
||||
});
|
||||
|
||||
setFindings(findingsData);
|
||||
|
||||
if (findingsData?.data) {
|
||||
// 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 expandedData = findingsData.data.map(
|
||||
(finding: FindingProps) => {
|
||||
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 },
|
||||
};
|
||||
},
|
||||
);
|
||||
setExpandedFindings(expandedData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading findings:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadFindings();
|
||||
}, [requirement, scanId, pageNumber, sort, region]);
|
||||
|
||||
const checks = requirement.check_ids || [];
|
||||
const checksList = (
|
||||
<div className="mb-2 flex items-center">
|
||||
<span>{checks.join(", ")}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const accordionChecksItems = [
|
||||
{
|
||||
key: "checks",
|
||||
title: (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-primary">{checks.length}</span>
|
||||
{checks.length > 1 ? <span>Checks</span> : <span>Check</span>}
|
||||
</div>
|
||||
),
|
||||
content: checksList,
|
||||
},
|
||||
];
|
||||
|
||||
const renderFindingsTable = () => {
|
||||
if (findings === null && requirement.status !== "MANUAL") {
|
||||
return <SkeletonTableFindings />;
|
||||
}
|
||||
|
||||
if (findings?.data?.length && findings.data.length > 0) {
|
||||
return (
|
||||
<div className="p-1">
|
||||
<DataTable
|
||||
// Remove the updated_at column as compliance is for the last scan
|
||||
columns={ColumnFindings.filter(
|
||||
(_, index) => index !== 4 && index !== 7,
|
||||
)}
|
||||
data={expandedFindings || []}
|
||||
metadata={findings?.meta}
|
||||
disableScroll={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<ComplianceCustomDetails requirement={requirement} />
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{renderDetails()}
|
||||
|
||||
{checks.length > 0 && (
|
||||
<div className="mb-6 mt-2">
|
||||
<Accordion
|
||||
items={accordionChecksItems}
|
||||
variant="light"
|
||||
defaultExpandedKeys={[""]}
|
||||
className="rounded-lg bg-white dark:bg-prowler-blue-400"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderFindingsTable()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Accordion, AccordionItemProps } from "@/components/ui";
|
||||
import { CustomButton } from "@/components/ui/custom";
|
||||
|
||||
export const ClientAccordionWrapper = ({
|
||||
items,
|
||||
defaultExpandedKeys,
|
||||
}: {
|
||||
items: AccordionItemProps[];
|
||||
defaultExpandedKeys: string[];
|
||||
}) => {
|
||||
const [selectedKeys, setSelectedKeys] =
|
||||
useState<string[]>(defaultExpandedKeys);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// Function to get all keys except the last level (requirements)
|
||||
const getAllKeysExceptLastLevel = (items: AccordionItemProps[]): string[] => {
|
||||
const keys: string[] = [];
|
||||
|
||||
const traverse = (items: AccordionItemProps[], level: number = 0) => {
|
||||
items.forEach((item) => {
|
||||
// Add current item key if it's not the last level
|
||||
if (item.items && item.items.length > 0) {
|
||||
keys.push(item.key);
|
||||
// Check if the children have their own children (not the last level)
|
||||
const hasGrandChildren = item.items.some(
|
||||
(child) => child.items && child.items.length > 0,
|
||||
);
|
||||
if (hasGrandChildren) {
|
||||
traverse(item.items, level + 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
traverse(items);
|
||||
return keys;
|
||||
};
|
||||
|
||||
const handleToggleExpand = () => {
|
||||
if (isExpanded) {
|
||||
setSelectedKeys(defaultExpandedKeys);
|
||||
} else {
|
||||
const allKeys = getAllKeysExceptLastLevel(items);
|
||||
setSelectedKeys(allKeys);
|
||||
}
|
||||
setIsExpanded(!isExpanded);
|
||||
};
|
||||
|
||||
const handleSelectionChange = (keys: string[]) => {
|
||||
setSelectedKeys(keys);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<CustomButton
|
||||
variant="flat"
|
||||
size="sm"
|
||||
onPress={handleToggleExpand}
|
||||
ariaLabel={isExpanded ? "Collapse all" : "Expand all"}
|
||||
>
|
||||
{isExpanded ? "Collapse all" : "Expand all"}
|
||||
</CustomButton>
|
||||
</div>
|
||||
<Accordion
|
||||
items={items}
|
||||
variant="light"
|
||||
selectionMode="multiple"
|
||||
defaultExpandedKeys={defaultExpandedKeys}
|
||||
selectedKeys={selectedKeys}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { FindingStatus, StatusFindingBadge } from "@/components/ui/table";
|
||||
import { translateType } from "@/lib/compliance/ens";
|
||||
|
||||
interface ComplianceAccordionRequirementTitleProps {
|
||||
type: string;
|
||||
name: string;
|
||||
status: FindingStatus;
|
||||
}
|
||||
|
||||
export const ComplianceAccordionRequirementTitle = ({
|
||||
type,
|
||||
name,
|
||||
status,
|
||||
}: ComplianceAccordionRequirementTitleProps) => {
|
||||
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 font-bold capitalize">
|
||||
{translateType(type)}:
|
||||
</span>
|
||||
<span className="whitespace-nowrap text-sm uppercase">{name}</span>
|
||||
</div>
|
||||
<StatusFindingBadge status={status} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Tooltip } from "@nextui-org/react";
|
||||
|
||||
interface ComplianceAccordionTitleProps {
|
||||
label: string;
|
||||
pass: number;
|
||||
fail: number;
|
||||
manual?: number;
|
||||
}
|
||||
|
||||
export const ComplianceAccordionTitle = ({
|
||||
label,
|
||||
pass,
|
||||
fail,
|
||||
manual = 0,
|
||||
}: ComplianceAccordionTitleProps) => {
|
||||
const total = pass + fail + manual;
|
||||
const passPercentage = (pass / total) * 100;
|
||||
const failPercentage = (fail / total) * 100;
|
||||
const manualPercentage = (manual / total) * 100;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-between gap-1 md:flex-row md:items-center md:gap-2">
|
||||
<div className="overflow-hidden md:min-w-0 md:flex-1">
|
||||
<span
|
||||
className="block w-full overflow-hidden truncate text-ellipsis text-sm"
|
||||
title={label}
|
||||
>
|
||||
{label.charAt(0).toUpperCase() + label.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mr-4 flex items-center gap-2">
|
||||
<div className="hidden lg:block">
|
||||
{total > 0 && (
|
||||
<span className="whitespace-nowrap text-xs font-medium text-gray-600">
|
||||
Requirements:
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex h-1.5 w-[200px] overflow-hidden rounded-full bg-gray-100 shadow-inner">
|
||||
{total > 0 ? (
|
||||
<div className="flex w-full">
|
||||
{pass > 0 && (
|
||||
<Tooltip
|
||||
content={
|
||||
<div className="px-1 py-0.5">
|
||||
<div className="text-xs font-medium">Pass</div>
|
||||
<div className="text-tiny text-default-400">
|
||||
{pass} ({passPercentage.toFixed(1)}%)
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
size="sm"
|
||||
placement="top"
|
||||
delay={0}
|
||||
closeDelay={0}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-[#3CEC6D] transition-all duration-200 hover:brightness-110"
|
||||
style={{
|
||||
width: `${passPercentage}%`,
|
||||
marginRight: pass > 0 ? "2px" : "0",
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{fail > 0 && (
|
||||
<Tooltip
|
||||
content={
|
||||
<div className="px-1 py-0.5">
|
||||
<div className="text-xs font-medium">Fail</div>
|
||||
<div className="text-tiny text-default-400">
|
||||
{fail} ({failPercentage.toFixed(1)}%)
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
size="sm"
|
||||
placement="top"
|
||||
delay={0}
|
||||
closeDelay={0}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-[#FB718F] transition-all duration-200 hover:brightness-110"
|
||||
style={{
|
||||
width: `${failPercentage}%`,
|
||||
marginRight: manual > 0 ? "2px" : "0",
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{manual > 0 && (
|
||||
<Tooltip
|
||||
content={
|
||||
<div className="px-1 py-0.5">
|
||||
<div className="text-xs font-medium">Manual</div>
|
||||
<div className="text-tiny text-default-400">
|
||||
{manual} ({manualPercentage.toFixed(1)}%)
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
size="sm"
|
||||
placement="top"
|
||||
delay={0}
|
||||
closeDelay={0}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-[#868994] transition-all duration-200 hover:brightness-110"
|
||||
style={{ width: `${manualPercentage}%` }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full w-full bg-gray-200" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tooltip
|
||||
content={
|
||||
<div className="px-1 py-0.5">
|
||||
<div className="text-xs font-medium">Total requirements</div>
|
||||
<div className="text-tiny text-default-400">{total}</div>
|
||||
</div>
|
||||
}
|
||||
size="sm"
|
||||
placement="top"
|
||||
>
|
||||
<div className="min-w-[32px] text-center text-xs font-medium text-default-600">
|
||||
{total > 0 ? total : "—"}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Card, CardBody, Progress } from "@nextui-org/react";
|
||||
import Image from "next/image";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { DownloadIconButton, toast } from "@/components/ui";
|
||||
@@ -19,6 +19,7 @@ interface ComplianceCardProps {
|
||||
prevTotalRequirements: number;
|
||||
scanId: string;
|
||||
complianceId: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const ComplianceCard: React.FC<ComplianceCardProps> = ({
|
||||
@@ -28,8 +29,10 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
|
||||
totalRequirements,
|
||||
scanId,
|
||||
complianceId,
|
||||
id,
|
||||
}) => {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const hasRegionFilter = searchParams.has("filter[region__in]");
|
||||
const [isDownloading, setIsDownloading] = useState<boolean>(false);
|
||||
|
||||
@@ -68,6 +71,22 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
|
||||
return "success";
|
||||
};
|
||||
|
||||
const navigateToDetail = () => {
|
||||
// We will unlock this while developing the rest of complainces.
|
||||
if (!id.includes("ens") && !id.includes("cis")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formattedTitleForUrl = encodeURIComponent(title);
|
||||
const path = `/compliance/${formattedTitleForUrl}`;
|
||||
const params = new URLSearchParams();
|
||||
|
||||
params.set("complianceId", id);
|
||||
params.set("version", version);
|
||||
params.set("scanId", scanId);
|
||||
|
||||
router.push(`${path}?${params.toString()}`);
|
||||
};
|
||||
const handleDownload = async () => {
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
@@ -78,7 +97,13 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Card fullWidth isHoverable shadow="sm">
|
||||
<Card
|
||||
fullWidth
|
||||
isHoverable
|
||||
shadow="sm"
|
||||
isPressable
|
||||
onPress={navigateToDetail}
|
||||
>
|
||||
<CardBody className="flex flex-row items-center justify-between space-x-4 dark:bg-prowler-blue-800">
|
||||
<div className="flex w-full items-center space-x-4">
|
||||
<Image
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Requirement } from "@/types/compliance";
|
||||
|
||||
export const ComplianceCustomDetails = ({
|
||||
requirement,
|
||||
}: {
|
||||
requirement: Requirement;
|
||||
}) => {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<div className="mb-2 text-sm text-gray-600">
|
||||
{requirement.description}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">Level:</span>
|
||||
<span className="capitalize">{requirement.nivel}</span>
|
||||
</div>
|
||||
{requirement.dimensiones && requirement.dimensiones.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">Dimensions:</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{requirement.dimensiones.map(
|
||||
(dimension: string, index: number) => (
|
||||
<span
|
||||
key={index}
|
||||
className="rounded-full bg-gray-100 px-2 py-0.5 text-xs capitalize dark:bg-prowler-blue-400"
|
||||
>
|
||||
{dimension}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { Spacer } from "@nextui-org/react";
|
||||
|
||||
import { FilterControls } from "@/components/filters";
|
||||
import { DataTableFilterCustom } from "@/components/ui/table/data-table-filter-custom";
|
||||
|
||||
import { DataCompliance } from "./data-compliance";
|
||||
import { SelectScanComplianceDataProps } from "./select-scan-compliance-data";
|
||||
|
||||
interface ComplianceHeaderProps {
|
||||
scans: SelectScanComplianceDataProps["scans"];
|
||||
uniqueRegions: string[];
|
||||
showSearch?: boolean;
|
||||
showRegionFilter?: boolean;
|
||||
}
|
||||
|
||||
export const ComplianceHeader = ({
|
||||
scans,
|
||||
uniqueRegions,
|
||||
showSearch = true,
|
||||
showRegionFilter = true,
|
||||
}: ComplianceHeaderProps) => {
|
||||
return (
|
||||
<>
|
||||
{showSearch && <FilterControls search />}
|
||||
<Spacer y={8} />
|
||||
<DataCompliance scans={scans} />
|
||||
{showRegionFilter && (
|
||||
<>
|
||||
<Spacer y={8} />
|
||||
<DataTableFilterCustom
|
||||
filters={[
|
||||
{
|
||||
key: "region__in",
|
||||
labelCheckboxGroup: "Regions",
|
||||
values: uniqueRegions,
|
||||
},
|
||||
]}
|
||||
defaultOpen={true}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Spacer y={12} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import React from "react";
|
||||
|
||||
import { DateWithTime, EntityInfoShort } from "@/components/ui/entities";
|
||||
import { ProviderType } from "@/types";
|
||||
|
||||
interface ComplianceScanInfoProps {
|
||||
scan: {
|
||||
providerInfo: {
|
||||
@@ -3,8 +3,10 @@
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { SelectScanComplianceData } from "@/components/compliance/data-compliance";
|
||||
import { SelectScanComplianceDataProps } from "@/types";
|
||||
import {
|
||||
SelectScanComplianceData,
|
||||
SelectScanComplianceDataProps,
|
||||
} from "@/components/compliance/compliance-header/index";
|
||||
interface DataComplianceProps {
|
||||
scans: SelectScanComplianceDataProps["scans"];
|
||||
}
|
||||
@@ -1,8 +1,20 @@
|
||||
import { Select, SelectItem } from "@nextui-org/react";
|
||||
|
||||
import { SelectScanComplianceDataProps } from "@/types";
|
||||
import { ProviderType, ScanProps } from "@/types";
|
||||
|
||||
import { ComplianceScanInfo } from "../compliance-scan-info";
|
||||
import { ComplianceScanInfo } from "./compliance-scan-info";
|
||||
|
||||
export interface SelectScanComplianceDataProps {
|
||||
scans: (ScanProps & {
|
||||
providerInfo: {
|
||||
provider: ProviderType;
|
||||
uid: string;
|
||||
alias: string;
|
||||
};
|
||||
})[];
|
||||
selectedScanId: string;
|
||||
onSelectionChange: (selectedKey: string) => void;
|
||||
}
|
||||
|
||||
export const SelectScanComplianceData = ({
|
||||
scans,
|
||||
30
ui/components/compliance/compliance-skeleton-accordion.tsx
Normal file
30
ui/components/compliance/compliance-skeleton-accordion.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Skeleton } from "@nextui-org/react";
|
||||
import React from "react";
|
||||
|
||||
interface SkeletonAccordionProps {
|
||||
itemCount?: number;
|
||||
className?: string;
|
||||
isCompact?: boolean;
|
||||
}
|
||||
|
||||
export const SkeletonAccordion = ({
|
||||
itemCount = 3,
|
||||
className = "",
|
||||
isCompact = false,
|
||||
}: SkeletonAccordionProps) => {
|
||||
const itemHeight = isCompact ? "h-10" : "h-14";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full space-y-2 ${className} rounded-xl border border-gray-300 p-2 dark:border-gray-700`}
|
||||
>
|
||||
{[...Array(itemCount)].map((_, index) => (
|
||||
<Skeleton key={index} className="rounded-lg">
|
||||
<div className={`${itemHeight} bg-default-300`}></div>
|
||||
</Skeleton>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SkeletonAccordion.displayName = "SkeletonAccordion";
|
||||
53
ui/components/compliance/failed-sections-chart-skeleton.tsx
Normal file
53
ui/components/compliance/failed-sections-chart-skeleton.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { Skeleton } from "@nextui-org/react";
|
||||
|
||||
export const FailedSectionsChartSkeleton = () => {
|
||||
return (
|
||||
<div className="flex w-[400px] flex-col items-center justify-between lg:w-[600px]">
|
||||
{/* Title skeleton */}
|
||||
<Skeleton className="h-4 w-40 rounded-lg">
|
||||
<div className="h-4 bg-default-200" />
|
||||
</Skeleton>
|
||||
|
||||
{/* Chart area skeleton */}
|
||||
<div className="ml-24 flex h-full flex-col justify-center space-y-2 p-4">
|
||||
{/* Bar chart skeleton - 5 horizontal bars */}
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={index} className="flex items-center space-x-4">
|
||||
{/* Bar skeleton with varying widths */}
|
||||
<Skeleton
|
||||
className={`h-10 rounded-lg ${
|
||||
index === 0
|
||||
? "w-48"
|
||||
: index === 1
|
||||
? "w-40"
|
||||
: index === 2
|
||||
? "w-32"
|
||||
: index === 3
|
||||
? "w-24"
|
||||
: "w-16"
|
||||
}`}
|
||||
>
|
||||
<div className="h-6 bg-default-200" />
|
||||
</Skeleton>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Legend skeleton */}
|
||||
<div className="flex justify-center space-x-4 pt-2">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="flex items-center space-x-1">
|
||||
<Skeleton className="h-3 w-3 rounded-full">
|
||||
<div className="h-3 w-3 bg-default-200" />
|
||||
</Skeleton>
|
||||
<Skeleton className="h-3 w-16 rounded-lg">
|
||||
<div className="h-3 bg-default-200" />
|
||||
</Skeleton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
150
ui/components/compliance/failed-sections-chart.tsx
Normal file
150
ui/components/compliance/failed-sections-chart.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { translateType } from "@/lib/compliance/ens";
|
||||
|
||||
type FailedSectionItem = {
|
||||
name: string;
|
||||
total: number;
|
||||
types: {
|
||||
[key: string]: number;
|
||||
};
|
||||
};
|
||||
|
||||
interface FailedSectionsListProps {
|
||||
sections: FailedSectionItem[];
|
||||
}
|
||||
|
||||
const title = (
|
||||
<h3 className="whitespace-nowrap text-xs font-semibold uppercase tracking-wide">
|
||||
Failed Sections (Top 5)
|
||||
</h3>
|
||||
);
|
||||
|
||||
export const FailedSectionsChart = ({ sections }: FailedSectionsListProps) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
switch (type.toLowerCase()) {
|
||||
case "requisito":
|
||||
return "#ff5356";
|
||||
case "recomendacion":
|
||||
return "#FDC53A"; // Increased contrast from #FDDD8A
|
||||
case "refuerzo":
|
||||
return "#7FB5FF"; // Increased contrast from #B5D7FF
|
||||
default:
|
||||
return "#868994";
|
||||
}
|
||||
};
|
||||
|
||||
const chartData = [...sections]
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, 5)
|
||||
.map((section) => ({
|
||||
name: section.name.charAt(0).toUpperCase() + section.name.slice(1),
|
||||
...section.types,
|
||||
}));
|
||||
|
||||
const allTypes = Array.from(
|
||||
new Set(sections.flatMap((section) => Object.keys(section.types))),
|
||||
);
|
||||
|
||||
// Check if there are no failed sections
|
||||
if (!sections || sections.length === 0) {
|
||||
return (
|
||||
<div className="flex w-[400px] flex-col items-center justify-between lg:w-[600px]">
|
||||
{title}
|
||||
<div className="flex h-[320px] w-full items-center justify-center">
|
||||
<p className="text-sm text-gray-500">There are no failed sections</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-[400px] flex-col items-center justify-between lg:w-[600px]">
|
||||
{title}
|
||||
|
||||
<div className="h-[320px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={chartData}
|
||||
layout="vertical"
|
||||
margin={{ top: 5, right: 30, left: 40, bottom: 5 }}
|
||||
maxBarSize={40}
|
||||
>
|
||||
<XAxis
|
||||
type="number"
|
||||
fontSize={12}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{
|
||||
fontSize: 12,
|
||||
fill: theme === "dark" ? "#94a3b8" : "#374151",
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={100}
|
||||
tick={{
|
||||
fontSize: 12,
|
||||
fill: theme === "dark" ? "#94a3b8" : "#374151",
|
||||
}}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: theme === "dark" ? "#1e293b" : "white",
|
||||
border: `1px solid ${theme === "dark" ? "#475569" : "rgba(0, 0, 0, 0.1)"}`,
|
||||
borderRadius: "6px",
|
||||
boxShadow: "0px 4px 12px rgba(0, 0, 0, 0.15)",
|
||||
fontSize: "12px",
|
||||
padding: "8px 12px",
|
||||
color: theme === "dark" ? "white" : "black",
|
||||
}}
|
||||
formatter={(value: number, name: string) => [
|
||||
value,
|
||||
translateType(name),
|
||||
]}
|
||||
cursor={false}
|
||||
/>
|
||||
<Legend
|
||||
formatter={(value) => translateType(value)}
|
||||
wrapperStyle={{
|
||||
fontSize: "10px",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
}}
|
||||
iconType="circle"
|
||||
layout="horizontal"
|
||||
verticalAlign="bottom"
|
||||
height={36}
|
||||
/>
|
||||
{allTypes.map((type, i) => (
|
||||
<Bar
|
||||
key={type}
|
||||
dataKey={type}
|
||||
stackId="a"
|
||||
fill={getTypeColor(type)}
|
||||
radius={i === allTypes.length - 1 ? [0, 4, 4, 0] : [0, 0, 0, 0]}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from "./compliance-card";
|
||||
export * from "./compliance-scan-info";
|
||||
export * from "./compliance-header/compliance-scan-info";
|
||||
export * from "./compliance-skeleton-grid";
|
||||
export * from "./no-scans-available";
|
||||
|
||||
63
ui/components/compliance/requirements-chart-skeleton.tsx
Normal file
63
ui/components/compliance/requirements-chart-skeleton.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { Skeleton } from "@nextui-org/react";
|
||||
|
||||
export const RequirementsChartSkeleton = () => {
|
||||
return (
|
||||
<div className="flex h-[320px] flex-col items-center justify-between">
|
||||
{/* Title skeleton */}
|
||||
<Skeleton className="h-4 w-32 rounded-lg">
|
||||
<div className="h-4 bg-default-200" />
|
||||
</Skeleton>
|
||||
|
||||
{/* Pie chart skeleton */}
|
||||
<div className="relative flex aspect-square w-[200px] min-w-[200px] items-center justify-center">
|
||||
{/* Outer circle */}
|
||||
<Skeleton className="absolute h-[200px] w-[200px] rounded-full">
|
||||
<div className="h-[200px] w-[200px] bg-default-200" />
|
||||
</Skeleton>
|
||||
|
||||
{/* Inner circle (donut hole) */}
|
||||
<div className="absolute h-[140px] w-[140px] rounded-full bg-background"></div>
|
||||
|
||||
{/* Center text skeleton */}
|
||||
<div className="absolute flex flex-col items-center">
|
||||
<Skeleton className="h-6 w-8 rounded-lg">
|
||||
<div className="h-6 bg-default-300" />
|
||||
</Skeleton>
|
||||
<Skeleton className="mt-1 h-3 w-6 rounded-lg">
|
||||
<div className="h-3 bg-default-300" />
|
||||
</Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom stats skeleton */}
|
||||
<div className="mt-2 grid grid-cols-3 gap-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<Skeleton className="h-4 w-8 rounded-lg">
|
||||
<div className="h-4 bg-default-200" />
|
||||
</Skeleton>
|
||||
<Skeleton className="mt-1 h-5 w-6 rounded-lg">
|
||||
<div className="h-5 bg-default-200" />
|
||||
</Skeleton>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<Skeleton className="h-4 w-6 rounded-lg">
|
||||
<div className="h-4 bg-default-200" />
|
||||
</Skeleton>
|
||||
<Skeleton className="mt-1 h-5 w-6 rounded-lg">
|
||||
<div className="h-5 bg-default-200" />
|
||||
</Skeleton>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<Skeleton className="h-4 w-12 rounded-lg">
|
||||
<div className="h-4 bg-default-200" />
|
||||
</Skeleton>
|
||||
<Skeleton className="mt-1 h-5 w-6 rounded-lg">
|
||||
<div className="h-5 bg-default-200" />
|
||||
</Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
190
ui/components/compliance/requirements-chart.tsx
Normal file
190
ui/components/compliance/requirements-chart.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { Cell, Label, Pie, PieChart, Tooltip } from "recharts";
|
||||
|
||||
import { ChartConfig, ChartContainer } from "@/components/ui/chart/Chart";
|
||||
|
||||
interface RequirementsChartProps {
|
||||
pass: number;
|
||||
fail: number;
|
||||
manual: number;
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
number: {
|
||||
label: "Requirements",
|
||||
},
|
||||
pass: {
|
||||
label: "Pass",
|
||||
color: "hsl(var(--chart-success))",
|
||||
},
|
||||
fail: {
|
||||
label: "Fail",
|
||||
color: "hsl(var(--chart-fail))",
|
||||
},
|
||||
manual: {
|
||||
label: "Manual",
|
||||
color: "hsl(var(--chart-warning))",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export const RequirementsChart = ({
|
||||
pass,
|
||||
fail,
|
||||
manual,
|
||||
}: RequirementsChartProps) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const chartData = [
|
||||
{
|
||||
name: "Pass",
|
||||
value: pass,
|
||||
fill: "#3CEC6D",
|
||||
},
|
||||
{
|
||||
name: "Fail",
|
||||
value: fail,
|
||||
fill: "#FB718F",
|
||||
},
|
||||
{
|
||||
name: "Manual",
|
||||
value: manual,
|
||||
fill: "#868994",
|
||||
},
|
||||
];
|
||||
|
||||
const totalRequirements = pass + fail + manual;
|
||||
|
||||
const emptyChartData = [
|
||||
{
|
||||
name: "Empty",
|
||||
value: 1,
|
||||
fill: "#64748b",
|
||||
},
|
||||
];
|
||||
|
||||
interface CustomTooltipProps {
|
||||
active: boolean;
|
||||
payload: {
|
||||
payload: {
|
||||
name: string;
|
||||
value: number;
|
||||
fill: string;
|
||||
};
|
||||
}[];
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0];
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: theme === "dark" ? "#1e293b" : "white",
|
||||
border: `1px solid ${theme === "dark" ? "#475569" : "rgba(0, 0, 0, 0.1)"}`,
|
||||
borderRadius: "6px",
|
||||
boxShadow: "0px 4px 12px rgba(0, 0, 0, 0.15)",
|
||||
fontSize: "12px",
|
||||
padding: "8px 12px",
|
||||
color: theme === "dark" ? "white" : "black",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<div
|
||||
style={{
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: data.payload.fill,
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
{data.payload.name}: {data.payload.value}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-[320px] flex-col items-center justify-between">
|
||||
<h3 className="whitespace-nowrap text-xs font-semibold uppercase tracking-wide">
|
||||
Requirements Status
|
||||
</h3>
|
||||
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-square w-[200px] min-w-[200px]"
|
||||
>
|
||||
<PieChart>
|
||||
<Tooltip
|
||||
cursor={false}
|
||||
content={<CustomTooltip active={false} payload={[]} />}
|
||||
/>
|
||||
<Pie
|
||||
data={totalRequirements > 0 ? chartData : emptyChartData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
innerRadius={70}
|
||||
outerRadius={100}
|
||||
paddingAngle={2}
|
||||
cornerRadius={4}
|
||||
>
|
||||
{(totalRequirements > 0 ? chartData : emptyChartData).map(
|
||||
(entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.fill} />
|
||||
),
|
||||
)}
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
|
||||
return (
|
||||
<text
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
className="fill-foreground text-xl font-bold"
|
||||
>
|
||||
{totalRequirements}
|
||||
</tspan>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={(viewBox.cy || 0) + 20}
|
||||
className="fill-foreground text-xs"
|
||||
>
|
||||
Total
|
||||
</tspan>
|
||||
</text>
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
|
||||
<div className="mt-2 grid grid-cols-3 gap-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-muted-foreground text-sm">Pass</div>
|
||||
<div className="font-semibold text-system-success-medium">{pass}</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-muted-foreground text-sm">Fail</div>
|
||||
<div className="font-semibold text-system-error-medium">{fail}</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-muted-foreground text-sm">Manual</div>
|
||||
<div className="font-semibold text-prowler-grey-light">{manual}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,65 +1,11 @@
|
||||
import { Card, Skeleton } from "@nextui-org/react";
|
||||
import React from "react";
|
||||
|
||||
import { SkeletonTable } from "../../ui/skeleton/skeleton";
|
||||
|
||||
export const SkeletonTableFindings = () => {
|
||||
return (
|
||||
<Card className="h-full w-full space-y-5 p-4" radius="sm">
|
||||
{/* Table headers */}
|
||||
<div className="hidden justify-between md:flex">
|
||||
<Skeleton className="w-1/12 rounded-lg">
|
||||
<div className="h-8 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="w-2/12 rounded-lg">
|
||||
<div className="h-8 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="w-2/12 rounded-lg">
|
||||
<div className="h-8 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="w-2/12 rounded-lg">
|
||||
<div className="h-8 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="w-2/12 rounded-lg">
|
||||
<div className="h-8 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="w-1/12 rounded-lg">
|
||||
<div className="h-8 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="w-1/12 rounded-lg">
|
||||
<div className="h-8 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
<div className="bg-card rounded-xl border p-4 shadow-sm">
|
||||
<SkeletonTable rows={4} columns={7} />
|
||||
</div>
|
||||
|
||||
{/* Table body */}
|
||||
<div className="space-y-3">
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col items-center justify-between space-x-0 md:flex-row md:space-x-4"
|
||||
>
|
||||
<Skeleton className="mb-2 w-full rounded-lg md:mb-0 md:w-1/12">
|
||||
<div className="h-12 bg-default-300"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="mb-2 w-full rounded-lg md:mb-0 md:w-2/12">
|
||||
<div className="h-12 bg-default-300"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-2/12">
|
||||
<div className="h-12 bg-default-300"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-2/12">
|
||||
<div className="h-12 bg-default-300"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-2/12">
|
||||
<div className="h-12 bg-default-300"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-1/12">
|
||||
<div className="h-12 bg-default-300"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-1/12">
|
||||
<div className="h-12 bg-default-300"></div>
|
||||
</Skeleton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -116,9 +116,9 @@ export const FindingsBySeverityChart = ({
|
||||
>
|
||||
<LabelList
|
||||
position="insideRight"
|
||||
offset={10}
|
||||
offset={5}
|
||||
className="fill-foreground font-bold"
|
||||
fontSize={12}
|
||||
fontSize={11}
|
||||
/>
|
||||
</Bar>
|
||||
</BarChart>
|
||||
|
||||
@@ -146,9 +146,9 @@ export const FindingsByStatusChart: React.FC<FindingsByStatusChartProps> = ({
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
|
||||
<div className="grid w-full grid-cols-2 justify-items-center gap-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center space-x-2 self-end">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Link
|
||||
href="/findings?filter[status]=PASS"
|
||||
className="flex items-center space-x-2"
|
||||
|
||||
@@ -1,65 +1,11 @@
|
||||
import { Card, Skeleton } from "@nextui-org/react";
|
||||
import React from "react";
|
||||
|
||||
import { SkeletonTable } from "@/components/ui/skeleton/skeleton";
|
||||
|
||||
export const SkeletonTableNewFindings = () => {
|
||||
return (
|
||||
<Card className="h-full w-full space-y-5 p-4" radius="sm">
|
||||
{/* Table headers */}
|
||||
<div className="hidden justify-between md:flex">
|
||||
<Skeleton className="w-1/12 rounded-lg">
|
||||
<div className="h-8 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="w-2/12 rounded-lg">
|
||||
<div className="h-8 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="w-2/12 rounded-lg">
|
||||
<div className="h-8 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="w-2/12 rounded-lg">
|
||||
<div className="h-8 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="w-2/12 rounded-lg">
|
||||
<div className="h-8 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="w-1/12 rounded-lg">
|
||||
<div className="h-8 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="w-1/12 rounded-lg">
|
||||
<div className="h-8 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
<div className="bg-card rounded-xl border p-4 shadow-sm">
|
||||
<SkeletonTable rows={3} columns={7} />
|
||||
</div>
|
||||
|
||||
{/* Table body */}
|
||||
<div className="space-y-3">
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col items-center justify-between space-x-0 md:flex-row md:space-x-4"
|
||||
>
|
||||
<Skeleton className="mb-2 w-full rounded-lg md:mb-0 md:w-1/12">
|
||||
<div className="h-12 bg-default-300"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="mb-2 w-full rounded-lg md:mb-0 md:w-2/12">
|
||||
<div className="h-12 bg-default-300"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-2/12">
|
||||
<div className="h-12 bg-default-300"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-2/12">
|
||||
<div className="h-12 bg-default-300"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-2/12">
|
||||
<div className="h-12 bg-default-300"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-1/12">
|
||||
<div className="h-12 bg-default-300"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-1/12">
|
||||
<div className="h-12 bg-default-300"></div>
|
||||
</Skeleton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Selection,
|
||||
} from "@nextui-org/react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import React, { ReactNode, useCallback, useState } from "react";
|
||||
import React, { ReactNode, useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -24,17 +24,24 @@ export interface AccordionProps {
|
||||
variant?: "light" | "shadow" | "bordered" | "splitted";
|
||||
className?: string;
|
||||
defaultExpandedKeys?: string[];
|
||||
selectedKeys?: string[];
|
||||
selectionMode?: "single" | "multiple";
|
||||
isCompact?: boolean;
|
||||
showDivider?: boolean;
|
||||
onItemExpand?: (key: string) => void;
|
||||
onSelectionChange?: (keys: string[]) => void;
|
||||
}
|
||||
|
||||
const AccordionContent = ({
|
||||
content,
|
||||
items,
|
||||
selectedKeys,
|
||||
onSelectionChange,
|
||||
}: {
|
||||
content: ReactNode;
|
||||
items?: AccordionItemProps[];
|
||||
selectedKeys?: string[];
|
||||
onSelectionChange?: (keys: string[]) => void;
|
||||
}) => {
|
||||
return (
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
@@ -46,6 +53,8 @@ const AccordionContent = ({
|
||||
variant="light"
|
||||
isCompact
|
||||
selectionMode="multiple"
|
||||
selectedKeys={selectedKeys}
|
||||
onSelectionChange={onSelectionChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -58,17 +67,54 @@ export const Accordion = ({
|
||||
variant = "light",
|
||||
className,
|
||||
defaultExpandedKeys = [],
|
||||
selectedKeys,
|
||||
selectionMode = "single",
|
||||
isCompact = false,
|
||||
showDivider = true,
|
||||
onItemExpand,
|
||||
onSelectionChange,
|
||||
}: AccordionProps) => {
|
||||
const [expandedKeys, setExpandedKeys] = useState<Selection>(
|
||||
// Determine if component is in controlled or uncontrolled mode
|
||||
const isControlled = selectedKeys !== undefined;
|
||||
|
||||
const [internalExpandedKeys, setInternalExpandedKeys] = useState<Selection>(
|
||||
new Set(defaultExpandedKeys),
|
||||
);
|
||||
|
||||
const handleSelectionChange = useCallback((keys: Selection) => {
|
||||
setExpandedKeys(keys);
|
||||
}, []);
|
||||
// Use selectedKeys if controlled, otherwise use internal state
|
||||
const expandedKeys = useMemo(
|
||||
() => (isControlled ? new Set(selectedKeys) : internalExpandedKeys),
|
||||
[isControlled, selectedKeys, internalExpandedKeys],
|
||||
);
|
||||
|
||||
const handleSelectionChange = useCallback(
|
||||
(keys: Selection) => {
|
||||
const keysArray = Array.from(keys as Set<string>);
|
||||
|
||||
// If controlled mode, call parent callback
|
||||
if (isControlled && onSelectionChange) {
|
||||
onSelectionChange(keysArray);
|
||||
} else {
|
||||
// If uncontrolled, update internal state
|
||||
setInternalExpandedKeys(keys);
|
||||
}
|
||||
|
||||
// Handle onItemExpand for backward compatibility
|
||||
if (onItemExpand && keys !== expandedKeys) {
|
||||
const currentKeys = Array.from(expandedKeys as Set<string>);
|
||||
const newKeys = keysArray;
|
||||
|
||||
const newlyExpandedKeys = newKeys.filter(
|
||||
(key) => !currentKeys.includes(key),
|
||||
);
|
||||
|
||||
newlyExpandedKeys.forEach((key) => {
|
||||
onItemExpand(key);
|
||||
});
|
||||
}
|
||||
},
|
||||
[expandedKeys, onItemExpand, isControlled, onSelectionChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<NextUIAccordion
|
||||
@@ -92,14 +138,19 @@ export const Accordion = ({
|
||||
indicator={<ChevronDown className="text-gray-500" />}
|
||||
classNames={{
|
||||
base: index === 0 || index === 1 ? "my-2" : "my-1",
|
||||
title: "text-sm font-medium",
|
||||
title: "text-sm font-medium max-w-full overflow-hidden truncate",
|
||||
subtitle: "text-xs text-gray-500",
|
||||
trigger:
|
||||
"p-2 rounded-lg data-[hover=true]:bg-gray-50 dark:data-[hover=true]:bg-gray-800/50",
|
||||
"p-2 rounded-lg data-[hover=true]:bg-gray-50 dark:data-[hover=true]:bg-gray-800/50 w-full flex items-center",
|
||||
content: "p-2",
|
||||
}}
|
||||
>
|
||||
<AccordionContent content={item.content} items={item.items} />
|
||||
<AccordionContent
|
||||
content={item.content}
|
||||
items={item.items}
|
||||
selectedKeys={selectedKeys}
|
||||
onSelectionChange={onSelectionChange}
|
||||
/>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</NextUIAccordion>
|
||||
|
||||
@@ -70,6 +70,16 @@ interface HorizontalSplitBarProps {
|
||||
* @default "text-gray-700"
|
||||
*/
|
||||
labelColor?: string;
|
||||
/**
|
||||
* Growth ratio multiplier (pixels per value unit)
|
||||
* @default 1
|
||||
*/
|
||||
ratio?: number;
|
||||
/**
|
||||
* Show zero values in labels
|
||||
* @default true
|
||||
*/
|
||||
showZero?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,6 +109,8 @@ export const HorizontalSplitBar = ({
|
||||
tooltipContentA,
|
||||
tooltipContentB,
|
||||
labelColor = "text-gray-700",
|
||||
ratio = 1,
|
||||
showZero = true,
|
||||
}: HorizontalSplitBarProps) => {
|
||||
// Reference to the container to measure its width
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -150,8 +162,9 @@ export const HorizontalSplitBar = ({
|
||||
const halfWidth = availableWidth / 2;
|
||||
const separatorWidth = 1;
|
||||
|
||||
let rawWidthA = valA;
|
||||
let rawWidthB = valB;
|
||||
// Apply ratio multiplier to raw widths
|
||||
let rawWidthA = valA * ratio;
|
||||
let rawWidthB = valB * ratio;
|
||||
|
||||
// Determine if we need to scale to fit in available space
|
||||
const maxSideWidth = halfWidth - separatorWidth / 2;
|
||||
@@ -183,7 +196,7 @@ export const HorizontalSplitBar = ({
|
||||
className={cn("text-xs font-medium", labelColor)}
|
||||
aria-label={`${formattedValueA} ${tooltipContentA ? tooltipContentA : ""}`}
|
||||
>
|
||||
{valA > 0 ? formattedValueA : "0"}
|
||||
{valA > 0 ? formattedValueA : showZero ? "0" : ""}
|
||||
</div>
|
||||
{/* Left bar */}
|
||||
{valA > 0 && (
|
||||
@@ -230,7 +243,7 @@ export const HorizontalSplitBar = ({
|
||||
className={cn("text-xs font-medium", labelColor)}
|
||||
aria-label={`${formattedValueB} ${tooltipContentB ? tooltipContentB : ""}`}
|
||||
>
|
||||
{valB > 0 ? formattedValueB : "0"}
|
||||
{valB > 0 ? formattedValueB : showZero ? "0" : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
123
ui/components/ui/skeleton/skeleton.tsx
Normal file
123
ui/components/ui/skeleton/skeleton.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SkeletonProps {
|
||||
className?: string;
|
||||
variant?: "default" | "card" | "table" | "text" | "circle" | "rectangular";
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
animate?: boolean;
|
||||
}
|
||||
|
||||
export function Skeleton({
|
||||
className,
|
||||
variant = "default",
|
||||
width,
|
||||
height,
|
||||
animate = true,
|
||||
}: SkeletonProps) {
|
||||
const variantClasses = {
|
||||
default: "w-full h-4 rounded-lg",
|
||||
card: "w-full h-40 rounded-xl",
|
||||
table: "w-full h-60 rounded-lg",
|
||||
text: "w-24 h-4 rounded-full",
|
||||
circle: "rounded-full w-8 h-8",
|
||||
rectangular: "rounded-md",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: width
|
||||
? typeof width === "number"
|
||||
? `${width}px`
|
||||
: width
|
||||
: undefined,
|
||||
height: height
|
||||
? typeof height === "number"
|
||||
? `${height}px`
|
||||
: height
|
||||
: undefined,
|
||||
}}
|
||||
className={cn(
|
||||
"animate-pulse bg-gray-200 dark:bg-prowler-blue-800",
|
||||
variantClasses[variant],
|
||||
!animate && "animate-none",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonTable({
|
||||
rows = 5,
|
||||
columns = 4,
|
||||
className,
|
||||
roundedCells = true,
|
||||
}: {
|
||||
rows?: number;
|
||||
columns?: number;
|
||||
className?: string;
|
||||
roundedCells?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("w-full space-y-4", className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center space-x-4 pb-4">
|
||||
{Array.from({ length: columns }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={`header-${index}`}
|
||||
className={cn("h-8", roundedCells && "rounded-lg")}
|
||||
width={`${100 / columns}%`}
|
||||
variant={roundedCells ? "default" : "rectangular"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
{Array.from({ length: rows }).map((_, rowIndex) => (
|
||||
<div
|
||||
key={`row-${rowIndex}`}
|
||||
className="flex items-center space-x-4 py-3"
|
||||
>
|
||||
{Array.from({ length: columns }).map((_, colIndex) => (
|
||||
<Skeleton
|
||||
key={`cell-${rowIndex}-${colIndex}`}
|
||||
className={cn("h-6", roundedCells && "rounded-lg")}
|
||||
width={`${100 / columns}%`}
|
||||
variant={roundedCells ? "default" : "rectangular"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonCard({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
<Skeleton variant="card" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonText({
|
||||
lines = 3,
|
||||
className,
|
||||
lastLineWidth = "w-1/2",
|
||||
}: {
|
||||
lines?: number;
|
||||
className?: string;
|
||||
lastLineWidth?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
{Array.from({ length: lines - 1 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-4 w-full" variant="text" />
|
||||
))}
|
||||
<Skeleton className={cn("h-4", lastLineWidth)} variant="text" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export const DataTableFilterCustom = ({
|
||||
size="md"
|
||||
startContent={<CustomFilterIcon size={16} />}
|
||||
onPress={() => setShowFilters(!showFilters)}
|
||||
className="w-fit"
|
||||
className="w-full max-w-fit"
|
||||
>
|
||||
<h3 className="text-small">
|
||||
{showFilters ? "Hide Filters" : "Show Filters"}
|
||||
|
||||
@@ -23,9 +23,19 @@ import {
|
||||
|
||||
interface DataTablePaginationProps {
|
||||
metadata?: MetaDataProps;
|
||||
disableScroll?: boolean;
|
||||
}
|
||||
|
||||
export function DataTablePagination({ metadata }: DataTablePaginationProps) {
|
||||
const baseLinkClass =
|
||||
"relative block rounded border-0 bg-transparent px-3 py-1.5 text-gray-800 outline-none transition-all duration-300 hover:bg-gray-200 hover:text-gray-800 focus:shadow-none dark:text-prowler-theme-green";
|
||||
|
||||
const disabledLinkClass =
|
||||
"text-gray-300 dark:text-gray-600 hover:bg-transparent hover:text-gray-300 dark:hover:text-gray-600 cursor-default pointer-events-none";
|
||||
|
||||
export function DataTablePagination({
|
||||
metadata,
|
||||
disableScroll = false,
|
||||
}: DataTablePaginationProps) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
@@ -41,36 +51,67 @@ export function DataTablePagination({ metadata }: DataTablePaginationProps) {
|
||||
const createPageUrl = (pageNumber: number | string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
if (pageNumber === "...") return `${pathname}?${params.toString()}`;
|
||||
// Preserve all important parameters
|
||||
const scanId = searchParams.get("scanId");
|
||||
const id = searchParams.get("id");
|
||||
const version = searchParams.get("version");
|
||||
|
||||
if (+pageNumber > totalPages) {
|
||||
return `${pathname}?${params.toString()}`;
|
||||
}
|
||||
|
||||
params.set("page", pageNumber.toString());
|
||||
|
||||
// Ensure that scanId, id and version are preserved
|
||||
if (scanId) params.set("scanId", scanId);
|
||||
if (id) params.set("id", id);
|
||||
if (version) params.set("version", version);
|
||||
|
||||
return `${pathname}?${params.toString()}`;
|
||||
};
|
||||
|
||||
const isFirstPage = currentPage === 1;
|
||||
const isLastPage = currentPage === totalPages;
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col-reverse items-center justify-between gap-4 overflow-auto p-1 sm:flex-row sm:gap-8">
|
||||
<div className="whitespace-nowrap text-sm font-medium">
|
||||
{totalEntries} entries in Total.
|
||||
<div className="whitespace-nowrap text-sm">
|
||||
{totalEntries} entries in total
|
||||
</div>
|
||||
{totalEntries > 10 && (
|
||||
<div className="flex flex-col-reverse items-center gap-4 sm:flex-row sm:gap-6 lg:gap-8">
|
||||
{/* Rows per page selector */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="whitespace-nowrap text-sm font-medium">Rows per page</p>
|
||||
<p className="whitespace-nowrap text-sm font-medium">
|
||||
Rows per page
|
||||
</p>
|
||||
<Select
|
||||
value={selectedPageSize}
|
||||
onValueChange={(value) => {
|
||||
setSelectedPageSize(value);
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
// Preserve all important parameters
|
||||
const scanId = searchParams.get("scanId");
|
||||
const id = searchParams.get("id");
|
||||
const version = searchParams.get("version");
|
||||
|
||||
params.set("pageSize", value);
|
||||
params.set("page", "1");
|
||||
|
||||
// Ensure that scanId, id and version are preserved
|
||||
if (scanId) params.set("scanId", scanId);
|
||||
if (id) params.set("id", id);
|
||||
if (version) params.set("version", version);
|
||||
|
||||
// This pushes the URL without reloading the page
|
||||
if (disableScroll) {
|
||||
const url = `${pathname}?${params.toString()}`;
|
||||
router.push(url, { scroll: false });
|
||||
} else {
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[4.5rem]">
|
||||
@@ -95,36 +136,63 @@ export function DataTablePagination({ metadata }: DataTablePaginationProps) {
|
||||
<div className="flex items-center space-x-2">
|
||||
<Link
|
||||
aria-label="Go to first page"
|
||||
className="page-link relative block rounded border-0 bg-transparent px-3 py-1.5 text-gray-800 outline-none transition-all duration-300 hover:bg-gray-200 hover:text-gray-800 focus:shadow-none dark:text-prowler-theme-green"
|
||||
href={createPageUrl(1)}
|
||||
aria-disabled="true"
|
||||
className={`${baseLinkClass} ${isFirstPage ? disabledLinkClass : ""}`}
|
||||
href={
|
||||
isFirstPage
|
||||
? pathname + "?" + searchParams.toString()
|
||||
: createPageUrl(1)
|
||||
}
|
||||
scroll={!disableScroll}
|
||||
aria-disabled={isFirstPage}
|
||||
onClick={(e) => isFirstPage && e.preventDefault()}
|
||||
>
|
||||
<DoubleArrowLeftIcon className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
<Link
|
||||
aria-label="Go to previous page"
|
||||
className="page-link relative block rounded border-0 bg-transparent px-3 py-1.5 text-gray-800 outline-none transition-all duration-300 hover:bg-gray-200 hover:text-gray-800 focus:shadow-none dark:text-prowler-theme-green"
|
||||
href={createPageUrl(currentPage - 1)}
|
||||
aria-disabled="true"
|
||||
className={`${baseLinkClass} ${isFirstPage ? disabledLinkClass : ""}`}
|
||||
href={
|
||||
isFirstPage
|
||||
? pathname + "?" + searchParams.toString()
|
||||
: createPageUrl(currentPage - 1)
|
||||
}
|
||||
scroll={!disableScroll}
|
||||
aria-disabled={isFirstPage}
|
||||
onClick={(e) => isFirstPage && e.preventDefault()}
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
<Link
|
||||
aria-label="Go to next page"
|
||||
className="page-link relative block rounded border-0 bg-transparent px-3 py-1.5 text-gray-800 outline-none transition-all duration-300 hover:bg-gray-200 hover:text-gray-800 focus:shadow-none dark:text-prowler-theme-green"
|
||||
href={createPageUrl(currentPage + 1)}
|
||||
className={`${baseLinkClass} ${isLastPage ? disabledLinkClass : ""}`}
|
||||
href={
|
||||
isLastPage
|
||||
? pathname + "?" + searchParams.toString()
|
||||
: createPageUrl(currentPage + 1)
|
||||
}
|
||||
scroll={!disableScroll}
|
||||
aria-disabled={isLastPage}
|
||||
onClick={(e) => isLastPage && e.preventDefault()}
|
||||
>
|
||||
<ChevronRightIcon className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
<Link
|
||||
aria-label="Go to last page"
|
||||
className="page-link relative block rounded border-0 bg-transparent px-3 py-1.5 text-gray-800 outline-none transition-all duration-300 hover:bg-gray-200 hover:text-gray-800 focus:shadow-none dark:text-prowler-theme-green"
|
||||
href={createPageUrl(totalPages)}
|
||||
className={`${baseLinkClass} ${isLastPage ? disabledLinkClass : ""}`}
|
||||
href={
|
||||
isLastPage
|
||||
? pathname + "?" + searchParams.toString()
|
||||
: createPageUrl(totalPages)
|
||||
}
|
||||
scroll={!disableScroll}
|
||||
aria-disabled={isLastPage}
|
||||
onClick={(e) => isLastPage && e.preventDefault()}
|
||||
>
|
||||
<DoubleArrowRightIcon className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,12 +29,14 @@ interface DataTableProviderProps<TData, TValue> {
|
||||
data: TData[];
|
||||
metadata?: MetaDataProps;
|
||||
customFilters?: FilterOption[];
|
||||
disableScroll?: boolean;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
metadata,
|
||||
disableScroll = false,
|
||||
}: DataTableProviderProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
@@ -109,7 +111,10 @@ export function DataTable<TData, TValue>({
|
||||
</div>
|
||||
{metadata && (
|
||||
<div className="flex w-full items-center space-x-2 py-4">
|
||||
<DataTablePagination metadata={metadata} />
|
||||
<DataTablePagination
|
||||
metadata={metadata}
|
||||
disableScroll={disableScroll}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -16,10 +16,12 @@ const statusColorMap: Record<
|
||||
export const StatusFindingBadge = ({
|
||||
status,
|
||||
size = "sm",
|
||||
value,
|
||||
...props
|
||||
}: {
|
||||
status: FindingStatus;
|
||||
size?: "sm" | "md" | "lg";
|
||||
value?: string | number;
|
||||
}) => {
|
||||
const color = statusColorMap[status];
|
||||
|
||||
@@ -33,6 +35,7 @@ export const StatusFindingBadge = ({
|
||||
>
|
||||
<span className="text-xs font-light tracking-wide text-default-600">
|
||||
{status.charAt(0).toUpperCase() + status.slice(1).toLowerCase()}
|
||||
{value !== undefined && `: ${value}`}
|
||||
</span>
|
||||
</Chip>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useState } from "react";
|
||||
|
||||
import { CustomAlertModal, CustomButton } from "@/components/ui/custom";
|
||||
import { DateWithTime, InfoField } from "@/components/ui/entities";
|
||||
import { MembershipDetailData } from "@/types/users/users";
|
||||
import { MembershipDetailData } from "@/types/users";
|
||||
|
||||
import { EditTenantForm } from "../forms";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Card, CardBody, CardHeader } from "@nextui-org/react";
|
||||
|
||||
import { MembershipDetailData, TenantDetailData } from "@/types/users/users";
|
||||
import { MembershipDetailData, TenantDetailData } from "@/types/users";
|
||||
|
||||
import { MembershipItem } from "./membership-item";
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useState } from "react";
|
||||
|
||||
import { CustomButton } from "@/components/ui/custom/custom-button";
|
||||
import { getRolePermissions } from "@/lib/permissions";
|
||||
import { RoleData, RoleDetail } from "@/types/users/users";
|
||||
import { RoleData, RoleDetail } from "@/types/users";
|
||||
|
||||
interface PermissionItemProps {
|
||||
enabled: boolean;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Card, CardBody, CardHeader } from "@nextui-org/react";
|
||||
|
||||
import { RoleData, RoleDetail } from "@/types/users/users";
|
||||
import { RoleData, RoleDetail } from "@/types/users";
|
||||
|
||||
import { RoleItem } from "./role-item";
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Card, CardBody, Divider } from "@nextui-org/react";
|
||||
|
||||
import { DateWithTime, InfoField, SnippetChip } from "@/components/ui/entities";
|
||||
import { UserDataWithRoles } from "@/types/users/users";
|
||||
import { UserDataWithRoles } from "@/types/users";
|
||||
|
||||
import { ProwlerShort } from "../../icons";
|
||||
|
||||
|
||||
242
ui/lib/compliance/ens.tsx
Normal file
242
ui/lib/compliance/ens.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
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,
|
||||
Framework,
|
||||
MappedComplianceData,
|
||||
Requirement,
|
||||
RequirementItemData,
|
||||
RequirementsData,
|
||||
RequirementStatus,
|
||||
} from "@/types/compliance";
|
||||
|
||||
export const translateType = (type: string) => {
|
||||
switch (type.toLowerCase()) {
|
||||
case "requisito":
|
||||
return "Requirement";
|
||||
case "recomendacion":
|
||||
return "Recommendation";
|
||||
case "refuerzo":
|
||||
return "Reinforcement";
|
||||
case "medida":
|
||||
return "Measure";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
export const mapComplianceData = (
|
||||
attributesData: AttributesData,
|
||||
requirementsData: RequirementsData,
|
||||
): MappedComplianceData => {
|
||||
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 attrs = attributeItem.attributes?.attributes?.metadata?.[0];
|
||||
if (!attrs) continue;
|
||||
|
||||
// Get corresponding requirement data
|
||||
const requirementData = requirementsMap.get(id);
|
||||
if (!requirementData) continue;
|
||||
|
||||
const frameworkName = attrs.Marco;
|
||||
const categoryName = attrs.Categoria;
|
||||
const groupControl = attrs.IdGrupoControl;
|
||||
const type = attrs.Tipo;
|
||||
const description = attributeItem.attributes.description;
|
||||
const status = requirementData.attributes.status || "";
|
||||
const controlDescription = attrs.DescripcionControl || "";
|
||||
const checks = attributeItem.attributes.attributes.check_ids || [];
|
||||
const isManual = attrs.ModoEjecucion === "manual";
|
||||
const requirementName = id;
|
||||
const groupControlLabel = `${groupControl} - ${description}`;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Find or create category
|
||||
let category = framework.categories.find((c) => c.name === categoryName);
|
||||
if (!category) {
|
||||
category = {
|
||||
name: categoryName,
|
||||
pass: 0,
|
||||
fail: 0,
|
||||
manual: 0,
|
||||
controls: [],
|
||||
};
|
||||
framework.categories.push(category);
|
||||
}
|
||||
|
||||
// Find or create control
|
||||
let control = category.controls.find((c) => c.label === groupControlLabel);
|
||||
if (!control) {
|
||||
control = {
|
||||
label: groupControlLabel,
|
||||
type,
|
||||
pass: 0,
|
||||
fail: 0,
|
||||
manual: 0,
|
||||
requirements: [],
|
||||
};
|
||||
category.controls.push(control);
|
||||
}
|
||||
|
||||
// Create requirement
|
||||
const finalStatus: RequirementStatus = isManual
|
||||
? "MANUAL"
|
||||
: (status as RequirementStatus);
|
||||
const requirement: Requirement = {
|
||||
name: requirementName,
|
||||
description: controlDescription,
|
||||
status: finalStatus,
|
||||
type,
|
||||
check_ids: checks,
|
||||
pass: finalStatus === "PASS" ? 1 : 0,
|
||||
fail: finalStatus === "FAIL" ? 1 : 0,
|
||||
manual: finalStatus === "MANUAL" ? 1 : 0,
|
||||
nivel: attrs.Nivel || "",
|
||||
dimensiones: attrs.Dimensiones || [],
|
||||
};
|
||||
|
||||
control.requirements.push(requirement);
|
||||
}
|
||||
|
||||
// Calculate counters
|
||||
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) => {
|
||||
control.pass = 0;
|
||||
control.fail = 0;
|
||||
control.manual = 0;
|
||||
|
||||
control.requirements.forEach((requirement) => {
|
||||
if (requirement.status === "MANUAL") {
|
||||
control.manual++;
|
||||
} else if (requirement.status === "PASS") {
|
||||
control.pass++;
|
||||
} else if (requirement.status === "FAIL") {
|
||||
control.fail++;
|
||||
}
|
||||
});
|
||||
|
||||
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: MappedComplianceData,
|
||||
scanId: string | undefined,
|
||||
): AccordionItemProps[] => {
|
||||
return data.map((framework) => {
|
||||
return {
|
||||
key: framework.name,
|
||||
title: (
|
||||
<ComplianceAccordionTitle
|
||||
label={framework.name}
|
||||
pass={framework.pass}
|
||||
fail={framework.fail}
|
||||
manual={framework.manual}
|
||||
/>
|
||||
),
|
||||
content: "",
|
||||
items: framework.categories.map((category) => {
|
||||
return {
|
||||
key: `${framework.name}-${category.name}`,
|
||||
title: (
|
||||
<ComplianceAccordionTitle
|
||||
label={category.name}
|
||||
pass={category.pass}
|
||||
fail={category.fail}
|
||||
manual={category.manual}
|
||||
/>
|
||||
),
|
||||
content: "",
|
||||
items: category.controls.map((control, i: number) => {
|
||||
return {
|
||||
key: `${framework.name}-${category.name}-control-${i}`,
|
||||
title: (
|
||||
<ComplianceAccordionTitle
|
||||
label={control.label}
|
||||
pass={control.pass}
|
||||
fail={control.fail}
|
||||
manual={control.manual}
|
||||
/>
|
||||
),
|
||||
content: "",
|
||||
items: control.requirements.map((requirement, j: number) => {
|
||||
const itemKey = `${framework.name}-${category.name}-control-${i}-req-${j}`;
|
||||
|
||||
return {
|
||||
key: itemKey,
|
||||
title: (
|
||||
<ComplianceAccordionRequirementTitle
|
||||
type={requirement.type}
|
||||
name={requirement.name}
|
||||
status={requirement.status as FindingStatus}
|
||||
/>
|
||||
),
|
||||
content: (
|
||||
<ClientAccordionContent
|
||||
requirement={requirement}
|
||||
scanId={scanId || ""}
|
||||
/>
|
||||
),
|
||||
items: [],
|
||||
isDisabled:
|
||||
requirement.check_ids.length === 0 &&
|
||||
requirement.manual === 0,
|
||||
};
|
||||
}),
|
||||
isDisabled:
|
||||
control.pass === 0 &&
|
||||
control.fail === 0 &&
|
||||
control.manual === 0,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RolePermissionAttributes } from "@/types/users/users";
|
||||
import { RolePermissionAttributes } from "@/types/users";
|
||||
|
||||
export const isUserOwnerAndHasManageAccount = (
|
||||
roles: any[],
|
||||
|
||||
BIN
ui/public/ens.png
Normal file
BIN
ui/public/ens.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
119
ui/types/compliance.ts
Normal file
119
ui/types/compliance.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
export type RequirementStatus = "PASS" | "FAIL" | "MANUAL" | "No findings";
|
||||
|
||||
export type ComplianceId = "ens_rd2022_aws";
|
||||
|
||||
export interface CompliancesOverview {
|
||||
data: ComplianceOverviewData[];
|
||||
}
|
||||
|
||||
export interface ComplianceOverviewData {
|
||||
type: "compliance-requirements-status";
|
||||
id: string;
|
||||
attributes: {
|
||||
framework: string;
|
||||
version: string;
|
||||
requirements_passed: number;
|
||||
requirements_failed: number;
|
||||
requirements_manual: number;
|
||||
total_requirements: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Requirement {
|
||||
name: string;
|
||||
description: string;
|
||||
status: RequirementStatus;
|
||||
type: string;
|
||||
pass: number;
|
||||
fail: number;
|
||||
manual: number;
|
||||
check_ids: string[];
|
||||
// ENS
|
||||
nivel?: string;
|
||||
dimensiones?: string[];
|
||||
}
|
||||
|
||||
export interface Control {
|
||||
label: string;
|
||||
type: string;
|
||||
pass: number;
|
||||
fail: number;
|
||||
manual: number;
|
||||
requirements: Requirement[];
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
name: string;
|
||||
pass: number;
|
||||
fail: number;
|
||||
manual: number;
|
||||
controls: Control[];
|
||||
}
|
||||
|
||||
export interface Framework {
|
||||
name: string;
|
||||
pass: number;
|
||||
fail: number;
|
||||
manual: number;
|
||||
categories: Category[];
|
||||
}
|
||||
|
||||
export type MappedComplianceData = Framework[];
|
||||
|
||||
export interface FailedSection {
|
||||
name: string;
|
||||
total: number;
|
||||
types: { [key: string]: number };
|
||||
}
|
||||
|
||||
export interface RequirementsTotals {
|
||||
pass: number;
|
||||
fail: number;
|
||||
manual: number;
|
||||
}
|
||||
|
||||
// API Responses types:
|
||||
export interface AttributesMetadata {
|
||||
IdGrupoControl: string;
|
||||
Marco: string;
|
||||
Categoria: string;
|
||||
DescripcionControl: string;
|
||||
Tipo: string;
|
||||
Nivel: string;
|
||||
Dimensiones: string[];
|
||||
ModoEjecucion: string;
|
||||
Dependencias: any[];
|
||||
}
|
||||
|
||||
export interface AttributesItemData {
|
||||
type: "compliance-requirements-attributes";
|
||||
id: string;
|
||||
attributes: {
|
||||
framework: string;
|
||||
version: string;
|
||||
description: string;
|
||||
attributes: {
|
||||
metadata: AttributesMetadata[];
|
||||
check_ids: string[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface RequirementItemData {
|
||||
type: "compliance-requirements-details";
|
||||
id: string;
|
||||
attributes: {
|
||||
framework: string;
|
||||
version: string;
|
||||
description: string;
|
||||
status: RequirementStatus;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AttributesData {
|
||||
data: AttributesItemData[];
|
||||
}
|
||||
|
||||
export interface RequirementsData {
|
||||
data: RequirementItemData[];
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import { LucideIcon } from "lucide-react";
|
||||
import { SVGProps } from "react";
|
||||
|
||||
import { ProviderType } from "./providers";
|
||||
|
||||
export type IconSvgProps = SVGProps<SVGSVGElement> & {
|
||||
size?: number;
|
||||
};
|
||||
@@ -44,18 +42,6 @@ export interface CollapseMenuButtonProps {
|
||||
isOpen: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface SelectScanComplianceDataProps {
|
||||
scans: (ScanProps & {
|
||||
providerInfo: {
|
||||
provider: ProviderType;
|
||||
uid: string;
|
||||
alias: string;
|
||||
};
|
||||
})[];
|
||||
selectedScanId: string;
|
||||
onSelectionChange: (selectedKey: string) => void;
|
||||
}
|
||||
|
||||
export type NextUIVariants =
|
||||
| "solid"
|
||||
| "faded"
|
||||
@@ -269,53 +255,6 @@ export interface ApiError {
|
||||
};
|
||||
code: string;
|
||||
}
|
||||
export interface CompliancesOverview {
|
||||
links: {
|
||||
first: string;
|
||||
last: string;
|
||||
next: string | null;
|
||||
prev: string | null;
|
||||
};
|
||||
data: ComplianceOverviewData[];
|
||||
meta: {
|
||||
pagination: {
|
||||
page: number;
|
||||
pages: number;
|
||||
count: number;
|
||||
};
|
||||
version: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ComplianceOverviewData {
|
||||
type: "compliance-overviews";
|
||||
id: string;
|
||||
attributes: {
|
||||
inserted_at: string;
|
||||
compliance_id: string;
|
||||
framework: string;
|
||||
version: string;
|
||||
requirements_status: {
|
||||
passed: number;
|
||||
failed: number;
|
||||
manual: number;
|
||||
total: number;
|
||||
};
|
||||
region: string;
|
||||
provider_type: string;
|
||||
};
|
||||
relationships: {
|
||||
scan: {
|
||||
data: {
|
||||
type: "scans";
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
links: {
|
||||
self: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface InvitationProps {
|
||||
type: "invitations";
|
||||
@@ -497,52 +436,9 @@ export interface UserProps {
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface ScanProps {
|
||||
type: "scans";
|
||||
id: string;
|
||||
attributes: {
|
||||
name: string;
|
||||
trigger: "scheduled" | "manual";
|
||||
state:
|
||||
| "available"
|
||||
| "scheduled"
|
||||
| "executing"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
unique_resource_count: number;
|
||||
progress: number;
|
||||
scanner_args: {
|
||||
only_logs?: boolean;
|
||||
excluded_checks?: string[];
|
||||
aws_retries_max_attempts?: number;
|
||||
} | null;
|
||||
duration: number;
|
||||
started_at: string;
|
||||
inserted_at: string;
|
||||
completed_at: string;
|
||||
scheduled_at: string;
|
||||
next_scan_at: string;
|
||||
};
|
||||
relationships: {
|
||||
provider: {
|
||||
data: {
|
||||
id: string;
|
||||
type: "providers";
|
||||
};
|
||||
};
|
||||
task: {
|
||||
data: {
|
||||
id: string;
|
||||
type: "tasks";
|
||||
};
|
||||
};
|
||||
};
|
||||
providerInfo?: {
|
||||
provider: ProviderType;
|
||||
uid: string;
|
||||
alias: string;
|
||||
};
|
||||
export interface FindingsResponse {
|
||||
data: FindingProps[];
|
||||
meta: MetaDataProps;
|
||||
}
|
||||
|
||||
export interface FindingProps {
|
||||
|
||||
@@ -3,3 +3,4 @@ export * from "./components";
|
||||
export * from "./filters";
|
||||
export * from "./formSchemas";
|
||||
export * from "./providers";
|
||||
export * from "./scans";
|
||||
|
||||
49
ui/types/scans.ts
Normal file
49
ui/types/scans.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { ProviderType } from "./providers";
|
||||
|
||||
export interface ScanProps {
|
||||
type: "scans";
|
||||
id: string;
|
||||
attributes: {
|
||||
name: string;
|
||||
trigger: "scheduled" | "manual";
|
||||
state:
|
||||
| "available"
|
||||
| "scheduled"
|
||||
| "executing"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
unique_resource_count: number;
|
||||
progress: number;
|
||||
scanner_args: {
|
||||
only_logs?: boolean;
|
||||
excluded_checks?: string[];
|
||||
aws_retries_max_attempts?: number;
|
||||
} | null;
|
||||
duration: number;
|
||||
started_at: string;
|
||||
inserted_at: string;
|
||||
completed_at: string;
|
||||
scheduled_at: string;
|
||||
next_scan_at: string;
|
||||
};
|
||||
relationships: {
|
||||
provider: {
|
||||
data: {
|
||||
id: string;
|
||||
type: "providers";
|
||||
};
|
||||
};
|
||||
task: {
|
||||
data: {
|
||||
id: string;
|
||||
type: "tasks";
|
||||
};
|
||||
};
|
||||
};
|
||||
providerInfo?: {
|
||||
provider: ProviderType;
|
||||
uid: string;
|
||||
alias: string;
|
||||
};
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./users";
|
||||
Reference in New Issue
Block a user