mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-01-25 02:08:11 +00:00
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:
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
100
ui/lib/helper.ts
100
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<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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user