diff --git a/ui/actions/scans/scans.ts b/ui/actions/scans/scans.ts index 0871bded69..c8a9c98da9 100644 --- a/ui/actions/scans/scans.ts +++ b/ui/actions/scans/scans.ts @@ -257,3 +257,42 @@ export const getExportsZip = async (scanId: string) => { }; } }; + +export const getComplianceCsv = async ( + scanId: string, + complianceId: string, +) => { + const headers = await getAuthHeaders({ contentType: false }); + + const url = new URL( + `${apiBaseUrl}/scans/${scanId}/compliance/${complianceId}`, + ); + + try { + const response = await fetch(url.toString(), { + headers, + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + errorData?.errors?.[0]?.detail || "Failed to fetch compliance report", + ); + } + + // Get the blob data as an array buffer + const arrayBuffer = await response.arrayBuffer(); + // Convert to base64 + const base64 = Buffer.from(arrayBuffer).toString("base64"); + + return { + success: true, + data: base64, + filename: `scan-${scanId}-compliance-${complianceId}.csv`, + }; + } catch (error) { + return { + error: getErrorMessage(error), + }; + } +}; diff --git a/ui/app/(prowler)/compliance/page.tsx b/ui/app/(prowler)/compliance/page.tsx index 3c1ff0cd0a..e3dab9e762 100644 --- a/ui/app/(prowler)/compliance/page.tsx +++ b/ui/app/(prowler)/compliance/page.tsx @@ -159,6 +159,7 @@ const SSRComplianceGrid = async ({ framework, version, requirements_status: { passed, total }, + compliance_id, } = attributes; return ( @@ -170,6 +171,8 @@ const SSRComplianceGrid = async ({ totalRequirements={total} prevPassingRequirements={passed} prevTotalRequirements={total} + scanId={scanId} + complianceId={compliance_id} /> ); })} diff --git a/ui/components/compliance/compliance-card.tsx b/ui/components/compliance/compliance-card.tsx index 55bb064bee..56b6cec402 100644 --- a/ui/components/compliance/compliance-card.tsx +++ b/ui/components/compliance/compliance-card.tsx @@ -1,7 +1,13 @@ +"use client"; + import { Card, CardBody, Progress } from "@nextui-org/react"; import Image from "next/image"; +import { useSearchParams } from "next/navigation"; import React from "react"; +import { DownloadIconButton, toast } from "@/components/ui"; +import { downloadComplianceCsv } from "@/lib/helper"; + import { getComplianceIcon } from "../icons"; interface ComplianceCardProps { @@ -11,6 +17,8 @@ interface ComplianceCardProps { totalRequirements: number; prevPassingRequirements: number; prevTotalRequirements: number; + scanId: string; + complianceId: string; } export const ComplianceCard: React.FC = ({ @@ -18,7 +26,12 @@ export const ComplianceCard: React.FC = ({ version, passingRequirements, totalRequirements, + scanId, + complianceId, }) => { + const searchParams = useSearchParams(); + const hasRegionFilter = searchParams.has("filter[region__in]"); + const formatTitle = (title: string) => { return title.split("-").join(" "); }; @@ -27,6 +40,8 @@ export const ComplianceCard: React.FC = ({ (passingRequirements / totalRequirements) * 100, ); + // Calculates the percentage change in passing requirements compared to the previous scan. + // // const prevRatingPercentage = Math.floor( // (prevPassingRequirements / prevTotalRequirements) * 100, // ); @@ -79,13 +94,22 @@ export const ComplianceCard: React.FC = ({ }} color={getRatingColor(ratingPercentage)} /> -
+
{passingRequirements} / {totalRequirements} Passing Requirements + + + downloadComplianceCsv(scanId, complianceId, toast) + } + textTooltip="Download compliance CSV report" + isDisabled={hasRegionFilter} + /> {/* {getScanChange()} */}
diff --git a/ui/components/scans/table/scans/column-get-scans.tsx b/ui/components/scans/table/scans/column-get-scans.tsx index 6316c6080e..f21be24299 100644 --- a/ui/components/scans/table/scans/column-get-scans.tsx +++ b/ui/components/scans/table/scans/column-get-scans.tsx @@ -9,6 +9,7 @@ import { DownloadIconButton, toast } from "@/components/ui"; import { DateWithTime, EntityInfoShort } from "@/components/ui/entities"; import { TriggerSheet } from "@/components/ui/sheet"; import { DataTableColumnHeader, StatusBadge } from "@/components/ui/table"; +import { downloadScanZip } from "@/lib/helper"; import { ScanProps } from "@/types"; import { LinkToFindingsFromScan } from "../../link-to-findings-from-scan"; @@ -135,6 +136,7 @@ export const ColumnGetScans: ColumnDef[] = [ return ( downloadScanZip(scanId, toast)} isDisabled={scanState !== "completed"} /> ); diff --git a/ui/components/ui/download-icon-button/download-icon-button.tsx b/ui/components/ui/download-icon-button/download-icon-button.tsx index ed78884543..bac9404723 100644 --- a/ui/components/ui/download-icon-button/download-icon-button.tsx +++ b/ui/components/ui/download-icon-button/download-icon-button.tsx @@ -1,34 +1,40 @@ "use client"; -import { downloadScanZip } from "@/lib/helper"; -import { CustomButton } from "../custom/custom-button"; -import { toast } from "@/components/ui"; +import { Tooltip } from "@nextui-org/react"; import { DownloadIcon } from "lucide-react"; +import { CustomButton } from "../custom/custom-button"; + interface DownloadIconButtonProps { paramId: string; + onDownload: (paramId: string) => void; ariaLabel?: string; isDisabled?: boolean; + textTooltip?: string; } export const DownloadIconButton = ({ paramId, + onDownload, ariaLabel = "Download report", isDisabled = false, + textTooltip = "Download report", }: DownloadIconButtonProps) => { return ( -
- downloadScanZip(paramId, toast)} - className="p-0 text-default-500 hover:text-primary disabled:opacity-30" - isIconOnly - ariaLabel={ariaLabel} - size="sm" - > - - +
+ + onDownload(paramId)} + className="p-0 text-default-500 hover:text-primary disabled:opacity-30" + isIconOnly + ariaLabel={ariaLabel} + size="sm" + > + + +
); }; diff --git a/ui/components/ui/index.ts b/ui/components/ui/index.ts index ec399fbce3..181ad5e4c1 100644 --- a/ui/components/ui/index.ts +++ b/ui/components/ui/index.ts @@ -4,6 +4,7 @@ export * from "./alert-dialog/AlertDialog"; export * from "./chart/Chart"; export * from "./content-layout/content-layout"; export * from "./dialog/dialog"; +export * from "./download-icon-button/download-icon-button"; export * from "./dropdown/Dropdown"; export * from "./headers/navigation-header"; export * from "./label/Label"; @@ -11,4 +12,3 @@ export * from "./main-layout/main-layout"; export * from "./select/Select"; export * from "./sidebar"; export * from "./toast"; -export * from "./download-icon-button/download-icon-button"; diff --git a/ui/lib/helper.ts b/ui/lib/helper.ts index e8e7a9e71c..c7d0f02a87 100644 --- a/ui/lib/helper.ts +++ b/ui/lib/helper.ts @@ -1,4 +1,4 @@ -import { getExportsZip } from "@/actions/scans"; +import { getComplianceCsv, getExportsZip } from "@/actions/scans"; import { getTask } from "@/actions/task"; import { auth } from "@/auth.config"; import { useToast } from "@/components/ui"; @@ -91,6 +91,43 @@ export const downloadScanZip = async ( } }; +export const downloadComplianceCsv = async ( + scanId: string, + complianceId: string, + toast: ReturnType["toast"], +) => { + const result = await getComplianceCsv(scanId, complianceId); + + if (result?.success && result?.data) { + const binaryString = window.atob(result.data); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + const blob = new Blob([bytes], { type: "text/csv" }); + + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = result.filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + + toast({ + title: "Download Complete", + description: "The compliance report has been downloaded successfully.", + }); + } else if (result?.error) { + toast({ + variant: "destructive", + title: "Download Failed", + description: result.error, + }); + } +}; + export const isGoogleOAuthEnabled = !!process.env.SOCIAL_GOOGLE_OAUTH_CLIENT_ID && !!process.env.SOCIAL_GOOGLE_OAUTH_CLIENT_SECRET;