mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
401 lines
12 KiB
TypeScript
401 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { CloudCog, Loader2, Rocket } from "lucide-react";
|
|
import Link from "next/link";
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
import { useRef, useState } from "react";
|
|
import { useForm, useWatch } from "react-hook-form";
|
|
import { z } from "zod";
|
|
|
|
import { scanOnDemand } from "@/actions/scans";
|
|
import { getSchedule } from "@/actions/schedules";
|
|
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
|
|
import { Field, FieldError, FieldLabel, Input } from "@/components/shadcn";
|
|
import { Modal } from "@/components/shadcn/modal";
|
|
import {
|
|
RadioGroup,
|
|
RadioGroupItem,
|
|
} from "@/components/shadcn/radio-group/radio-group";
|
|
import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge";
|
|
import { FormButtons } from "@/components/ui/form";
|
|
import { toast, ToastAction } from "@/components/ui/toast";
|
|
import {
|
|
getScanScheduleCapability,
|
|
getScheduleFormDefaults,
|
|
getScheduleFormValues,
|
|
scheduleFormSchema,
|
|
} from "@/lib/schedules";
|
|
import { isCloud } from "@/lib/shared/env";
|
|
import { SCAN_JOBS_TAB } from "@/types";
|
|
import type { ProviderProps } from "@/types/providers";
|
|
import {
|
|
SCAN_SCHEDULE_CAPABILITY,
|
|
type ScanScheduleCapability,
|
|
type ScheduleApiResponse,
|
|
type ScheduleFormValues,
|
|
} from "@/types/schedules";
|
|
|
|
import { scanAliasSchema } from "./scan-alias-validation";
|
|
import { getScanJobsTab } from "./scans.utils";
|
|
import {
|
|
SAVE_SCHEDULE_STATUS,
|
|
saveScheduleWithInitialScan,
|
|
} from "./schedule/save-schedule";
|
|
import { ScanScheduleFields } from "./schedule/scan-schedule-fields";
|
|
|
|
const launchScanSchema = z.object({
|
|
providerId: z.string().min(1, "Select a provider to launch a scan."),
|
|
scanAlias: scanAliasSchema.optional(),
|
|
});
|
|
|
|
type LaunchScanFormValues = z.infer<typeof launchScanSchema>;
|
|
|
|
const LAUNCH_MODE = {
|
|
NOW: "now",
|
|
SCHEDULE: "schedule",
|
|
} as const;
|
|
|
|
type LaunchMode = (typeof LAUNCH_MODE)[keyof typeof LAUNCH_MODE];
|
|
|
|
const SCHEDULE_LOAD_STATE = {
|
|
IDLE: "idle",
|
|
LOADING: "loading",
|
|
LOADED: "loaded",
|
|
ERROR: "error",
|
|
} as const;
|
|
|
|
type ScheduleLoadState =
|
|
(typeof SCHEDULE_LOAD_STATE)[keyof typeof SCHEDULE_LOAD_STATE];
|
|
|
|
interface LaunchScanModalProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
providers: ProviderProps[];
|
|
/** Cloud overlay seam; defaults to the environment-resolved capability. */
|
|
capability?: ScanScheduleCapability;
|
|
isScanLimitReached?: boolean;
|
|
}
|
|
|
|
interface LaunchScanFormProps {
|
|
providers: ProviderProps[];
|
|
onClose: () => void;
|
|
capability: ScanScheduleCapability;
|
|
isScanLimitReached: boolean;
|
|
}
|
|
|
|
function LaunchScanForm({
|
|
providers,
|
|
onClose,
|
|
capability,
|
|
isScanLimitReached,
|
|
}: LaunchScanFormProps) {
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
const form = useForm<LaunchScanFormValues>({
|
|
resolver: zodResolver(launchScanSchema),
|
|
defaultValues: { providerId: "", scanAlias: "" },
|
|
});
|
|
const scheduleForm = useForm<ScheduleFormValues>({
|
|
resolver: zodResolver(scheduleFormSchema),
|
|
defaultValues: getScheduleFormDefaults(),
|
|
});
|
|
const [mode, setMode] = useState<LaunchMode>(LAUNCH_MODE.NOW);
|
|
const [scheduleLoad, setScheduleLoad] = useState<ScheduleLoadState>(
|
|
SCHEDULE_LOAD_STATE.IDLE,
|
|
);
|
|
// Guards against out-of-order responses when switching providers quickly.
|
|
const requestedProviderRef = useRef<string>("");
|
|
|
|
const isAdvanced = capability === SCAN_SCHEDULE_CAPABILITY.ADVANCED;
|
|
const isManualOnly = capability === SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY;
|
|
const isBlocked =
|
|
capability === SCAN_SCHEDULE_CAPABILITY.BLOCKED ||
|
|
(isManualOnly && isScanLimitReached);
|
|
const isScheduleMode = isAdvanced && mode === LAUNCH_MODE.SCHEDULE;
|
|
|
|
// useWatch, not form.watch: form.watch re-renders are dropped by React Compiler memoization.
|
|
const providerId = useWatch({ control: form.control, name: "providerId" });
|
|
const activeTab = getScanJobsTab(searchParams.get("tab") ?? undefined);
|
|
const shouldShowActiveTabAction = activeTab !== SCAN_JOBS_TAB.ACTIVE;
|
|
const disconnectedProviderIds = providers
|
|
.filter((provider) => provider.attributes.connection.connected !== true)
|
|
.map((provider) => provider.id);
|
|
|
|
const loadSchedule = async (id: string) => {
|
|
requestedProviderRef.current = id;
|
|
if (!id) {
|
|
setScheduleLoad(SCHEDULE_LOAD_STATE.IDLE);
|
|
return;
|
|
}
|
|
|
|
setScheduleLoad(SCHEDULE_LOAD_STATE.LOADING);
|
|
const response = (await getSchedule(id)) as
|
|
| ScheduleApiResponse
|
|
| { error?: string };
|
|
|
|
if (requestedProviderRef.current !== id) return;
|
|
|
|
if (!response || ("error" in response && response.error)) {
|
|
setScheduleLoad(SCHEDULE_LOAD_STATE.ERROR);
|
|
return;
|
|
}
|
|
|
|
scheduleForm.reset(
|
|
getScheduleFormValues(
|
|
"data" in response ? response.data?.attributes : null,
|
|
),
|
|
);
|
|
setScheduleLoad(SCHEDULE_LOAD_STATE.LOADED);
|
|
};
|
|
|
|
const handleProviderChange = (id: string) => {
|
|
form.setValue("providerId", id, { shouldValidate: true });
|
|
if (isScheduleMode) void loadSchedule(id);
|
|
};
|
|
|
|
const handleModeChange = (nextMode: string) => {
|
|
if (nextMode === LAUNCH_MODE.SCHEDULE && !isAdvanced) return;
|
|
setMode(nextMode as LaunchMode);
|
|
if (nextMode === LAUNCH_MODE.SCHEDULE) void loadSchedule(providerId);
|
|
};
|
|
|
|
const launchNow = form.handleSubmit(async ({ providerId, scanAlias }) => {
|
|
if (isBlocked) return;
|
|
|
|
const formData = new FormData();
|
|
formData.set("providerId", providerId);
|
|
const trimmedAlias = scanAlias?.trim();
|
|
if (trimmedAlias) {
|
|
formData.set("scanName", trimmedAlias);
|
|
}
|
|
|
|
const result = await scanOnDemand(formData);
|
|
|
|
if (result?.error) {
|
|
form.setError("root", { message: String(result.error) });
|
|
return;
|
|
}
|
|
|
|
if (result?.errors && result.errors.length > 0) {
|
|
form.setError("root", {
|
|
message: String(result.errors[0]?.detail ?? "Failed to launch scan."),
|
|
});
|
|
return;
|
|
}
|
|
|
|
toast({
|
|
title: "Scan launched",
|
|
description: "The scan was launched successfully.",
|
|
action: shouldShowActiveTabAction ? (
|
|
<ToastAction altText="View scan in progress" asChild>
|
|
<Link href="/scans?tab=active">View scan</Link>
|
|
</ToastAction>
|
|
) : undefined,
|
|
});
|
|
onClose();
|
|
router.refresh();
|
|
});
|
|
|
|
const saveSchedule = async () => {
|
|
if (isBlocked || !isAdvanced) return;
|
|
|
|
const providerValid = await form.trigger("providerId");
|
|
if (!providerValid) return;
|
|
|
|
await scheduleForm.handleSubmit(async (values) => {
|
|
const result = await saveScheduleWithInitialScan({
|
|
providerId: form.getValues("providerId"),
|
|
values,
|
|
});
|
|
|
|
if (result.status === SAVE_SCHEDULE_STATUS.ERROR) {
|
|
form.setError("root", { message: result.message });
|
|
return;
|
|
}
|
|
|
|
const launched =
|
|
result.status === SAVE_SCHEDULE_STATUS.SAVED_AND_LAUNCHED;
|
|
toast({
|
|
title: launched
|
|
? "Scan schedule saved and initial scan launched"
|
|
: "Scan schedule saved",
|
|
description:
|
|
result.status === SAVE_SCHEDULE_STATUS.SAVED_SCAN_FAILED
|
|
? `The schedule was saved, but the initial scan could not be launched: ${result.message}`
|
|
: launched
|
|
? "The schedule was saved and the initial scan was launched."
|
|
: "The scan schedule was saved successfully.",
|
|
action: (
|
|
<ToastAction altText="View scheduled scans" asChild>
|
|
<Link href={`/scans?tab=${SCAN_JOBS_TAB.SCHEDULED}`}>
|
|
View schedule
|
|
</Link>
|
|
</ToastAction>
|
|
),
|
|
});
|
|
onClose();
|
|
router.refresh();
|
|
})();
|
|
};
|
|
|
|
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
if (isBlocked) return;
|
|
|
|
if (isScheduleMode) {
|
|
void saveSchedule();
|
|
return;
|
|
}
|
|
void launchNow();
|
|
};
|
|
|
|
const providerError = form.formState.errors.providerId?.message;
|
|
const aliasError = form.formState.errors.scanAlias?.message;
|
|
const rootError = form.formState.errors.root?.message;
|
|
const isSubmitting =
|
|
form.formState.isSubmitting || scheduleForm.formState.isSubmitting;
|
|
const isScheduleLoading = scheduleLoad === SCHEDULE_LOAD_STATE.LOADING;
|
|
|
|
return (
|
|
// min-w-0: let this dialog grid item shrink so a long provider UID truncates instead of widening the modal
|
|
<form onSubmit={onSubmit} className="flex min-w-0 flex-col gap-8">
|
|
<div className="flex items-center gap-2">
|
|
<CloudCog className="text-text-neutral-secondary size-4" />
|
|
<span className="text-text-neutral-secondary text-sm">
|
|
Select the provider you would like to scan
|
|
</span>
|
|
</div>
|
|
|
|
<Field>
|
|
<FieldLabel htmlFor="launch-scan-account">Providers</FieldLabel>
|
|
<AccountsSelector
|
|
id="launch-scan-account"
|
|
providers={providers}
|
|
disabledValues={disconnectedProviderIds}
|
|
onBatchChange={(_, values) =>
|
|
handleProviderChange(values.at(-1) ?? "")
|
|
}
|
|
selectedValues={providerId ? [providerId] : []}
|
|
closeOnSelect
|
|
/>
|
|
{providerError && <FieldError>{providerError}</FieldError>}
|
|
</Field>
|
|
|
|
{!isManualOnly && !isBlocked && (
|
|
<Field>
|
|
<FieldLabel>Mode</FieldLabel>
|
|
<RadioGroup
|
|
value={mode}
|
|
onValueChange={handleModeChange}
|
|
className="flex flex-row flex-wrap gap-6"
|
|
aria-label="Scan mode"
|
|
>
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<RadioGroupItem value={LAUNCH_MODE.NOW} aria-label="Run now" />
|
|
Run now
|
|
</label>
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<RadioGroupItem
|
|
value={LAUNCH_MODE.SCHEDULE}
|
|
aria-label="On a schedule"
|
|
disabled={!isAdvanced}
|
|
/>
|
|
On a schedule
|
|
{!isAdvanced && <CloudFeatureBadgeLink size="sm" />}
|
|
</label>
|
|
</RadioGroup>
|
|
</Field>
|
|
)}
|
|
|
|
{isBlocked && (
|
|
<p className="text-text-error-primary text-sm">
|
|
You have reached your scan limit, so additional scans are not
|
|
available right now.
|
|
</p>
|
|
)}
|
|
|
|
{!isScheduleMode && (
|
|
<Field>
|
|
<FieldLabel htmlFor="launch-scan-alias">Alias (optional)</FieldLabel>
|
|
<Input
|
|
id="launch-scan-alias"
|
|
aria-label="Alias"
|
|
{...form.register("scanAlias")}
|
|
/>
|
|
{aliasError && <FieldError>{aliasError}</FieldError>}
|
|
</Field>
|
|
)}
|
|
|
|
{isScheduleMode && isScheduleLoading && (
|
|
<div className="flex items-center gap-3 py-2">
|
|
<Loader2 className="size-5 animate-spin" />
|
|
<span className="text-sm">Loading scan schedule...</span>
|
|
</div>
|
|
)}
|
|
|
|
{isScheduleMode && scheduleLoad === SCHEDULE_LOAD_STATE.ERROR && (
|
|
<FieldError>
|
|
Failed to load the current scan schedule. Saving will overwrite it.
|
|
</FieldError>
|
|
)}
|
|
|
|
{isScheduleMode && !isScheduleLoading && (
|
|
<ScanScheduleFields
|
|
form={scheduleForm}
|
|
disabled={isSubmitting || !providerId}
|
|
showLaunchInitialScan
|
|
showNextScheduledCopy
|
|
/>
|
|
)}
|
|
|
|
{rootError && <FieldError>{rootError}</FieldError>}
|
|
|
|
<FormButtons
|
|
onCancel={onClose}
|
|
submitText={
|
|
isSubmitting
|
|
? isScheduleMode
|
|
? "Saving..."
|
|
: "Launching..."
|
|
: isScheduleMode
|
|
? "Save Schedule"
|
|
: "Launch Scan"
|
|
}
|
|
loadingText={isScheduleMode ? "Saving..." : "Launching..."}
|
|
isDisabled={
|
|
isSubmitting || !providers.length || isScheduleLoading || isBlocked
|
|
}
|
|
rightIcon={<Rocket className="size-4" />}
|
|
/>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
export function LaunchScanModal({
|
|
open,
|
|
onOpenChange,
|
|
providers,
|
|
capability,
|
|
isScanLimitReached = false,
|
|
}: LaunchScanModalProps) {
|
|
const resolvedCapability = capability ?? getScanScheduleCapability(isCloud());
|
|
|
|
return (
|
|
<Modal
|
|
open={open}
|
|
onOpenChange={onOpenChange}
|
|
title="Launch A Scan"
|
|
size="xl"
|
|
className="gap-8"
|
|
>
|
|
<LaunchScanForm
|
|
providers={providers}
|
|
onClose={() => onOpenChange(false)}
|
|
capability={resolvedCapability}
|
|
isScanLimitReached={isScanLimitReached}
|
|
/>
|
|
</Modal>
|
|
);
|
|
}
|