From ad662fe245845ef3a996b2e5a6e8afba9bb38319 Mon Sep 17 00:00:00 2001 From: Prowler Bot Date: Tue, 27 May 2025 11:12:22 +0200 Subject: [PATCH] fix(ui): updated to use the correct message when download report clicked (#7847) Co-authored-by: sumit-tft <70506234+sumit-tft@users.noreply.github.com> Co-authored-by: Pablo Lara --- ui/CHANGELOG.md | 7 ++ ui/actions/scans/scans.ts | 35 ++++-- ui/components/compliance/compliance-card.tsx | 17 ++- .../scans/table/scans/column-get-scans.tsx | 14 +-- .../scans/data-table-download-details.tsx | 34 ++++++ .../download-icon-button.tsx | 9 +- ui/lib/helper.ts | 100 ++++++++++++------ ui/styles/globals.css | 11 ++ ui/tailwind.config.js | 6 ++ ui/types/formSchemas.ts | 4 +- 10 files changed, 178 insertions(+), 59 deletions(-) create mode 100644 ui/components/scans/table/scans/data-table-download-details.tsx diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index c75fed37fd..c847227e85 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -7,6 +7,13 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🚀 Added - New profile page with details about the user and their roles. [(#7780)](https://github.com/prowler-cloud/prowler/pull/7780) +<<<<<<< HEAD +======= +- Improved `SnippetChip` component and show resource name in new findings table. [(#7813)](https://github.com/prowler-cloud/prowler/pull/7813) +- Possibility to edit the organization name. [(#7829)](https://github.com/prowler-cloud/prowler/pull/7829) +- Add `Provider UID` filter to scans page. [(#7820)](https://github.com/prowler-cloud/prowler/pull/7820) +- Download report behaviour updated to show feedback based on API response. [(#7758)](https://github.com/prowler-cloud/prowler/pull/7758) +>>>>>>> ea97de7f4 (fix(ui): updated to use the correct message when download report clicked (#7758)) --- diff --git a/ui/actions/scans/scans.ts b/ui/actions/scans/scans.ts index 39e17a4b42..b3115ea0e9 100644 --- a/ui/actions/scans/scans.ts +++ b/ui/actions/scans/scans.ts @@ -236,10 +236,23 @@ export const getExportsZip = async (scanId: string) => { headers, }); + if (response.status === 202) { + const json = await response.json(); + const taskId = json?.data?.id; + const state = json?.data?.attributes?.state; + return { + pending: true, + state, + taskId, + }; + } + if (!response.ok) { const errorData = await response.json(); + throw new Error( - errorData?.errors?.[0]?.detail || "Failed to fetch report", + errorData?.errors?.detail || + "Unable to fetch scan report. Contact support if the issue continues.", ); } @@ -271,20 +284,28 @@ export const getComplianceCsv = async ( ); try { - const response = await fetch(url.toString(), { - headers, - }); + const response = await fetch(url.toString(), { headers }); + + if (response.status === 202) { + const json = await response.json(); + const taskId = json?.data?.id; + const state = json?.data?.attributes?.state; + return { + pending: true, + state, + taskId, + }; + } if (!response.ok) { const errorData = await response.json(); throw new Error( - errorData?.errors?.[0]?.detail || "Failed to fetch compliance report", + errorData?.errors?.detail || + "Unable to retrieve compliance report. Contact support if the issue continues.", ); } - // Get the blob data as an array buffer const arrayBuffer = await response.arrayBuffer(); - // Convert to base64 const base64 = Buffer.from(arrayBuffer).toString("base64"); return { diff --git a/ui/components/compliance/compliance-card.tsx b/ui/components/compliance/compliance-card.tsx index 56b6cec402..ff55421681 100644 --- a/ui/components/compliance/compliance-card.tsx +++ b/ui/components/compliance/compliance-card.tsx @@ -3,7 +3,7 @@ import { Card, CardBody, Progress } from "@nextui-org/react"; import Image from "next/image"; import { useSearchParams } from "next/navigation"; -import React from "react"; +import React, { useState } from "react"; import { DownloadIconButton, toast } from "@/components/ui"; import { downloadComplianceCsv } from "@/lib/helper"; @@ -31,6 +31,7 @@ export const ComplianceCard: React.FC = ({ }) => { const searchParams = useSearchParams(); const hasRegionFilter = searchParams.has("filter[region__in]"); + const [isDownloading, setIsDownloading] = useState(false); const formatTitle = (title: string) => { return title.split("-").join(" "); @@ -67,6 +68,15 @@ export const ComplianceCard: React.FC = ({ return "success"; }; + const handleDownload = async () => { + setIsDownloading(true); + try { + await downloadComplianceCsv(scanId, complianceId, toast); + } finally { + setIsDownloading(false); + } + }; + return ( @@ -104,11 +114,10 @@ export const ComplianceCard: React.FC = ({ - downloadComplianceCsv(scanId, complianceId, toast) - } + onDownload={handleDownload} textTooltip="Download compliance CSV report" isDisabled={hasRegionFilter} + isDownloading={isDownloading} /> {/* {getScanChange()} */} diff --git a/ui/components/scans/table/scans/column-get-scans.tsx b/ui/components/scans/table/scans/column-get-scans.tsx index 9082618eb8..4d6796b6bc 100644 --- a/ui/components/scans/table/scans/column-get-scans.tsx +++ b/ui/components/scans/table/scans/column-get-scans.tsx @@ -5,15 +5,14 @@ import { ColumnDef } from "@tanstack/react-table"; import { useSearchParams } from "next/navigation"; import { InfoIcon } from "@/components/icons"; -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 { ProviderType, ScanProps } from "@/types"; import { LinkToFindingsFromScan } from "../../link-to-findings-from-scan"; import { TriggerIcon } from "../../trigger-icon"; +import { DataTableDownloadDetails } from "./data-table-download-details"; import { DataTableRowActions } from "./data-table-row-actions"; import { DataTableRowDetails } from "./data-table-row-details"; @@ -130,15 +129,10 @@ export const ColumnGetScans: ColumnDef[] = [ ), cell: ({ row }) => { - const scanId = row.original.id; - const scanState = row.original.attributes?.state; - return ( - downloadScanZip(scanId, toast)} - isDisabled={scanState !== "completed"} - /> +
+ +
); }, }, diff --git a/ui/components/scans/table/scans/data-table-download-details.tsx b/ui/components/scans/table/scans/data-table-download-details.tsx new file mode 100644 index 0000000000..4579ef3c56 --- /dev/null +++ b/ui/components/scans/table/scans/data-table-download-details.tsx @@ -0,0 +1,34 @@ +import { Row } from "@tanstack/react-table"; +import { useState } from "react"; + +import { DownloadIconButton, useToast } from "@/components/ui"; +import { downloadScanZip } from "@/lib"; + +interface DataTableDownloadDetailsProps { + row: Row; +} + +export function DataTableDownloadDetails({ + row, +}: DataTableDownloadDetailsProps) { + const { toast } = useToast(); + const [isDownloading, setIsDownloading] = useState(false); + + const scanId = (row.original as { id: string }).id; + const scanState = (row.original as any).attributes?.state; + + const handleDownload = async () => { + setIsDownloading(true); + await downloadScanZip(scanId, toast); + setIsDownloading(false); + }; + + return ( + + ); +} 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 364ae0fe39..e84ecfa568 100644 --- a/ui/components/ui/download-icon-button/download-icon-button.tsx +++ b/ui/components/ui/download-icon-button/download-icon-button.tsx @@ -11,6 +11,7 @@ interface DownloadIconButtonProps { ariaLabel?: string; isDisabled?: boolean; textTooltip?: string; + isDownloading?: boolean; } export const DownloadIconButton = ({ @@ -19,20 +20,24 @@ export const DownloadIconButton = ({ ariaLabel = "Download report", isDisabled, textTooltip = "Download report", + isDownloading = false, }: DownloadIconButtonProps) => { return (
onDownload(paramId)} className="p-0 text-default-500 hover:text-primary disabled:opacity-30" isIconOnly ariaLabel={ariaLabel} size="sm" > - +
diff --git a/ui/lib/helper.ts b/ui/lib/helper.ts index db664e2a18..7a13fb266f 100644 --- a/ui/lib/helper.ts +++ b/ui/lib/helper.ts @@ -61,14 +61,22 @@ export const downloadScanZip = async ( ) => { const result = await getExportsZip(scanId); - if (result?.success && result?.data) { + if (result?.pending) { + toast({ + title: "The report is still being generated", + description: "Please try again in a few minutes.", + }); + return; + } + + 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: "application/zip" }); + const blob = new Blob([bytes], { type: "application/zip" }); const url = window.URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; @@ -82,11 +90,11 @@ export const downloadScanZip = async ( title: "Download Complete", description: "Your scan report has been downloaded successfully.", }); - } else if (result?.error) { + } else { toast({ variant: "destructive", title: "Download Failed", - description: result.error, + description: result?.error || "An unknown error occurred.", }); } }; @@ -95,37 +103,64 @@ export const downloadComplianceCsv = async ( scanId: string, complianceId: string, toast: ReturnType["toast"], -) => { +): Promise => { 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); - + if (result?.pending) { toast({ - title: "Download Complete", - description: "The compliance report has been downloaded successfully.", + title: "The report is still being generated", + description: "Please try again in a few minutes.", }); - } else if (result?.error) { + return; + } + + if (result?.success && result.data) { + try { + 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.", + }); + } catch (error) { + toast({ + variant: "destructive", + title: "Download Failed", + description: "An error occurred while processing the file.", + }); + } + return; + } + + if (result?.error) { toast({ variant: "destructive", title: "Download Failed", description: result.error, }); + return; } + + // Unexpected case + toast({ + variant: "destructive", + title: "Download Failed", + description: "Unexpected response. Please try again later.", + }); }; export const isGoogleOAuthEnabled = @@ -136,13 +171,12 @@ export const isGithubOAuthEnabled = !!process.env.SOCIAL_GITHUB_OAUTH_CLIENT_ID && !!process.env.SOCIAL_GITHUB_OAUTH_CLIENT_SECRET; -export async function checkTaskStatus( +export const checkTaskStatus = async ( taskId: string, -): Promise<{ completed: boolean; error?: string }> { - const MAX_RETRIES = 20; // Define the maximum number of attempts before stopping the polling - const RETRY_DELAY = 1000; // Delay time between each poll (in milliseconds) - - for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + maxRetries: number = 20, + retryDelay: number = 1500, +): Promise<{ completed: boolean; error?: string }> => { + for (let attempt = 0; attempt < maxRetries; attempt++) { const task = await getTask(taskId); if (task.error) { @@ -162,7 +196,7 @@ export async function checkTaskStatus( case "scheduled": case "executing": // Continue waiting if the task is still in progress - await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY)); + await new Promise((resolve) => setTimeout(resolve, retryDelay)); break; default: return { completed: false, error: "Unexpected task state" }; @@ -170,7 +204,7 @@ export async function checkTaskStatus( } return { completed: false, error: "Max retries exceeded" }; -} +}; export const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/ui/styles/globals.css b/ui/styles/globals.css index 3d8d40186d..f3b0a3e4ae 100644 --- a/ui/styles/globals.css +++ b/ui/styles/globals.css @@ -32,6 +32,7 @@ } @layer utilities { + /* Hide scrollbar */ .no-scrollbar { scrollbar-width: none; @@ -41,3 +42,13 @@ @apply mr-2 bg-background; } } + +@layer components { + + .animate-download-icon polyline, + .animate-download-icon line { + @apply animate-drop-arrow; + transform-box: fill-box; + transform-origin: center; + } +} \ No newline at end of file diff --git a/ui/tailwind.config.js b/ui/tailwind.config.js index 8245f1971f..664e360167 100644 --- a/ui/tailwind.config.js +++ b/ui/tailwind.config.js @@ -170,10 +170,16 @@ module.exports = { "50%": { left: "20%", width: "80%" }, "100%": { left: "100%", width: "100%" }, }, + dropArrow: { + '0%': { transform: 'translateY(-8px)', opacity: '0' }, + '50%': { opacity: '1' }, + '100%': { transform: 'translateY(0)', opacity: '1' }, + }, }, animation: { "collapsible-down": "collapsible-down 0.2s ease-out", "collapsible-up": "collapsible-up 0.2s ease-out", + "drop-arrow": "dropArrow 0.6s ease-out infinite", }, screens: { "3xl": "1920px", // Add breakpoint to optimize layouts for large screens. diff --git a/ui/types/formSchemas.ts b/ui/types/formSchemas.ts index b0c64dfa24..217cca4e11 100644 --- a/ui/types/formSchemas.ts +++ b/ui/types/formSchemas.ts @@ -142,9 +142,7 @@ export const addCredentialsFormSchema = (providerType: string) => .nonempty("Client Secret is required"), tenant_id: z.string().nonempty("Tenant ID is required"), user: z.string().nonempty("User is required"), - encrypted_password: z - .string() - .nonempty("Encrypted Password is required"), + password: z.string().nonempty("Password is required"), } : {}), });