mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 13:01:56 +00:00
Merge pull request #82 from prowler-cloud/PRWLR-5141-Prowler-V-release-tweaks-scan-page-v2
Create workflow to launch a scan
This commit is contained in:
@@ -1,30 +1,43 @@
|
||||
import { Link, Spacer, Tooltip } from "@nextui-org/react";
|
||||
import { Spacer } from "@nextui-org/react";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getProviders } from "@/actions/providers";
|
||||
import { getScans } from "@/actions/scans";
|
||||
import { FilterControls, filterScans } from "@/components/filters";
|
||||
import { InfoIcon } from "@/components/icons";
|
||||
import { filterScans } from "@/components/filters";
|
||||
import { LaunchScanWorkflow } from "@/components/scans/launch-workflow";
|
||||
import { SkeletonTableScans } from "@/components/scans/table";
|
||||
import { ColumnProviderScans } from "@/components/scans/table/provider-scans";
|
||||
import { ColumnGetScans } from "@/components/scans/table/scans";
|
||||
import { Header } from "@/components/ui";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
import { ProviderProps, SearchParamsProps } from "@/types";
|
||||
|
||||
export default async function Scans({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SearchParamsProps;
|
||||
}) {
|
||||
const searchParamsKey = JSON.stringify(searchParams || {});
|
||||
const filteredParams = { ...searchParams };
|
||||
delete filteredParams.scanId;
|
||||
const searchParamsKey = JSON.stringify(filteredParams);
|
||||
|
||||
const providersData = await getProviders({});
|
||||
|
||||
const providerInfo = providersData?.data?.length
|
||||
? providersData.data.map((provider: ProviderProps) => ({
|
||||
providerId: provider.id,
|
||||
alias: provider.attributes.alias,
|
||||
providerType: provider.attributes.provider,
|
||||
uid: provider.attributes.uid,
|
||||
connected: provider.attributes.connection.connected,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Scans" icon="lucide:scan-search" />
|
||||
|
||||
<Spacer y={4} />
|
||||
<FilterControls search date providers />
|
||||
<LaunchScanWorkflow providers={providerInfo} />
|
||||
<Spacer y={8} />
|
||||
|
||||
<div className="grid grid-cols-12 items-start gap-4">
|
||||
@@ -33,43 +46,11 @@ export default async function Scans({
|
||||
<SSRDataTableScans searchParams={searchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
<div className="col-span-12 lg:col-span-6">
|
||||
<Suspense key={searchParamsKey} fallback={<SkeletonTableScans />}>
|
||||
<SSRDataTableProviders />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const SSRDataTableProviders = async () => {
|
||||
const filters = { "filter[connected]": "true" };
|
||||
const providersData = await getProviders({ page: 1, filters });
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Tooltip content="Only connected providers can be scanned">
|
||||
<InfoIcon size={16} />
|
||||
</Tooltip>
|
||||
<p className="text-sm text-default-500">Connected providers</p>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={ColumnProviderScans}
|
||||
data={providersData?.data || []}
|
||||
/>
|
||||
<p className="-mt-4 text-sm text-default-500">
|
||||
If you don't see any providers, please check your connection settings on
|
||||
the{" "}
|
||||
<Link className="text-sm font-medium" href="/providers">
|
||||
providers page
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SSRDataTableScans = async ({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -78,9 +59,11 @@ const SSRDataTableScans = async ({
|
||||
const page = parseInt(searchParams.page?.toString() || "1", 10);
|
||||
const sort = searchParams.sort?.toString();
|
||||
|
||||
// Extract all filter parameters
|
||||
// Extract all filter parameters, excluding scanId
|
||||
const filters = Object.fromEntries(
|
||||
Object.entries(searchParams).filter(([key]) => key.startsWith("filter[")),
|
||||
Object.entries(searchParams).filter(
|
||||
([key]) => key.startsWith("filter[") && key !== "scanId",
|
||||
),
|
||||
);
|
||||
|
||||
// Extract query from filters
|
||||
|
||||
@@ -8,6 +8,11 @@ export const filterProviders = [
|
||||
];
|
||||
|
||||
export const filterScans = [
|
||||
{
|
||||
key: "provider_type__in",
|
||||
labelCheckboxGroup: "Provider",
|
||||
values: ["aws", "azure", "gcp", "kubernetes"],
|
||||
},
|
||||
{
|
||||
key: "state",
|
||||
labelCheckboxGroup: "State",
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from "./edit-scan-form";
|
||||
export * from "./scan-on-demand-form";
|
||||
export * from "./schedule-form";
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { RocketIcon } from "lucide-react";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
|
||||
import { scanOnDemand } from "@/actions/scans";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomButton, CustomInput } from "@/components/ui/custom";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { onDemandScanFormSchema } from "@/types";
|
||||
|
||||
export const ScanOnDemandForm = ({
|
||||
providerId,
|
||||
scanName,
|
||||
scannerArgs,
|
||||
setIsOpen,
|
||||
}: {
|
||||
providerId: string;
|
||||
scanName?: string;
|
||||
scannerArgs?: { checksToExecute: string[] };
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
}) => {
|
||||
const formSchema = onDemandScanFormSchema();
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
providerId: providerId,
|
||||
scanName: scanName,
|
||||
scannerArgs: scannerArgs,
|
||||
},
|
||||
});
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const isLoading = form.formState.isSubmitting;
|
||||
|
||||
const onSubmitClient = async (values: z.infer<typeof formSchema>) => {
|
||||
const formData = new FormData();
|
||||
|
||||
// Loop through form values and add to formData, converting objects to JSON strings
|
||||
Object.entries(values).forEach(
|
||||
([key, value]) =>
|
||||
value !== undefined &&
|
||||
formData.append(
|
||||
key,
|
||||
typeof value === "object" ? JSON.stringify(value) : value,
|
||||
),
|
||||
);
|
||||
|
||||
const data = await scanOnDemand(formData);
|
||||
|
||||
if (data?.errors && data.errors.length > 0) {
|
||||
const error = data.errors[0];
|
||||
const errorMessage = `${error.detail}`;
|
||||
// show error
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Oops! Something went wrong",
|
||||
description: errorMessage,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "Success!",
|
||||
description: "The scan was launched successfully.",
|
||||
});
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmitClient)}
|
||||
className="flex flex-col space-y-4"
|
||||
>
|
||||
<input type="hidden" name="providerId" value={providerId} />
|
||||
|
||||
<div>
|
||||
<CustomInput
|
||||
control={form.control}
|
||||
name="scanName"
|
||||
type="text"
|
||||
label="Scan Name"
|
||||
labelPlacement="outside"
|
||||
placeholder={scanName}
|
||||
variant="bordered"
|
||||
isRequired={false}
|
||||
isInvalid={!!form.formState.errors.scanName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full justify-center sm:space-x-6">
|
||||
<CustomButton
|
||||
type="button"
|
||||
ariaLabel="Cancel"
|
||||
className="w-full bg-transparent"
|
||||
variant="faded"
|
||||
size="lg"
|
||||
radius="lg"
|
||||
onPress={() => setIsOpen(false)}
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
<span>Cancel</span>
|
||||
</CustomButton>
|
||||
|
||||
<CustomButton
|
||||
type="submit"
|
||||
ariaLabel="Start scan now"
|
||||
className="w-full"
|
||||
variant="solid"
|
||||
color="action"
|
||||
size="lg"
|
||||
isLoading={isLoading}
|
||||
startContent={!isLoading && <RocketIcon size={24} />}
|
||||
>
|
||||
{isLoading ? <>Loading</> : <span>Start now</span>}
|
||||
</CustomButton>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -95,7 +95,7 @@ export const ScheduleForm = ({
|
||||
|
||||
<CustomButton
|
||||
type="submit"
|
||||
ariaLabel="Save"
|
||||
ariaLabel="Schedule scan"
|
||||
className="w-full"
|
||||
variant="solid"
|
||||
color="action"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./launch-scan-workflow";
|
||||
export * from "./select-scan-provider";
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
|
||||
import { scanOnDemand } from "@/actions/scans";
|
||||
import { RocketIcon, ScheduleIcon } from "@/components/icons";
|
||||
import { CustomButton, CustomInput } from "@/components/ui/custom";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { toast } from "@/components/ui/toast";
|
||||
import { onDemandScanFormSchema } from "@/types";
|
||||
|
||||
import { SelectScanProvider } from "./select-scan-provider";
|
||||
|
||||
type ProviderInfo = {
|
||||
providerId: string;
|
||||
alias: string;
|
||||
providerType: string;
|
||||
uid: string;
|
||||
connected: boolean;
|
||||
};
|
||||
|
||||
export const LaunchScanWorkflow = ({
|
||||
providers,
|
||||
}: {
|
||||
providers: ProviderInfo[];
|
||||
}) => {
|
||||
const formSchema = onDemandScanFormSchema();
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
providerId: "",
|
||||
scanName: "",
|
||||
scannerArgs: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const isLoading = form.formState.isSubmitting;
|
||||
|
||||
const onSubmitClient = async (values: z.infer<typeof formSchema>) => {
|
||||
const formData = new FormData();
|
||||
console.log(values);
|
||||
|
||||
// Loop through form values and add to formData
|
||||
Object.entries(values).forEach(
|
||||
([key, value]) =>
|
||||
value !== undefined &&
|
||||
formData.append(
|
||||
key,
|
||||
typeof value === "object" ? JSON.stringify(value) : value,
|
||||
),
|
||||
);
|
||||
|
||||
const data = await scanOnDemand(formData);
|
||||
|
||||
if (data?.errors && data.errors.length > 0) {
|
||||
const error = data.errors[0];
|
||||
const errorMessage = `${error.detail}`;
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Oops! Something went wrong",
|
||||
description: errorMessage,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "Success!",
|
||||
description: "The scan was launched successfully.",
|
||||
});
|
||||
// Reset form after successful submission
|
||||
form.reset();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmitClient)}
|
||||
className="flex flex-col space-y-4"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="w-full">
|
||||
<p className="pb-1 text-sm font-medium text-default-700">
|
||||
Launch Scan
|
||||
</p>
|
||||
<SelectScanProvider
|
||||
providers={providers}
|
||||
control={form.control}
|
||||
name="providerId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{form.watch("providerId") && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<CustomInput
|
||||
control={form.control}
|
||||
name="scanName"
|
||||
type="text"
|
||||
label="Scan Name (optional)"
|
||||
labelPlacement="outside"
|
||||
placeholder="Scan Name"
|
||||
variant="bordered"
|
||||
isRequired={false}
|
||||
isInvalid={!!form.formState.errors.scanName}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-start">
|
||||
<AnimatePresence>
|
||||
{form.watch("providerId") && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<CustomInput
|
||||
control={form.control}
|
||||
name="scannerArgs"
|
||||
type="text"
|
||||
label="Scanner Args (optional)"
|
||||
labelPlacement="outside"
|
||||
placeholder="Scanner Args"
|
||||
variant="bordered"
|
||||
isRequired={false}
|
||||
isInvalid={!!form.formState.errors.scannerArgs}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{form.watch("providerId") && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="col-span-2 flex justify-start gap-4"
|
||||
>
|
||||
<CustomButton
|
||||
onPress={() => form.reset()}
|
||||
className="w-fit border-gray-200 bg-transparent"
|
||||
ariaLabel="Clear form"
|
||||
variant="bordered"
|
||||
size="lg"
|
||||
radius="lg"
|
||||
>
|
||||
Cancel
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
type="submit"
|
||||
ariaLabel="Schedule scan"
|
||||
variant="solid"
|
||||
color="action"
|
||||
size="lg"
|
||||
isLoading={isLoading}
|
||||
startContent={!isLoading && <ScheduleIcon size={24} />}
|
||||
isDisabled={true}
|
||||
>
|
||||
{isLoading ? <>Loading</> : <span>Schedule</span>}
|
||||
</CustomButton>
|
||||
|
||||
<CustomButton
|
||||
type="submit"
|
||||
ariaLabel="Start scan now"
|
||||
variant="solid"
|
||||
color="action"
|
||||
size="lg"
|
||||
isLoading={isLoading}
|
||||
startContent={!isLoading && <RocketIcon size={24} />}
|
||||
>
|
||||
{isLoading ? <>Loading</> : <span>Start now</span>}
|
||||
</CustomButton>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { Select, SelectItem } from "@nextui-org/react";
|
||||
import { Control, FieldPath, FieldValues } from "react-hook-form";
|
||||
|
||||
import { AWSProviderBadge } from "@/components/icons/providers-badge/AWSProviderBadge";
|
||||
import { AzureProviderBadge } from "@/components/icons/providers-badge/AzureProviderBadge";
|
||||
import { GCPProviderBadge } from "@/components/icons/providers-badge/GCPProviderBadge";
|
||||
import { KS8ProviderBadge } from "@/components/icons/providers-badge/KS8ProviderBadge";
|
||||
import { FormControl, FormField, FormMessage } from "@/components/ui/form";
|
||||
|
||||
interface SelectScanProviderProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> {
|
||||
providers: {
|
||||
providerId: string;
|
||||
alias: string;
|
||||
providerType: string;
|
||||
uid: string;
|
||||
connected: boolean;
|
||||
}[];
|
||||
control: Control<TFieldValues>;
|
||||
name: TName;
|
||||
}
|
||||
|
||||
export const SelectScanProvider = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
providers,
|
||||
control,
|
||||
name,
|
||||
}: SelectScanProviderProps<TFieldValues, TName>) => {
|
||||
const renderBadge = (providerType: string) => {
|
||||
switch (providerType) {
|
||||
case "aws":
|
||||
return <AWSProviderBadge width={25} height={25} />;
|
||||
case "azure":
|
||||
return <AzureProviderBadge width={25} height={25} />;
|
||||
case "gcp":
|
||||
return <GCPProviderBadge width={25} height={25} />;
|
||||
case "kubernetes":
|
||||
return <KS8ProviderBadge width={25} height={25} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<>
|
||||
<FormControl>
|
||||
<Select
|
||||
aria-label="Select a Provider"
|
||||
placeholder="Choose a provider"
|
||||
labelPlacement="outside"
|
||||
size="md"
|
||||
selectedKeys={field.value ? new Set([field.value]) : new Set()}
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedValue = Array.from(keys)[0]?.toString();
|
||||
field.onChange(selectedValue);
|
||||
}}
|
||||
renderValue={() => {
|
||||
const selectedItem = providers.find(
|
||||
(item) => item.providerId === field.value,
|
||||
);
|
||||
return selectedItem ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{renderBadge(selectedItem.providerType)}
|
||||
{selectedItem.alias}
|
||||
</div>
|
||||
) : (
|
||||
"Choose a provider"
|
||||
);
|
||||
}}
|
||||
>
|
||||
{providers.map((item) => (
|
||||
<SelectItem
|
||||
key={item.providerId}
|
||||
textValue={item.alias}
|
||||
aria-label={item.alias}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{renderBadge(item.providerType)}
|
||||
{item.alias}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage className="text-system-error dark:text-system-error" />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
|
||||
import { EntityInfoShort } from "@/components/ui/entities";
|
||||
import { ProviderProps } from "@/types";
|
||||
|
||||
import { DataTableRowActions } from "./data-table-row-actions";
|
||||
|
||||
const getProviderData = (row: { original: ProviderProps }) => {
|
||||
return row.original;
|
||||
};
|
||||
|
||||
export const ColumnProviderScans: ColumnDef<ProviderProps>[] = [
|
||||
{
|
||||
accessorKey: "provider",
|
||||
header: "Provider",
|
||||
cell: ({ row }) => {
|
||||
const {
|
||||
attributes: { connection, provider, alias, uid },
|
||||
} = getProviderData(row);
|
||||
return (
|
||||
<EntityInfoShort
|
||||
connected={connection.connected}
|
||||
cloudProvider={provider}
|
||||
entityAlias={alias}
|
||||
entityId={uid}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "launchScan",
|
||||
header: "Launch Scan",
|
||||
cell: ({ row }) => {
|
||||
return <DataTableRowActions row={row} />;
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,110 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownSection,
|
||||
DropdownTrigger,
|
||||
} from "@nextui-org/react";
|
||||
import { Row } from "@tanstack/react-table";
|
||||
import { CalendarClockIcon, RocketIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { AddIcon } from "@/components/icons";
|
||||
import { CustomAlertModal, CustomButton } from "@/components/ui/custom";
|
||||
|
||||
import { ScanOnDemandForm, ScheduleForm } from "../../forms";
|
||||
|
||||
interface DataTableRowActionsProps<ProviderProps> {
|
||||
row: Row<ProviderProps>;
|
||||
}
|
||||
const iconClasses =
|
||||
"text-2xl text-default-500 pointer-events-none flex-shrink-0";
|
||||
|
||||
export function DataTableRowActions<ProviderProps>({
|
||||
row,
|
||||
}: DataTableRowActionsProps<ProviderProps>) {
|
||||
const [isScanOnDemandOpen, setIsScanOnDemandOpen] = useState(false);
|
||||
const [isScanScheduleOpen, setIsScanScheduleOpen] = useState(false);
|
||||
|
||||
const providerId = (row.original as { id: string }).id;
|
||||
const scanName = (row.original as any).attributes?.name;
|
||||
return (
|
||||
<>
|
||||
<CustomAlertModal
|
||||
isOpen={isScanOnDemandOpen}
|
||||
onOpenChange={setIsScanOnDemandOpen}
|
||||
title="Start Scan On Demand"
|
||||
description={"Start a scan on demand for this provider"}
|
||||
>
|
||||
<ScanOnDemandForm
|
||||
providerId={providerId}
|
||||
scanName={scanName}
|
||||
setIsOpen={setIsScanOnDemandOpen}
|
||||
/>
|
||||
</CustomAlertModal>
|
||||
|
||||
<CustomAlertModal
|
||||
isOpen={isScanScheduleOpen}
|
||||
onOpenChange={setIsScanScheduleOpen}
|
||||
title="Schedule Scan"
|
||||
description={"Schedule a scan for this provider"}
|
||||
>
|
||||
<ScheduleForm
|
||||
providerId={providerId}
|
||||
scheduleDate={""}
|
||||
setIsOpen={setIsScanScheduleOpen}
|
||||
/>
|
||||
</CustomAlertModal>
|
||||
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<Dropdown className="shadow-xl" placement="bottom-start">
|
||||
<DropdownTrigger>
|
||||
<CustomButton
|
||||
className="w-full"
|
||||
ariaLabel="Start Scan"
|
||||
variant="solid"
|
||||
color="action"
|
||||
size="md"
|
||||
endContent={<AddIcon size={20} />}
|
||||
>
|
||||
Start
|
||||
</CustomButton>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
closeOnSelect
|
||||
aria-label="Launch Scan"
|
||||
color="default"
|
||||
variant="flat"
|
||||
>
|
||||
<DropdownSection title="Start Scan On Demand">
|
||||
<DropdownItem
|
||||
key="scanNow"
|
||||
color="primary"
|
||||
description="Allows you to start a scan on demand"
|
||||
textValue="Start now"
|
||||
startContent={<RocketIcon className={iconClasses} />}
|
||||
onClick={() => setIsScanOnDemandOpen(true)}
|
||||
>
|
||||
Start now
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
<DropdownSection title="Schedule Scan">
|
||||
<DropdownItem
|
||||
key="schedule"
|
||||
color="primary"
|
||||
description="Schedule a scan for this provider"
|
||||
textValue="Schedule Scan"
|
||||
startContent={<CalendarClockIcon className={iconClasses} />}
|
||||
onClick={() => setIsScanScheduleOpen(true)}
|
||||
>
|
||||
Schedule Scan
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./column-provider-scans";
|
||||
export * from "./data-table-row-actions";
|
||||
@@ -4,16 +4,23 @@ import { Card, CardBody, CardHeader, Divider } from "@nextui-org/react";
|
||||
|
||||
import { DateWithTime, SnippetId } from "@/components/ui/entities";
|
||||
import { StatusBadge } from "@/components/ui/table/status-badge";
|
||||
import { ScanProps } from "@/types";
|
||||
import { ScanProps, TaskDetails } from "@/types";
|
||||
|
||||
export const ScanDetail = ({ scanDetails }: { scanDetails: ScanProps }) => {
|
||||
interface ScanDetailsProps {
|
||||
scanDetails: ScanProps & {
|
||||
taskDetails?: TaskDetails;
|
||||
};
|
||||
}
|
||||
|
||||
export const ScanDetail = ({ scanDetails }: ScanDetailsProps) => {
|
||||
const scanOnDemand = scanDetails.attributes;
|
||||
const taskDetails = scanDetails.taskDetails;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col items-baseline md:flex-row md:gap-x-4">
|
||||
<h2 className="text-lg font-black uppercase">Scan Details - </h2>
|
||||
<p>{scanOnDemand.name}</p>
|
||||
<h2 className="text-2xl font-bold">Scan Details</h2>
|
||||
</div>
|
||||
|
||||
<StatusBadge size="lg" status={scanOnDemand.state} />
|
||||
@@ -22,6 +29,7 @@ export const ScanDetail = ({ scanDetails }: { scanDetails: ScanProps }) => {
|
||||
<div className="relative z-0 flex w-full flex-col justify-between gap-4 overflow-auto rounded-large bg-content1 p-4 shadow-small">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
<DetailItem label="Scan Name" value={scanOnDemand.name} />
|
||||
<DetailItem
|
||||
label="ID"
|
||||
value={<SnippetId label="Type" entityId={scanDetails.id} />}
|
||||
@@ -99,7 +107,7 @@ export const ScanDetail = ({ scanDetails }: { scanDetails: ScanProps }) => {
|
||||
</div>
|
||||
<Card className="relative w-full border-small border-default-100 p-3 shadow-lg">
|
||||
<CardHeader className="py-2">
|
||||
<h2 className="text-2xl font-bold">Scan Arguments</h2>
|
||||
<h2 className="text-2xl font-bold">Scan arguments</h2>
|
||||
</CardHeader>
|
||||
|
||||
<Divider />
|
||||
@@ -115,6 +123,47 @@ export const ScanDetail = ({ scanDetails }: { scanDetails: ScanProps }) => {
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
{taskDetails && (
|
||||
<Card className="relative w-full border-small border-default-100 p-3 shadow-lg">
|
||||
<CardHeader className="py-2">
|
||||
<h2 className="text-2xl font-bold">State details</h2>
|
||||
</CardHeader>
|
||||
<Divider />
|
||||
<CardBody className="p-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<DetailItem label="State" value={taskDetails.attributes.state} />
|
||||
<DetailItem
|
||||
label="Completed At"
|
||||
value={taskDetails.attributes.completed_at}
|
||||
/>
|
||||
|
||||
{taskDetails.attributes.result && (
|
||||
<>
|
||||
<DetailItem
|
||||
label="Error Type"
|
||||
value={taskDetails.attributes.result.exc_type}
|
||||
/>
|
||||
{taskDetails.attributes.result.exc_message && (
|
||||
<DetailItem
|
||||
label="Error Message"
|
||||
value={taskDetails.attributes.result.exc_message.join(
|
||||
", ",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<DetailItem
|
||||
label="Checks to Execute"
|
||||
value={taskDetails.attributes.task_args.checks_to_execute.join(
|
||||
", ",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { InfoIcon } from "@/components/icons";
|
||||
import { DateWithTime, EntityInfoShort } from "@/components/ui/entities";
|
||||
@@ -125,11 +126,16 @@ export const ColumnGetScans: ColumnDef<ScanProps>[] = [
|
||||
id: "moreInfo",
|
||||
header: "Details",
|
||||
cell: ({ row }) => {
|
||||
const searchParams = useSearchParams();
|
||||
const scanId = searchParams.get("scanId");
|
||||
const isOpen = scanId === row.original.id;
|
||||
|
||||
return (
|
||||
<TriggerSheet
|
||||
triggerComponent={<InfoIcon className="text-primary" size={16} />}
|
||||
title="Scan Details"
|
||||
description="View the scan details"
|
||||
defaultOpen={isOpen}
|
||||
>
|
||||
<DataTableRowDetails entityId={row.original.id} />
|
||||
</TriggerSheet>
|
||||
|
||||
@@ -1,22 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { getScan } from "@/actions/scans";
|
||||
import { getTask } from "@/actions/task";
|
||||
import { ScanDetail, SkeletonTableScans } from "@/components/scans/table";
|
||||
import { checkTaskStatus } from "@/lib";
|
||||
import { ScanProps } from "@/types";
|
||||
|
||||
export const DataTableRowDetails = ({ entityId }: { entityId: string }) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [scanDetails, setScanDetails] = useState<ScanProps | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Add scanId to URL
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("scanId", entityId);
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
|
||||
// Cleanup function: remove scanId from URL when component unmounts
|
||||
return () => {
|
||||
const newParams = new URLSearchParams(searchParams.toString());
|
||||
newParams.delete("scanId");
|
||||
router.push(`?${newParams.toString()}`, { scroll: false });
|
||||
};
|
||||
}, [entityId, router, searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchScanDetails = async () => {
|
||||
try {
|
||||
const result = await getScan(entityId);
|
||||
setScanDetails(result?.data);
|
||||
|
||||
const taskId = result.data.relationships.task?.data?.id;
|
||||
|
||||
if (taskId) {
|
||||
const taskResult = await checkTaskStatus(taskId);
|
||||
|
||||
if (taskResult.completed !== undefined) {
|
||||
const task = await getTask(taskId);
|
||||
setScanDetails({
|
||||
...result.data,
|
||||
taskDetails: task.data,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setScanDetails(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching scan details:", error);
|
||||
console.error("Error in fetchScanDetails:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button, CircularProgress } from "@nextui-org/react";
|
||||
import clsx from "clsx";
|
||||
import { useFormStatus } from "react-dom";
|
||||
|
||||
interface CustomButtonClientActionProps {
|
||||
buttonLabel: string;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
export const CustomButtonClientAction = ({
|
||||
buttonLabel,
|
||||
danger = false,
|
||||
}: CustomButtonClientActionProps) => {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={clsx("m-0 h-fit min-w-min border-none bg-transparent p-0", {
|
||||
"text-danger": danger,
|
||||
})}
|
||||
spinner={<CircularProgress aria-label="Loading..." size="sm" />}
|
||||
type="submit"
|
||||
area-disabled={pending}
|
||||
>
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -5,4 +5,3 @@ export * from "./custom-dropdown-filter";
|
||||
export * from "./custom-input";
|
||||
export * from "./custom-loader";
|
||||
export * from "./custom-radio";
|
||||
export * from "./CustomButtonClientAction";
|
||||
|
||||
@@ -12,6 +12,7 @@ interface TriggerSheetProps {
|
||||
title: string;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
export function TriggerSheet({
|
||||
@@ -19,13 +20,14 @@ export function TriggerSheet({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
defaultOpen = false,
|
||||
}: TriggerSheetProps) {
|
||||
return (
|
||||
<Sheet>
|
||||
<Sheet defaultOpen={defaultOpen}>
|
||||
<SheetTrigger className="flex items-center gap-2">
|
||||
{triggerComponent}
|
||||
</SheetTrigger>
|
||||
<SheetContent className="max-w-[95vw] pt-10 md:max-w-[45vw]">
|
||||
<SheetContent className="my-4 max-h-[calc(100vh-2rem)] max-w-[95vw] rounded-l-xl pt-10 md:my-8 md:max-h-[calc(100vh-4rem)] md:max-w-[55vw]">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="sr-only">{title}</SheetTitle>
|
||||
<SheetDescription className="sr-only">{description}</SheetDescription>
|
||||
|
||||
@@ -26,6 +26,22 @@ export type NextUIColors =
|
||||
| "danger"
|
||||
| "default";
|
||||
|
||||
export interface TaskDetails {
|
||||
attributes: {
|
||||
state: string;
|
||||
completed_at: string;
|
||||
result: {
|
||||
exc_type?: string;
|
||||
exc_message?: string[];
|
||||
exc_module?: string;
|
||||
};
|
||||
task_args: {
|
||||
scan_id: string;
|
||||
provider_id: string;
|
||||
checks_to_execute: string[];
|
||||
};
|
||||
};
|
||||
}
|
||||
export type AWSCredentials = {
|
||||
aws_access_key_id: string;
|
||||
aws_secret_access_key: string;
|
||||
|
||||
Reference in New Issue
Block a user