From 24784f2ce5062cfb53ac1bd9e4571a97252c0d03 Mon Sep 17 00:00:00 2001 From: Pablo Lara Date: Mon, 24 Mar 2025 15:22:36 +0100 Subject: [PATCH] feat(scans): add download button column for completed scans in table (#7353) --- ui/components/icons/Icons.tsx | 31 ++++++++- .../scans/table/scans/column-get-scans.tsx | 64 +++++++++++++++++-- .../table/scans/data-table-row-actions.tsx | 43 ++----------- ui/components/scans/trigger-icon.tsx | 25 ++++++++ ui/components/ui/entities/date-with-time.tsx | 2 +- ui/lib/helper.ts | 38 +++++++++++ 6 files changed, 156 insertions(+), 47 deletions(-) create mode 100644 ui/components/scans/trigger-icon.tsx diff --git a/ui/components/icons/Icons.tsx b/ui/components/icons/Icons.tsx index b2ebc70fd2..4cf36bc6c7 100644 --- a/ui/components/icons/Icons.tsx +++ b/ui/components/icons/Icons.tsx @@ -760,7 +760,7 @@ export const AddIcon: React.FC = ({ }; export const ScheduleIcon: React.FC = ({ - size, + size = 24, height, width, ...props @@ -768,8 +768,9 @@ export const ScheduleIcon: React.FC = ({ return ( = ({ ); +export const ManualIcon: React.FC = ({ + size = 24, + width, + height, + ...props +}) => ( + +); + export const SpinnerIcon: React.FC = ({ size = 24, width, diff --git a/ui/components/scans/table/scans/column-get-scans.tsx b/ui/components/scans/table/scans/column-get-scans.tsx index 6fbbcdcc3e..b81ffd6287 100644 --- a/ui/components/scans/table/scans/column-get-scans.tsx +++ b/ui/components/scans/table/scans/column-get-scans.tsx @@ -1,15 +1,21 @@ "use client"; +import { Tooltip } from "@nextui-org/react"; import { ColumnDef } from "@tanstack/react-table"; +import { DownloadIcon } from "lucide-react"; import { useSearchParams } from "next/navigation"; import { InfoIcon } from "@/components/icons"; +import { toast } from "@/components/ui"; +import { CustomButton } from "@/components/ui/custom"; 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"; +import { TriggerIcon } from "../../trigger-icon"; import { DataTableRowActions } from "./data-table-row-actions"; import { DataTableRowDetails } from "./data-table-row-details"; @@ -104,6 +110,43 @@ export const ColumnGetScans: ColumnDef[] = [ return ; }, }, + { + id: "download", + header: () => ( +
+

Download

+ +
+ +
+
+
+ ), + cell: ({ row }) => { + const scanId = row.original.id; + const scanState = row.original.attributes?.state; + + return ( +
+ downloadScanZip(scanId, toast)} + className="p-0 text-default-500 hover:text-primary disabled:opacity-30" + isIconOnly + ariaLabel="Download .zip" + size="sm" + > + + +
+ ); + }, + }, + // { // accessorKey: "scanner_args", // header: "Scanner Args", @@ -121,7 +164,11 @@ export const ColumnGetScans: ColumnDef[] = [ const { attributes: { unique_resource_count }, } = getScanData(row); - return

{unique_resource_count}

; + return ( +
+ {unique_resource_count} +
+ ); }, }, { @@ -163,7 +210,11 @@ export const ColumnGetScans: ColumnDef[] = [ const { attributes: { trigger }, } = getScanData(row); - return

{trigger}

; + return ( +
+ +
+ ); }, }, { @@ -179,8 +230,13 @@ export const ColumnGetScans: ColumnDef[] = [ if (!name || name.length === 0) { return -; } - - return {name}; + return ( +
+ + {name === "Daily scheduled scan" ? "scheduled scan" : name} + +
+ ); }, }, { diff --git a/ui/components/scans/table/scans/data-table-row-actions.tsx b/ui/components/scans/table/scans/data-table-row-actions.tsx index 46c1111bd3..ad61a174e8 100644 --- a/ui/components/scans/table/scans/data-table-row-actions.tsx +++ b/ui/components/scans/table/scans/data-table-row-actions.tsx @@ -16,10 +16,10 @@ import { Row } from "@tanstack/react-table"; import { DownloadIcon } from "lucide-react"; import { useState } from "react"; -import { getExportsZip } from "@/actions/scans"; import { VerticalDotsIcon } from "@/components/icons"; import { useToast } from "@/components/ui"; import { CustomAlertModal } from "@/components/ui/custom"; +import { downloadScanZip } from "@/lib/helper"; import { EditScanForm } from "../../forms"; @@ -38,41 +38,6 @@ export function DataTableRowActions({ const scanName = (row.original as any).attributes?.name; const scanState = (row.original as any).attributes?.state; - const handleExportZip = async () => { - const result = await getExportsZip(scanId); - - if (result?.success && result?.data) { - // Convert base64 to blob - 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" }); - - // Create download link - 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: "Your scan report has been downloaded successfully.", - }); - } else if (result?.error) { - toast({ - variant: "destructive", - title: "Download Failed", - description: result.error, - }); - } - }; - return ( <> ({ color="default" variant="flat" > - + } - onPress={handleExportZip} + onPress={() => downloadScanZip(scanId, toast)} isDisabled={scanState !== "completed"} > Download .zip diff --git a/ui/components/scans/trigger-icon.tsx b/ui/components/scans/trigger-icon.tsx new file mode 100644 index 0000000000..4176a1e5da --- /dev/null +++ b/ui/components/scans/trigger-icon.tsx @@ -0,0 +1,25 @@ +import { Tooltip } from "@nextui-org/react"; + +import { ManualIcon, ScheduleIcon } from "@/components/icons"; + +interface TriggerIconProps { + trigger: "scheduled" | "manual"; + iconSize?: number; +} + +export function TriggerIcon({ trigger, iconSize = 24 }: TriggerIconProps) { + return ( + +
+ {trigger === "scheduled" ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/ui/components/ui/entities/date-with-time.tsx b/ui/components/ui/entities/date-with-time.tsx index 17600762c1..50e0efe8b5 100644 --- a/ui/components/ui/entities/date-with-time.tsx +++ b/ui/components/ui/entities/date-with-time.tsx @@ -32,7 +32,7 @@ export const DateWithTime: React.FC = ({ > {formattedDate} {showTime && ( - {formattedTime} + {formattedTime} )} diff --git a/ui/lib/helper.ts b/ui/lib/helper.ts index b0eaef6221..3e70760d8d 100644 --- a/ui/lib/helper.ts +++ b/ui/lib/helper.ts @@ -1,4 +1,6 @@ +import { getExportsZip } from "@/actions/scans"; import { getTask } from "@/actions/task"; +import { useToast } from "@/components/ui"; import { AuthSocialProvider, MetaDataProps, PermissionInfo } from "@/types"; export const baseUrl = process.env.AUTH_URL || "http://localhost:3000"; @@ -37,6 +39,42 @@ export const getAuthUrl = (provider: AuthSocialProvider) => { return url.toString(); }; +export const downloadScanZip = async ( + scanId: string, + toast: ReturnType["toast"], +) => { + const result = await getExportsZip(scanId); + + 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 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: "Your scan 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;