feat(scans): add download button column for completed scans in table (#7353)

This commit is contained in:
Pablo Lara
2025-03-24 15:22:36 +01:00
committed by Pepe Fagoaga
parent a4f5566589
commit a72d8d7c83
6 changed files with 156 additions and 47 deletions
+28 -3
View File
@@ -760,7 +760,7 @@ export const AddIcon: React.FC<IconSvgProps> = ({
};
export const ScheduleIcon: React.FC<IconSvgProps> = ({
size,
size = 24,
height,
width,
...props
@@ -768,8 +768,9 @@ export const ScheduleIcon: React.FC<IconSvgProps> = ({
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width || 24}
height={size || height || 24}
width={size || width}
height={size || height}
viewBox="0 0 24 24" // <- AÑADÍ ESTO
fill="none"
stroke="currentColor"
strokeWidth="2"
@@ -809,6 +810,30 @@ export const InfoIcon: React.FC<IconSvgProps> = ({
</svg>
);
export const ManualIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
<circle cx="12" cy="12" r="10" />
<polygon points="10 8 16 12 10 16 10 8" />
</svg>
);
export const SpinnerIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
@@ -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<ScanProps>[] = [
return <LinkToFindingsFromScan scanId={id} />;
},
},
{
id: "download",
header: () => (
<div className="flex items-end gap-x-1">
<p className="w-fit text-xs">Download</p>
<Tooltip
className="text-xs"
content="Download a ZIP file containing the JSON (OCSF), CSV, and HTML reports."
>
<div className="flex items-center gap-2">
<InfoIcon className="mb-1 text-primary" size={12} />
</div>
</Tooltip>
</div>
),
cell: ({ row }) => {
const scanId = row.original.id;
const scanState = row.original.attributes?.state;
return (
<div className="flex w-14 items-center justify-center">
<CustomButton
variant="ghost"
isDisabled={scanState !== "completed"}
onPress={() => downloadScanZip(scanId, toast)}
className="p-0 text-default-500 hover:text-primary disabled:opacity-30"
isIconOnly
ariaLabel="Download .zip"
size="sm"
>
<DownloadIcon size={16} />
</CustomButton>
</div>
);
},
},
// {
// accessorKey: "scanner_args",
// header: "Scanner Args",
@@ -121,7 +164,11 @@ export const ColumnGetScans: ColumnDef<ScanProps>[] = [
const {
attributes: { unique_resource_count },
} = getScanData(row);
return <p className="font-medium">{unique_resource_count}</p>;
return (
<div className="flex w-fit items-center justify-center">
<span className="text-xs font-medium">{unique_resource_count}</span>
</div>
);
},
},
{
@@ -163,7 +210,11 @@ export const ColumnGetScans: ColumnDef<ScanProps>[] = [
const {
attributes: { trigger },
} = getScanData(row);
return <p className="text-tiny font-medium uppercase">{trigger}</p>;
return (
<div className="flex w-9 items-center justify-center">
<TriggerIcon trigger={trigger} iconSize={16} />
</div>
);
},
},
{
@@ -179,8 +230,13 @@ export const ColumnGetScans: ColumnDef<ScanProps>[] = [
if (!name || name.length === 0) {
return <span className="font-medium">-</span>;
}
return <span className="text-xs font-medium">{name}</span>;
return (
<div className="flex w-fit items-center justify-center">
<span className="text-xs font-medium">
{name === "Daily scheduled scan" ? "scheduled scan" : name}
</span>
</div>
);
},
},
{
@@ -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<ScanProps>({
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 (
<>
<CustomAlertModal
@@ -103,13 +68,13 @@ export function DataTableRowActions<ScanProps>({
color="default"
variant="flat"
>
<DropdownSection title="Export artifacts">
<DropdownSection title="Download reports">
<DropdownItem
key="export"
description="Available only for completed scans"
textValue="Export Scan Artifacts"
textValue="Download .zip"
startContent={<DownloadIcon className={iconClasses} />}
onPress={handleExportZip}
onPress={() => downloadScanZip(scanId, toast)}
isDisabled={scanState !== "completed"}
>
Download .zip
+25
View File
@@ -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 (
<Tooltip
className="text-xs"
content={trigger === "scheduled" ? "Scheduled" : "Manual"}
>
<div className="h-fit">
{trigger === "scheduled" ? (
<ScheduleIcon size={iconSize} />
) : (
<ManualIcon size={iconSize} />
)}
</div>
</Tooltip>
);
}
+1 -1
View File
@@ -32,7 +32,7 @@ export const DateWithTime: React.FC<DateWithTimeProps> = ({
>
<span className="text-xs font-semibold">{formattedDate}</span>
{showTime && (
<span className="text-tiny text-gray-500">{formattedTime}</span>
<span className="text-xs text-gray-500">{formattedTime}</span>
)}
</div>
</div>
+38
View File
@@ -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<typeof useToast>["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;