diff --git a/ui/.eslintrc.cjs b/ui/.eslintrc.cjs index 8c32ae0804..01d6afe1ac 100644 --- a/ui/.eslintrc.cjs +++ b/ui/.eslintrc.cjs @@ -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", diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 4afa87f456..dba82a9d13 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -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)] diff --git a/ui/actions/compliances/compliances.ts b/ui/actions/compliances/compliances.ts index 21c65765e0..cdc947fd8c 100644 --- a/ui/actions/compliances/compliances.ts +++ b/ui/actions/compliances/compliances.ts @@ -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; + } + // */ +}; diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx new file mode 100644 index 0000000000..eeb3f1a588 --- /dev/null +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -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 ( +
+ Compliance Logo +
+ ); +}; + +const ChartsWrapper = ({ + children, + logoPath, +}: { + children: React.ReactNode; + logoPath: string; +}) => { + return ( +
+
+ {children} +
+ {logoPath && } +
+ ); +}; + +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 ( + + + + + + + + + + + } + > + + + + ); +} + +const getComplianceData = async ( + complianceId: string, + scanId: string, + region?: string, +): Promise => { + 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 ( +
+ + + + + +
+ ); + } + + 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 ( +
+ + + + + + + +
+ ); +}; diff --git a/ui/app/(prowler)/compliance/page.tsx b/ui/app/(prowler)/compliance/page.tsx index 56b6dc9d62..061af051d7 100644 --- a/ui/app/(prowler)/compliance/page.tsx +++ b/ui/app/(prowler)/compliance/page.tsx @@ -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({ {selectedScanId ? ( <> - - - - - - }> @@ -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 (
@@ -155,25 +145,22 @@ const SSRComplianceGrid = async ({ return (
{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 ( ); })} diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx index e37902ac31..82bf6d6d18 100644 --- a/ui/app/(prowler)/findings/page.tsx +++ b/ui/app/(prowler)/findings/page.tsx @@ -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} /> + }> diff --git a/ui/app/(prowler)/profile/page.tsx b/ui/app/(prowler)/profile/page.tsx index 29d8a01e45..ff26a9db15 100644 --- a/ui/app/(prowler)/profile/page.tsx +++ b/ui/app/(prowler)/profile/page.tsx @@ -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 ( diff --git a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx new file mode 100644 index 0000000000..06bb394fa0 --- /dev/null +++ b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx @@ -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(null); + const [expandedFindings, setExpandedFindings] = useState([]); + 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(null); + const loadedSortRef = useRef(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 = ( +
+ {checks.join(", ")} +
+ ); + + const accordionChecksItems = [ + { + key: "checks", + title: ( +
+ {checks.length} + {checks.length > 1 ? Checks : Check} +
+ ), + content: checksList, + }, + ]; + + const renderFindingsTable = () => { + if (findings === null && requirement.status !== "MANUAL") { + return ; + } + + if (findings?.data?.length && findings.data.length > 0) { + return ( +
+ index !== 4 && index !== 7, + )} + data={expandedFindings || []} + metadata={findings?.meta} + disableScroll={true} + /> +
+ ); + } + + return
There are no findings for this regions
; + }; + + const renderDetails = () => { + if (!complianceId) { + return null; + } + + switch (complianceId) { + case "ens_rd2022_aws": + return ( +
+ +
+ ); + default: + return null; + } + }; + + return ( +
+ {renderDetails()} + + {checks.length > 0 && ( +
+ +
+ )} + + {renderFindingsTable()} +
+ ); +}; diff --git a/ui/components/compliance/compliance-accordion/client-accordion-wrapper.tsx b/ui/components/compliance/compliance-accordion/client-accordion-wrapper.tsx new file mode 100644 index 0000000000..a47952612b --- /dev/null +++ b/ui/components/compliance/compliance-accordion/client-accordion-wrapper.tsx @@ -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(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 ( +
+
+ + {isExpanded ? "Collapse all" : "Expand all"} + +
+ +
+ ); +}; diff --git a/ui/components/compliance/compliance-accordion/compliance-accordion-requeriment-title.tsx b/ui/components/compliance/compliance-accordion/compliance-accordion-requeriment-title.tsx new file mode 100644 index 0000000000..92f79e032d --- /dev/null +++ b/ui/components/compliance/compliance-accordion/compliance-accordion-requeriment-title.tsx @@ -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 ( +
+
+ + {translateType(type)}: + + {name} +
+ +
+ ); +}; diff --git a/ui/components/compliance/compliance-accordion/compliance-accordion-title.tsx b/ui/components/compliance/compliance-accordion/compliance-accordion-title.tsx new file mode 100644 index 0000000000..e458cef948 --- /dev/null +++ b/ui/components/compliance/compliance-accordion/compliance-accordion-title.tsx @@ -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 ( +
+
+ + {label.charAt(0).toUpperCase() + label.slice(1)} + +
+
+
+ {total > 0 && ( + + Requirements: + + )} +
+ +
+ {total > 0 ? ( +
+ {pass > 0 && ( + +
Pass
+
+ {pass} ({passPercentage.toFixed(1)}%) +
+
+ } + size="sm" + placement="top" + delay={0} + closeDelay={0} + > +
0 ? "2px" : "0", + }} + /> + + )} + {fail > 0 && ( + +
Fail
+
+ {fail} ({failPercentage.toFixed(1)}%) +
+
+ } + size="sm" + placement="top" + delay={0} + closeDelay={0} + > +
0 ? "2px" : "0", + }} + /> + + )} + {manual > 0 && ( + +
Manual
+
+ {manual} ({manualPercentage.toFixed(1)}%) +
+
+ } + size="sm" + placement="top" + delay={0} + closeDelay={0} + > +
+ + )} +
+ ) : ( +
+ )} +
+ + +
Total requirements
+
{total}
+
+ } + size="sm" + placement="top" + > +
+ {total > 0 ? total : "—"} +
+ +
+
+ ); +}; diff --git a/ui/components/compliance/compliance-card.tsx b/ui/components/compliance/compliance-card.tsx index ff55421681..db7539401f 100644 --- a/ui/components/compliance/compliance-card.tsx +++ b/ui/components/compliance/compliance-card.tsx @@ -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 = ({ @@ -28,8 +29,10 @@ export const ComplianceCard: React.FC = ({ totalRequirements, scanId, complianceId, + id, }) => { const searchParams = useSearchParams(); + const router = useRouter(); const hasRegionFilter = searchParams.has("filter[region__in]"); const [isDownloading, setIsDownloading] = useState(false); @@ -68,6 +71,22 @@ export const ComplianceCard: React.FC = ({ 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 = ({ }; return ( - +
{ + return ( +
+
+ {requirement.description} +
+
+
+ Level: + {requirement.nivel} +
+ {requirement.dimensiones && requirement.dimensiones.length > 0 && ( +
+ Dimensions: +
+ {requirement.dimensiones.map( + (dimension: string, index: number) => ( + + {dimension} + + ), + )} +
+
+ )} +
+
+ ); +}; diff --git a/ui/components/compliance/compliance-header/compliance-header.tsx b/ui/components/compliance/compliance-header/compliance-header.tsx new file mode 100644 index 0000000000..9e31f4de5d --- /dev/null +++ b/ui/components/compliance/compliance-header/compliance-header.tsx @@ -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 && } + + + {showRegionFilter && ( + <> + + + + )} + + + ); +}; diff --git a/ui/components/compliance/compliance-scan-info.tsx b/ui/components/compliance/compliance-header/compliance-scan-info.tsx similarity index 99% rename from ui/components/compliance/compliance-scan-info.tsx rename to ui/components/compliance/compliance-header/compliance-scan-info.tsx index c4bff1d7ad..a6690da618 100644 --- a/ui/components/compliance/compliance-scan-info.tsx +++ b/ui/components/compliance/compliance-header/compliance-scan-info.tsx @@ -3,6 +3,7 @@ import React from "react"; import { DateWithTime, EntityInfoShort } from "@/components/ui/entities"; import { ProviderType } from "@/types"; + interface ComplianceScanInfoProps { scan: { providerInfo: { diff --git a/ui/components/compliance/data-compliance/data-compliance.tsx b/ui/components/compliance/compliance-header/data-compliance.tsx similarity index 90% rename from ui/components/compliance/data-compliance/data-compliance.tsx rename to ui/components/compliance/compliance-header/data-compliance.tsx index 9b57033bc1..a24a1db6c1 100644 --- a/ui/components/compliance/data-compliance/data-compliance.tsx +++ b/ui/components/compliance/compliance-header/data-compliance.tsx @@ -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"]; } diff --git a/ui/components/compliance/data-compliance/index.ts b/ui/components/compliance/compliance-header/index.ts similarity index 100% rename from ui/components/compliance/data-compliance/index.ts rename to ui/components/compliance/compliance-header/index.ts diff --git a/ui/components/compliance/data-compliance/select-scan-compliance-data.tsx b/ui/components/compliance/compliance-header/select-scan-compliance-data.tsx similarity index 72% rename from ui/components/compliance/data-compliance/select-scan-compliance-data.tsx rename to ui/components/compliance/compliance-header/select-scan-compliance-data.tsx index 77e4b12198..f28b79fe1a 100644 --- a/ui/components/compliance/data-compliance/select-scan-compliance-data.tsx +++ b/ui/components/compliance/compliance-header/select-scan-compliance-data.tsx @@ -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, diff --git a/ui/components/compliance/compliance-skeleton-accordion.tsx b/ui/components/compliance/compliance-skeleton-accordion.tsx new file mode 100644 index 0000000000..f1077b53ee --- /dev/null +++ b/ui/components/compliance/compliance-skeleton-accordion.tsx @@ -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 ( +
+ {[...Array(itemCount)].map((_, index) => ( + +
+
+ ))} +
+ ); +}; + +SkeletonAccordion.displayName = "SkeletonAccordion"; diff --git a/ui/components/compliance/failed-sections-chart-skeleton.tsx b/ui/components/compliance/failed-sections-chart-skeleton.tsx new file mode 100644 index 0000000000..24164ae55c --- /dev/null +++ b/ui/components/compliance/failed-sections-chart-skeleton.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { Skeleton } from "@nextui-org/react"; + +export const FailedSectionsChartSkeleton = () => { + return ( +
+ {/* Title skeleton */} + +
+ + + {/* Chart area skeleton */} +
+ {/* Bar chart skeleton - 5 horizontal bars */} + {Array.from({ length: 5 }).map((_, index) => ( +
+ {/* Bar skeleton with varying widths */} + +
+ +
+ ))} + + {/* Legend skeleton */} +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ +
+ + +
+ +
+ ))} +
+
+
+ ); +}; diff --git a/ui/components/compliance/failed-sections-chart.tsx b/ui/components/compliance/failed-sections-chart.tsx new file mode 100644 index 0000000000..32076f1b66 --- /dev/null +++ b/ui/components/compliance/failed-sections-chart.tsx @@ -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 = ( +

+ Failed Sections (Top 5) +

+); + +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 ( +
+ {title} +
+

There are no failed sections

+
+
+ ); + } + + return ( +
+ {title} + +
+ + + + + [ + value, + translateType(name), + ]} + cursor={false} + /> + translateType(value)} + wrapperStyle={{ + fontSize: "10px", + display: "flex", + justifyContent: "center", + width: "100%", + }} + iconType="circle" + layout="horizontal" + verticalAlign="bottom" + height={36} + /> + {allTypes.map((type, i) => ( + + ))} + + +
+
+ ); +}; diff --git a/ui/components/compliance/index.ts b/ui/components/compliance/index.ts index d0ba1eb027..fc730a267d 100644 --- a/ui/components/compliance/index.ts +++ b/ui/components/compliance/index.ts @@ -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"; diff --git a/ui/components/compliance/requirements-chart-skeleton.tsx b/ui/components/compliance/requirements-chart-skeleton.tsx new file mode 100644 index 0000000000..eae6ff6e4e --- /dev/null +++ b/ui/components/compliance/requirements-chart-skeleton.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { Skeleton } from "@nextui-org/react"; + +export const RequirementsChartSkeleton = () => { + return ( +
+ {/* Title skeleton */} + +
+ + + {/* Pie chart skeleton */} +
+ {/* Outer circle */} + +
+ + + {/* Inner circle (donut hole) */} +
+ + {/* Center text skeleton */} +
+ +
+ + +
+ +
+
+ + {/* Bottom stats skeleton */} +
+
+ +
+ + +
+ +
+
+ +
+ + +
+ +
+
+ +
+ + +
+ +
+
+
+ ); +}; diff --git a/ui/components/compliance/requirements-chart.tsx b/ui/components/compliance/requirements-chart.tsx new file mode 100644 index 0000000000..eb40619a8b --- /dev/null +++ b/ui/components/compliance/requirements-chart.tsx @@ -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 ( +
+
+
+ + {data.payload.name}: {data.payload.value} + +
+
+ ); + } + return null; + }; + + return ( +
+

+ Requirements Status +

+ + + + } + /> + 0 ? chartData : emptyChartData} + dataKey="value" + nameKey="name" + innerRadius={70} + outerRadius={100} + paddingAngle={2} + cornerRadius={4} + > + {(totalRequirements > 0 ? chartData : emptyChartData).map( + (entry, index) => ( + + ), + )} + + + + +
+
+
Pass
+
{pass}
+
+
+
Fail
+
{fail}
+
+
+
Manual
+
{manual}
+
+
+
+ ); +}; diff --git a/ui/components/findings/table/skeleton-table-findings.tsx b/ui/components/findings/table/skeleton-table-findings.tsx index 3af6403e7c..865fd14911 100644 --- a/ui/components/findings/table/skeleton-table-findings.tsx +++ b/ui/components/findings/table/skeleton-table-findings.tsx @@ -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 ( - - {/* Table headers */} -
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
- - {/* Table body */} -
- {[...Array(3)].map((_, index) => ( -
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
- ))} -
-
+
+ +
); }; diff --git a/ui/components/overview/findings-by-severity-chart/findings-by-severity-chart.tsx b/ui/components/overview/findings-by-severity-chart/findings-by-severity-chart.tsx index 3f30d2ee70..ae4d704731 100644 --- a/ui/components/overview/findings-by-severity-chart/findings-by-severity-chart.tsx +++ b/ui/components/overview/findings-by-severity-chart/findings-by-severity-chart.tsx @@ -116,9 +116,9 @@ export const FindingsBySeverityChart = ({ > diff --git a/ui/components/overview/findings-by-status-chart/findings-by-status-chart.tsx b/ui/components/overview/findings-by-status-chart/findings-by-status-chart.tsx index 331748adc0..7e6040ed60 100644 --- a/ui/components/overview/findings-by-status-chart/findings-by-status-chart.tsx +++ b/ui/components/overview/findings-by-status-chart/findings-by-status-chart.tsx @@ -146,9 +146,9 @@ export const FindingsByStatusChart: React.FC = ({ -
+
-
+
{ return ( - - {/* Table headers */} -
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
- - {/* Table body */} -
- {[...Array(3)].map((_, index) => ( -
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
- ))} -
-
+
+ +
); }; diff --git a/ui/components/ui/accordion/Accordion.tsx b/ui/components/ui/accordion/Accordion.tsx index aa029a121c..e9257bf015 100644 --- a/ui/components/ui/accordion/Accordion.tsx +++ b/ui/components/ui/accordion/Accordion.tsx @@ -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 (
@@ -46,6 +53,8 @@ const AccordionContent = ({ variant="light" isCompact selectionMode="multiple" + selectedKeys={selectedKeys} + onSelectionChange={onSelectionChange} />
)} @@ -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( + // Determine if component is in controlled or uncontrolled mode + const isControlled = selectedKeys !== undefined; + + const [internalExpandedKeys, setInternalExpandedKeys] = useState( 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); + + // 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); + const newKeys = keysArray; + + const newlyExpandedKeys = newKeys.filter( + (key) => !currentKeys.includes(key), + ); + + newlyExpandedKeys.forEach((key) => { + onItemExpand(key); + }); + } + }, + [expandedKeys, onItemExpand, isControlled, onSelectionChange], + ); return ( } 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", }} > - + ))} diff --git a/ui/components/ui/chart/horizontal-split-chart.tsx b/ui/components/ui/chart/horizontal-split-chart.tsx index b3b4c783a3..88c79e2996 100644 --- a/ui/components/ui/chart/horizontal-split-chart.tsx +++ b/ui/components/ui/chart/horizontal-split-chart.tsx @@ -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(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" : ""}
{/* 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" : ""}
diff --git a/ui/components/ui/skeleton/skeleton.tsx b/ui/components/ui/skeleton/skeleton.tsx new file mode 100644 index 0000000000..4591264f7d --- /dev/null +++ b/ui/components/ui/skeleton/skeleton.tsx @@ -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 ( +
+ ); +} + +export function SkeletonTable({ + rows = 5, + columns = 4, + className, + roundedCells = true, +}: { + rows?: number; + columns?: number; + className?: string; + roundedCells?: boolean; +}) { + return ( +
+ {/* Header */} +
+ {Array.from({ length: columns }).map((_, index) => ( + + ))} +
+ + {/* Rows */} + {Array.from({ length: rows }).map((_, rowIndex) => ( +
+ {Array.from({ length: columns }).map((_, colIndex) => ( + + ))} +
+ ))} +
+ ); +} + +export function SkeletonCard({ className }: { className?: string }) { + return ( +
+ + + +
+ ); +} + +export function SkeletonText({ + lines = 3, + className, + lastLineWidth = "w-1/2", +}: { + lines?: number; + className?: string; + lastLineWidth?: string; +}) { + return ( +
+ {Array.from({ length: lines - 1 }).map((_, index) => ( + + ))} + +
+ ); +} diff --git a/ui/components/ui/table/data-table-filter-custom.tsx b/ui/components/ui/table/data-table-filter-custom.tsx index 3a394c30ef..de2015619f 100644 --- a/ui/components/ui/table/data-table-filter-custom.tsx +++ b/ui/components/ui/table/data-table-filter-custom.tsx @@ -55,7 +55,7 @@ export const DataTableFilterCustom = ({ size="md" startContent={} onPress={() => setShowFilters(!showFilters)} - className="w-fit" + className="w-full max-w-fit" >

{showFilters ? "Hide Filters" : "Show Filters"} diff --git a/ui/components/ui/table/data-table-pagination.tsx b/ui/components/ui/table/data-table-pagination.tsx index a7773925e9..cb99c48c3d 100644 --- a/ui/components/ui/table/data-table-pagination.tsx +++ b/ui/components/ui/table/data-table-pagination.tsx @@ -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,90 +51,148 @@ 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 (
-
- {totalEntries} entries in Total. +
+ {totalEntries} entries in total
-
- {/* Rows per page selector */} -
-

Rows per page

- { + setSelectedPageSize(value); - const params = new URLSearchParams(searchParams); - params.set("pageSize", value); - params.set("page", "1"); + const params = new URLSearchParams(searchParams); - // This pushes the URL without reloading the page - router.push(`${pathname}?${params.toString()}`); - }} - > - - - - - {itemsPerPageOptions.map((pageSize) => ( - - {pageSize} - - ))} - - + // 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()}`); + } + }} + > + + + + + {itemsPerPageOptions.map((pageSize) => ( + + {pageSize} + + ))} + + +
+
+ Page {currentPage} of {totalPages} +
+
+ isFirstPage && e.preventDefault()} + > +
-
- Page {currentPage} of {totalPages} -
-
- -
-
+ )}
); } diff --git a/ui/components/ui/table/data-table.tsx b/ui/components/ui/table/data-table.tsx index b759e4316d..49ad556897 100644 --- a/ui/components/ui/table/data-table.tsx +++ b/ui/components/ui/table/data-table.tsx @@ -29,12 +29,14 @@ interface DataTableProviderProps { data: TData[]; metadata?: MetaDataProps; customFilters?: FilterOption[]; + disableScroll?: boolean; } export function DataTable({ columns, data, metadata, + disableScroll = false, }: DataTableProviderProps) { const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); @@ -109,7 +111,10 @@ export function DataTable({

{metadata && (
- +
)} diff --git a/ui/components/ui/table/status-finding-badge.tsx b/ui/components/ui/table/status-finding-badge.tsx index b9532d3b5d..8177d116d7 100644 --- a/ui/components/ui/table/status-finding-badge.tsx +++ b/ui/components/ui/table/status-finding-badge.tsx @@ -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 = ({ > {status.charAt(0).toUpperCase() + status.slice(1).toLowerCase()} + {value !== undefined && `: ${value}`} ); diff --git a/ui/components/users/profile/membership-item.tsx b/ui/components/users/profile/membership-item.tsx index 5db1e5cc6b..a22f33dda0 100644 --- a/ui/components/users/profile/membership-item.tsx +++ b/ui/components/users/profile/membership-item.tsx @@ -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"; diff --git a/ui/components/users/profile/memberships-card.tsx b/ui/components/users/profile/memberships-card.tsx index 43138c74c5..0aba1b4834 100644 --- a/ui/components/users/profile/memberships-card.tsx +++ b/ui/components/users/profile/memberships-card.tsx @@ -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"; diff --git a/ui/components/users/profile/role-item.tsx b/ui/components/users/profile/role-item.tsx index 460d9d5398..4d1ad7861e 100644 --- a/ui/components/users/profile/role-item.tsx +++ b/ui/components/users/profile/role-item.tsx @@ -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; diff --git a/ui/components/users/profile/roles-card.tsx b/ui/components/users/profile/roles-card.tsx index 5135c41528..126984be08 100644 --- a/ui/components/users/profile/roles-card.tsx +++ b/ui/components/users/profile/roles-card.tsx @@ -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"; diff --git a/ui/components/users/profile/user-basic-info-card.tsx b/ui/components/users/profile/user-basic-info-card.tsx index 37805b2c55..7085953062 100644 --- a/ui/components/users/profile/user-basic-info-card.tsx +++ b/ui/components/users/profile/user-basic-info-card.tsx @@ -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"; diff --git a/ui/lib/compliance/ens.tsx b/ui/lib/compliance/ens.tsx new file mode 100644 index 0000000000..bab025dd06 --- /dev/null +++ b/ui/lib/compliance/ens.tsx @@ -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(); + 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: ( + + ), + content: "", + items: framework.categories.map((category) => { + return { + key: `${framework.name}-${category.name}`, + title: ( + + ), + content: "", + items: category.controls.map((control, i: number) => { + return { + key: `${framework.name}-${category.name}-control-${i}`, + title: ( + + ), + content: "", + items: control.requirements.map((requirement, j: number) => { + const itemKey = `${framework.name}-${category.name}-control-${i}-req-${j}`; + + return { + key: itemKey, + title: ( + + ), + content: ( + + ), + items: [], + isDisabled: + requirement.check_ids.length === 0 && + requirement.manual === 0, + }; + }), + isDisabled: + control.pass === 0 && + control.fail === 0 && + control.manual === 0, + }; + }), + }; + }), + }; + }); +}; diff --git a/ui/lib/permissions.ts b/ui/lib/permissions.ts index 7315258a9d..7d1d4448e8 100644 --- a/ui/lib/permissions.ts +++ b/ui/lib/permissions.ts @@ -1,4 +1,4 @@ -import { RolePermissionAttributes } from "@/types/users/users"; +import { RolePermissionAttributes } from "@/types/users"; export const isUserOwnerAndHasManageAccount = ( roles: any[], diff --git a/ui/public/ens.png b/ui/public/ens.png new file mode 100644 index 0000000000..c3e6433f31 Binary files /dev/null and b/ui/public/ens.png differ diff --git a/ui/types/compliance.ts b/ui/types/compliance.ts new file mode 100644 index 0000000000..beeafcd836 --- /dev/null +++ b/ui/types/compliance.ts @@ -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[]; +} diff --git a/ui/types/components.ts b/ui/types/components.ts index f5928386d0..c8a8054f80 100644 --- a/ui/types/components.ts +++ b/ui/types/components.ts @@ -1,8 +1,6 @@ import { LucideIcon } from "lucide-react"; import { SVGProps } from "react"; -import { ProviderType } from "./providers"; - export type IconSvgProps = SVGProps & { 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 { diff --git a/ui/types/index.ts b/ui/types/index.ts index e35a2815da..1483946f3e 100644 --- a/ui/types/index.ts +++ b/ui/types/index.ts @@ -3,3 +3,4 @@ export * from "./components"; export * from "./filters"; export * from "./formSchemas"; export * from "./providers"; +export * from "./scans"; diff --git a/ui/types/scans.ts b/ui/types/scans.ts new file mode 100644 index 0000000000..ac8bfe64e6 --- /dev/null +++ b/ui/types/scans.ts @@ -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; + }; +} diff --git a/ui/types/users/users.ts b/ui/types/users.ts similarity index 100% rename from ui/types/users/users.ts rename to ui/types/users.ts diff --git a/ui/types/users/index.ts b/ui/types/users/index.ts deleted file mode 100644 index ddf77b4624..0000000000 --- a/ui/types/users/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./users";