mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 21:11:53 +00:00
feat(scans): update the progress for executing scans (#6972)
This commit is contained in:
+70
-21
@@ -48,6 +48,42 @@ export const getScans = async ({
|
||||
}
|
||||
};
|
||||
|
||||
export const getScansByState = async () => {
|
||||
const session = await auth();
|
||||
|
||||
const keyServer = process.env.API_BASE_URL;
|
||||
const url = new URL(`${keyServer}/scans`);
|
||||
|
||||
// Request only the necessary fields to optimize the response
|
||||
url.searchParams.append("fields[scans]", "state");
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
Accept: "application/vnd.api+json",
|
||||
Authorization: `Bearer ${session?.accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData?.message || "Failed to fetch scans by state");
|
||||
} catch {
|
||||
throw new Error("Failed to fetch scans by state");
|
||||
}
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return parseStringify(data);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error fetching scans by state:", error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const getScan = async (scanId: string) => {
|
||||
const session = await auth();
|
||||
|
||||
@@ -77,11 +113,30 @@ export const scanOnDemand = async (formData: FormData) => {
|
||||
const keyServer = process.env.API_BASE_URL;
|
||||
|
||||
const providerId = formData.get("providerId");
|
||||
const scanName = formData.get("scanName");
|
||||
const scanName = formData.get("scanName") || undefined;
|
||||
|
||||
if (!providerId) {
|
||||
return { error: "Provider ID is required" };
|
||||
}
|
||||
|
||||
const url = new URL(`${keyServer}/scans`);
|
||||
|
||||
try {
|
||||
const requestBody = {
|
||||
data: {
|
||||
type: "scans",
|
||||
attributes: scanName ? { name: scanName } : {},
|
||||
relationships: {
|
||||
provider: {
|
||||
data: {
|
||||
type: "providers",
|
||||
id: providerId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -89,32 +144,26 @@ export const scanOnDemand = async (formData: FormData) => {
|
||||
Accept: "application/vnd.api+json",
|
||||
Authorization: `Bearer ${session?.accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "scans",
|
||||
attributes: {
|
||||
name: scanName,
|
||||
},
|
||||
relationships: {
|
||||
provider: {
|
||||
data: {
|
||||
type: "providers",
|
||||
id: providerId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData?.message || "Failed to start scan");
|
||||
} catch {
|
||||
throw new Error("Failed to start scan");
|
||||
}
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
revalidatePath("/scans");
|
||||
return parseStringify(data);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
return {
|
||||
error: getErrorMessage(error),
|
||||
};
|
||||
console.error("Error starting scan:", error);
|
||||
return { error: getErrorMessage(error) };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@ import { Spacer } from "@nextui-org/react";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getProvider, getProviders } from "@/actions/providers";
|
||||
import { getScans } from "@/actions/scans";
|
||||
import { getScans, getScansByState } from "@/actions/scans";
|
||||
import { FilterControls, filterScans } from "@/components/filters";
|
||||
import {
|
||||
ButtonRefreshData,
|
||||
AutoRefresh,
|
||||
NoProvidersAdded,
|
||||
NoProvidersConnected,
|
||||
} from "@/components/scans";
|
||||
@@ -14,7 +14,7 @@ import { SkeletonTableScans } from "@/components/scans/table";
|
||||
import { ColumnGetScans } from "@/components/scans/table/scans";
|
||||
import { Header } from "@/components/ui";
|
||||
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
|
||||
import { ProviderProps, SearchParamsProps } from "@/types";
|
||||
import { ProviderProps, ScanProps, SearchParamsProps } from "@/types";
|
||||
|
||||
export default async function Scans({
|
||||
searchParams,
|
||||
@@ -32,7 +32,7 @@ export default async function Scans({
|
||||
});
|
||||
|
||||
const providerInfo =
|
||||
providersData?.data.map((provider: ProviderProps) => ({
|
||||
providersData?.data?.map((provider: ProviderProps) => ({
|
||||
providerId: provider.id,
|
||||
alias: provider.attributes.alias,
|
||||
providerType: provider.attributes.provider,
|
||||
@@ -48,6 +48,12 @@ export default async function Scans({
|
||||
(provider: ProviderProps) => !provider.attributes.connection.connected,
|
||||
);
|
||||
|
||||
// Get scans data to check for executing scans
|
||||
const scansData = await getScansByState();
|
||||
const hasExecutingScan = scansData?.data?.some(
|
||||
(scan: ScanProps) => scan.attributes.state === "executing",
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{thereIsNoProviders && (
|
||||
@@ -70,7 +76,7 @@ export default async function Scans({
|
||||
) : (
|
||||
<>
|
||||
<Header title="Scans" icon="lucide:scan-search" />
|
||||
|
||||
<AutoRefresh hasExecutingScan={hasExecutingScan} />
|
||||
<LaunchScanWorkflow providers={providerInfo} />
|
||||
<Spacer y={8} />
|
||||
</>
|
||||
@@ -82,12 +88,6 @@ export default async function Scans({
|
||||
<DataTableFilterCustom filters={filterScans || []} />
|
||||
<Spacer x={4} />
|
||||
<FilterControls />
|
||||
<ButtonRefreshData
|
||||
onPress={async () => {
|
||||
"use server";
|
||||
await getScans({});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Spacer y={8} />
|
||||
<Suspense key={searchParamsKey} fallback={<SkeletonTableScans />}>
|
||||
|
||||
@@ -101,7 +101,10 @@ export const FindingDetail = ({
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Check ID" variant="simple">
|
||||
<Snippet className="max-w-full" hideSymbol>
|
||||
<Snippet
|
||||
className="max-w-full bg-gray-50 py-1 dark:bg-slate-800"
|
||||
hideSymbol
|
||||
>
|
||||
{attributes.check_id}
|
||||
</Snippet>
|
||||
</InfoField>
|
||||
@@ -160,7 +163,7 @@ export const FindingDetail = ({
|
||||
{/* CLI Command section */}
|
||||
{attributes.check_metadata.remediation.code.cli && (
|
||||
<InfoField label="CLI Command" variant="simple">
|
||||
<Snippet>
|
||||
<Snippet className="bg-gray-50 py-1 dark:bg-slate-800">
|
||||
<span className="whitespace-pre-line text-xs">
|
||||
{attributes.check_metadata.remediation.code.cli}
|
||||
</span>
|
||||
@@ -191,7 +194,7 @@ export const FindingDetail = ({
|
||||
{/* Resource Details */}
|
||||
<Section title="Resource Details">
|
||||
<InfoField label="Resource ID" variant="simple">
|
||||
<Snippet hideSymbol>
|
||||
<Snippet className="bg-gray-50 py-1 dark:bg-slate-800" hideSymbol>
|
||||
<span className="whitespace-pre-line text-xs">
|
||||
{renderValue(resource.uid)}
|
||||
</span>
|
||||
|
||||
@@ -808,3 +808,27 @@ export const InfoIcon: React.FC<IconSvgProps> = ({
|
||||
<path d="M12 16v-4M12 8h.01" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SpinnerIcon: React.FC<IconSvgProps> = ({
|
||||
size = 24,
|
||||
width,
|
||||
height,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size || width}
|
||||
height={size || height}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
<path d="M20 4v5h-.582m0 0a8.001 8.001 0 00-15.356 2m15.356-2H15M4 20v-5h.581m0 0a8.003 8.003 0 0015.357-2M4.581 15H9" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -98,7 +98,7 @@ export const InvitationDetails = ({ attributes }: InvitationDetailsProps) => {
|
||||
}}
|
||||
hideSymbol
|
||||
variant="bordered"
|
||||
className="overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
className="overflow-hidden text-ellipsis whitespace-nowrap bg-gray-50 py-1 dark:bg-slate-800"
|
||||
>
|
||||
<p className="no-scrollbar w-fit overflow-hidden overflow-x-scroll text-ellipsis whitespace-nowrap text-small">
|
||||
{invitationLink}
|
||||
|
||||
@@ -105,7 +105,11 @@ export const FindingDetail = ({
|
||||
{remediation.code.cli && (
|
||||
<div>
|
||||
<p className="text-sm font-semibold">CLI Command:</p>
|
||||
<Snippet hideSymbol size="sm" className="max-w-full">
|
||||
<Snippet
|
||||
hideSymbol
|
||||
size="sm"
|
||||
className="max-w-full bg-gray-50 py-1 dark:bg-slate-800"
|
||||
>
|
||||
<p className="whitespace-pre-line">
|
||||
{remediation.code.cli}
|
||||
</p>
|
||||
@@ -148,7 +152,11 @@ export const FindingDetail = ({
|
||||
<p className="text-sm font-semibold dark:text-prowler-theme-pale">
|
||||
Resource ID
|
||||
</p>
|
||||
<Snippet size="sm" hideSymbol className="max-w-full">
|
||||
<Snippet
|
||||
size="sm"
|
||||
hideSymbol
|
||||
className="max-w-full bg-gray-50 py-1 dark:bg-slate-800"
|
||||
>
|
||||
<p className="whitespace-pre-line">{resource.uid}</p>
|
||||
</Snippet>
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,11 @@ export const CredentialsRoleHelper = () => {
|
||||
<p className="text-xs font-bold text-gray-600 dark:text-gray-400">
|
||||
The External ID will also be required:
|
||||
</p>
|
||||
<Snippet className="max-w-full py-1" color="warning" hideSymbol>
|
||||
<Snippet
|
||||
className="max-w-full bg-gray-50 py-1 dark:bg-slate-800"
|
||||
color="warning"
|
||||
hideSymbol
|
||||
>
|
||||
<p className="whitespace-pre-line text-xs font-bold">
|
||||
{session?.tenantId}
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface AutoRefreshProps {
|
||||
hasExecutingScan: boolean;
|
||||
}
|
||||
|
||||
export function AutoRefresh({ hasExecutingScan }: AutoRefreshProps) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasExecutingScan) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
router.refresh();
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [hasExecutingScan, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { RefreshCcwIcon } from "lucide-react";
|
||||
import { useTransition } from "react";
|
||||
|
||||
import { CustomButton } from "../ui/custom";
|
||||
|
||||
export const ButtonRefreshData = ({
|
||||
onPress,
|
||||
}: {
|
||||
onPress: () => Promise<void>;
|
||||
}) => {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<CustomButton
|
||||
ariaLabel="Refresh scan page"
|
||||
variant="solid"
|
||||
color="action"
|
||||
size="md"
|
||||
endContent={!isPending && <RefreshCcwIcon size={24} />}
|
||||
isLoading={isPending}
|
||||
onPress={() => {
|
||||
startTransition(async () => {
|
||||
await onPress();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from "./button-refresh-data";
|
||||
export * from "./auto-refresh";
|
||||
export * from "./link-to-findings-from-scan";
|
||||
export * from "./no-providers-added";
|
||||
export * from "./no-providers-connected";
|
||||
|
||||
@@ -62,7 +62,8 @@ export const ScanDetail = ({
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<StatusBadge
|
||||
size="lg"
|
||||
size="md"
|
||||
className="w-fit"
|
||||
status={scan.state}
|
||||
loadingProgress={scan.progress}
|
||||
/>
|
||||
@@ -86,11 +87,20 @@ export const ScanDetail = ({
|
||||
</InfoField>
|
||||
</div>
|
||||
|
||||
<InfoField label="Scan ID" variant="simple">
|
||||
<Snippet className="bg-gray-50 py-1 dark:bg-slate-800" hideSymbol>
|
||||
{renderValue(taskDetails?.attributes.task_args.scan_id)}
|
||||
</Snippet>
|
||||
</InfoField>
|
||||
|
||||
{scan.state === "failed" && taskDetails?.attributes.result && (
|
||||
<>
|
||||
{taskDetails.attributes.result.exc_message && (
|
||||
<InfoField label="Error Message" variant="simple">
|
||||
<Snippet hideSymbol>
|
||||
<Snippet
|
||||
className="bg-gray-50 py-1 dark:bg-slate-800"
|
||||
hideSymbol
|
||||
>
|
||||
<span className="whitespace-pre-line text-xs">
|
||||
{taskDetails.attributes.result.exc_message.join("\n")}
|
||||
</span>
|
||||
@@ -101,11 +111,6 @@ export const ScanDetail = ({
|
||||
<InfoField label="Error Type">
|
||||
{renderValue(taskDetails.attributes.result.exc_type)}
|
||||
</InfoField>
|
||||
<InfoField label="Scan ID" variant="simple">
|
||||
<Snippet hideSymbol>
|
||||
{renderValue(taskDetails?.attributes.task_args.scan_id)}
|
||||
</Snippet>
|
||||
</InfoField>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -87,10 +87,12 @@ export const ColumnGetScans: ColumnDef<ScanProps>[] = [
|
||||
attributes: { state },
|
||||
} = getScanData(row);
|
||||
return (
|
||||
<StatusBadge
|
||||
status={state}
|
||||
loadingProgress={row.original.attributes.progress}
|
||||
/>
|
||||
<div className="flex items-center justify-center">
|
||||
<StatusBadge
|
||||
status={state}
|
||||
loadingProgress={row.original.attributes.progress}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Chip, CircularProgress } from "@nextui-org/react";
|
||||
import { Chip } from "@nextui-org/react";
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
|
||||
import { SpinnerIcon } from "@/components/icons";
|
||||
|
||||
export type Status =
|
||||
| "available"
|
||||
| "scheduled"
|
||||
@@ -25,39 +28,38 @@ export const StatusBadge = ({
|
||||
status,
|
||||
size = "sm",
|
||||
loadingProgress,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
status: Status;
|
||||
size?: "sm" | "md" | "lg";
|
||||
loadingProgress?: number;
|
||||
className?: string;
|
||||
}) => {
|
||||
const color = statusColorMap[status as keyof typeof statusColorMap];
|
||||
|
||||
return (
|
||||
<Chip
|
||||
className="gap-1 border-none px-2 py-2 capitalize text-default-600"
|
||||
className={clsx(
|
||||
"relative w-full max-w-full border-none text-xs capitalize text-default-600",
|
||||
status === "executing" && "border-1 border-solid border-transparent",
|
||||
className,
|
||||
)}
|
||||
size={size}
|
||||
variant="flat"
|
||||
color={color}
|
||||
{...props}
|
||||
>
|
||||
{status === "executing" ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<CircularProgress
|
||||
size="md"
|
||||
classNames={{
|
||||
svg: "h-7 w-7 drop-shadow-md text-prowler-theme-green",
|
||||
indicator: "stroke-prowler-theme-green",
|
||||
track: "stroke-prowler-theme-green/10",
|
||||
}}
|
||||
aria-label="Loading..."
|
||||
value={loadingProgress}
|
||||
showValueLabel={true}
|
||||
/>
|
||||
executing
|
||||
<div className="relative flex items-center justify-center gap-1">
|
||||
<SpinnerIcon size={16} className="animate-spin text-default-500" />
|
||||
<span className="pointer-events-none text-[0.6rem] text-default-500">
|
||||
{loadingProgress}%
|
||||
</span>
|
||||
<span>executing</span>
|
||||
</div>
|
||||
) : (
|
||||
status
|
||||
<span className="flex items-center justify-center">{status}</span>
|
||||
)}
|
||||
</Chip>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user