fix(ui): updated to use the correct message when download report clicked (#7758)

Co-authored-by: Pablo Lara <larabjj@gmail.com>
This commit is contained in:
sumit-tft
2025-05-27 14:21:08 +05:30
committed by GitHub
parent f254a4bc0d
commit ea97de7f43
10 changed files with 172 additions and 59 deletions

View File

@@ -10,6 +10,7 @@ All notable changes to the **Prowler UI** are documented in this file.
- 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)
---

View File

@@ -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 {

View File

@@ -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<ComplianceCardProps> = ({
}) => {
const searchParams = useSearchParams();
const hasRegionFilter = searchParams.has("filter[region__in]");
const [isDownloading, setIsDownloading] = useState<boolean>(false);
const formatTitle = (title: string) => {
return title.split("-").join(" ");
@@ -67,6 +68,15 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
return "success";
};
const handleDownload = async () => {
setIsDownloading(true);
try {
await downloadComplianceCsv(scanId, complianceId, toast);
} finally {
setIsDownloading(false);
}
};
return (
<Card fullWidth isHoverable shadow="sm">
<CardBody className="flex flex-row items-center justify-between space-x-4 dark:bg-prowler-blue-800">
@@ -104,11 +114,10 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
<DownloadIconButton
paramId={complianceId}
onDownload={() =>
downloadComplianceCsv(scanId, complianceId, toast)
}
onDownload={handleDownload}
textTooltip="Download compliance CSV report"
isDisabled={hasRegionFilter}
isDownloading={isDownloading}
/>
{/* <small>{getScanChange()}</small> */}
</div>

View File

@@ -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<ScanProps>[] = [
</div>
),
cell: ({ row }) => {
const scanId = row.original.id;
const scanState = row.original.attributes?.state;
return (
<DownloadIconButton
paramId={scanId}
onDownload={() => downloadScanZip(scanId, toast)}
isDisabled={scanState !== "completed"}
/>
<div className="mx-auto w-fit">
<DataTableDownloadDetails row={row} />
</div>
);
},
},

View File

@@ -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<ScanProps> {
row: Row<ScanProps>;
}
export function DataTableDownloadDetails<ScanProps>({
row,
}: DataTableDownloadDetailsProps<ScanProps>) {
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 (
<DownloadIconButton
paramId={scanId}
onDownload={handleDownload}
isDownloading={isDownloading}
isDisabled={scanState !== "completed"}
/>
);
}

View File

@@ -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 (
<div className="flex items-center justify-end">
<Tooltip content={textTooltip} className="text-xs">
<CustomButton
variant="ghost"
isDisabled={isDisabled}
isDisabled={isDisabled || isDownloading}
onPress={() => onDownload(paramId)}
className="p-0 text-default-500 hover:text-primary disabled:opacity-30"
isIconOnly
ariaLabel={ariaLabel}
size="sm"
>
<DownloadIcon size={16} />
<DownloadIcon
className={isDownloading ? "animate-download-icon" : ""}
size={16}
/>
</CustomButton>
</Tooltip>
</div>

View File

@@ -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<typeof useToast>["toast"],
) => {
): Promise<void> => {
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));

View File

@@ -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;
}
}

View File

@@ -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.

View File

@@ -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"),
password: z
.string()
.nonempty("Password is required"),
password: z.string().nonempty("Password is required"),
}
: {}),
});