mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
feat(ui): per-provider scan schedule management gated by capability (#11521)
This commit is contained in:
@@ -1,21 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
|
||||
import { scanOnDemand, scheduleDaily } from "@/actions/scans";
|
||||
import { scanOnDemand } from "@/actions/scans";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/shadcn/select/select";
|
||||
SAVE_SCHEDULE_STATUS,
|
||||
saveScheduleWithInitialScan,
|
||||
} from "@/components/scans/schedule/save-schedule";
|
||||
import { ScanScheduleFields } from "@/components/scans/schedule/scan-schedule-fields";
|
||||
import { Field, FieldLabel } from "@/components/shadcn";
|
||||
import {
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
} from "@/components/shadcn/radio-group/radio-group";
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
import { TreeStatusIcon } from "@/components/shadcn/tree-view/tree-status-icon";
|
||||
import {
|
||||
CloudFeatureBadge,
|
||||
CloudFeatureBadgeLink,
|
||||
} from "@/components/shared/cloud-feature-badge";
|
||||
import { ToastAction, useToast } from "@/components/ui";
|
||||
import { EntityInfo } from "@/components/ui/entities";
|
||||
import {
|
||||
getScanScheduleCapability,
|
||||
getScheduleFormDefaults,
|
||||
scheduleFormSchema,
|
||||
} from "@/lib/schedules";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { useProviderWizardStore } from "@/store/provider-wizard/store";
|
||||
import { SCAN_JOBS_TAB } from "@/types";
|
||||
import {
|
||||
SCAN_SCHEDULE_CAPABILITY,
|
||||
type ScanScheduleCapability,
|
||||
type ScheduleFormValues,
|
||||
} from "@/types/schedules";
|
||||
import { TREE_ITEM_STATUS } from "@/types/tree";
|
||||
|
||||
import {
|
||||
@@ -23,51 +44,94 @@ import {
|
||||
WizardFooterConfig,
|
||||
} from "./footer-controls";
|
||||
|
||||
const SCAN_SCHEDULE = {
|
||||
DAILY: "daily",
|
||||
SINGLE: "single",
|
||||
const LAUNCH_MODE = {
|
||||
NOW: "now",
|
||||
SCHEDULE: "schedule",
|
||||
} as const;
|
||||
|
||||
type ScanScheduleOption = (typeof SCAN_SCHEDULE)[keyof typeof SCAN_SCHEDULE];
|
||||
type LaunchMode = (typeof LAUNCH_MODE)[keyof typeof LAUNCH_MODE];
|
||||
|
||||
interface LaunchStepProps {
|
||||
onBack: () => void;
|
||||
onClose: () => void;
|
||||
onFooterChange: (config: WizardFooterConfig) => void;
|
||||
/**
|
||||
* Schedule capability override. Absent in Prowler OSS (defaults to a
|
||||
* Cloud-vs-non-Cloud decision). The prowler-cloud overlay computes a
|
||||
* billing-aware capability and injects it here so trial/onboarding accounts
|
||||
* are limited to manual scans.
|
||||
*/
|
||||
capability?: ScanScheduleCapability;
|
||||
/**
|
||||
* When true, the manual scan action is disabled (account scan quota reached).
|
||||
* Cloud-only signal; never set in OSS.
|
||||
*/
|
||||
isScanLimitReached?: boolean;
|
||||
}
|
||||
|
||||
export function LaunchStep({
|
||||
onBack,
|
||||
onClose,
|
||||
onFooterChange,
|
||||
capability: capabilityProp,
|
||||
isScanLimitReached = false,
|
||||
}: LaunchStepProps) {
|
||||
const { toast } = useToast();
|
||||
const { providerId } = useProviderWizardStore();
|
||||
const { providerAlias, providerId, providerType, providerUid } =
|
||||
useProviderWizardStore();
|
||||
const capability = capabilityProp ?? getScanScheduleCapability(isCloud());
|
||||
const isManualOnly = capability === SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY;
|
||||
const isAdvanced = capability === SCAN_SCHEDULE_CAPABILITY.ADVANCED;
|
||||
const [isLaunching, setIsLaunching] = useState(false);
|
||||
const [scheduleOption, setScheduleOption] = useState<ScanScheduleOption>(
|
||||
SCAN_SCHEDULE.DAILY,
|
||||
const [mode, setMode] = useState<LaunchMode>(
|
||||
isAdvanced ? LAUNCH_MODE.SCHEDULE : LAUNCH_MODE.NOW,
|
||||
);
|
||||
const launchActionRef = useRef<() => void>(() => {});
|
||||
const form = useForm<ScheduleFormValues>({
|
||||
resolver: zodResolver(scheduleFormSchema),
|
||||
defaultValues: getScheduleFormDefaults(),
|
||||
});
|
||||
|
||||
const handleLaunchScan = async () => {
|
||||
if (!providerId) {
|
||||
const isScheduleMode = isAdvanced && mode === LAUNCH_MODE.SCHEDULE;
|
||||
const isLimitBlocked = mode === LAUNCH_MODE.NOW && isScanLimitReached;
|
||||
const isActionBlocked = isLaunching || !providerId || isLimitBlocked;
|
||||
const launchInitialScan = useWatch({
|
||||
control: form.control,
|
||||
name: "launchInitialScan",
|
||||
});
|
||||
|
||||
const actionLabel = (() => {
|
||||
if (!isScheduleMode) {
|
||||
return isLaunching ? "Launching scan..." : "Launch scan";
|
||||
}
|
||||
|
||||
if (isLaunching) {
|
||||
return launchInitialScan ? "Saving and launching..." : "Saving...";
|
||||
}
|
||||
|
||||
return launchInitialScan ? "Save and launch scan" : "Save";
|
||||
})();
|
||||
|
||||
const launchOnDemandScan = async (): Promise<{ error?: unknown } | null> => {
|
||||
if (!providerId) return null;
|
||||
const formData = new FormData();
|
||||
formData.set("providerId", providerId);
|
||||
return scanOnDemand(formData);
|
||||
};
|
||||
|
||||
const handleManualScan = async () => {
|
||||
if (isScanLimitReached) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLaunching(true);
|
||||
const formData = new FormData();
|
||||
formData.set("providerId", providerId);
|
||||
const result =
|
||||
scheduleOption === SCAN_SCHEDULE.DAILY
|
||||
? await scheduleDaily(formData)
|
||||
: await scanOnDemand(formData);
|
||||
const scanResult = await launchOnDemandScan();
|
||||
|
||||
if (result?.error) {
|
||||
if (scanResult?.error) {
|
||||
setIsLaunching(false);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Unable to launch scan",
|
||||
description: String(result.error),
|
||||
description: String(scanResult.error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -75,11 +139,8 @@ export function LaunchStep({
|
||||
setIsLaunching(false);
|
||||
onClose();
|
||||
toast({
|
||||
title: "Scan Launched",
|
||||
description:
|
||||
scheduleOption === SCAN_SCHEDULE.DAILY
|
||||
? "Daily scan scheduled successfully."
|
||||
: "Single scan launched successfully.",
|
||||
title: "Scan launched",
|
||||
description: "The scan was launched successfully.",
|
||||
action: (
|
||||
<ToastAction altText="Go to scans" asChild>
|
||||
<Link href={`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`}>Go to scans</Link>
|
||||
@@ -88,8 +149,69 @@ export function LaunchStep({
|
||||
});
|
||||
};
|
||||
|
||||
launchActionRef.current = () => {
|
||||
void handleLaunchScan();
|
||||
const handleSaveSchedule = form.handleSubmit(async (values) => {
|
||||
if (!providerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLaunching(true);
|
||||
|
||||
const result = await saveScheduleWithInitialScan({
|
||||
providerId,
|
||||
values,
|
||||
useLegacyDaily: !isAdvanced,
|
||||
});
|
||||
|
||||
setIsLaunching(false);
|
||||
|
||||
if (result.status === SAVE_SCHEDULE_STATUS.ERROR) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Unable to save scan schedule",
|
||||
description: result.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
|
||||
const launched = result.status === SAVE_SCHEDULE_STATUS.SAVED_AND_LAUNCHED;
|
||||
const targetTab = launched ? SCAN_JOBS_TAB.ACTIVE : SCAN_JOBS_TAB.SCHEDULED;
|
||||
const goToScans = (
|
||||
<ToastAction altText="Go to scans" asChild>
|
||||
<Link href={`/scans?tab=${targetTab}`}>Go to scans</Link>
|
||||
</ToastAction>
|
||||
);
|
||||
|
||||
if (result.status === SAVE_SCHEDULE_STATUS.SAVED_SCAN_FAILED) {
|
||||
toast({
|
||||
title: "Scan schedule saved",
|
||||
description: `The schedule was saved, but the initial scan could not be launched: ${result.message}`,
|
||||
action: goToScans,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: launched
|
||||
? "Scan schedule saved and initial scan launched"
|
||||
: "Scan schedule saved",
|
||||
description: launched
|
||||
? "The schedule was saved and the initial scan was launched."
|
||||
: "The scan schedule was saved successfully.",
|
||||
action: goToScans,
|
||||
});
|
||||
});
|
||||
|
||||
// Keep the latest action handler in a ref so the footer (synced via effect)
|
||||
// always invokes the current closure without re-running on every render.
|
||||
const actionRef = useRef<() => void>(() => {});
|
||||
actionRef.current = () => {
|
||||
if (!isScheduleMode) {
|
||||
void handleManualScan();
|
||||
return;
|
||||
}
|
||||
void handleSaveSchedule();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -99,21 +221,30 @@ export function LaunchStep({
|
||||
backDisabled: isLaunching,
|
||||
onBack,
|
||||
showAction: true,
|
||||
actionLabel: isLaunching ? "Launching scans..." : "Launch scan",
|
||||
actionDisabled: isLaunching || !providerId,
|
||||
actionLabel,
|
||||
actionDisabled: isActionBlocked,
|
||||
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
|
||||
onAction: () => {
|
||||
launchActionRef.current();
|
||||
},
|
||||
onAction: () => actionRef.current(),
|
||||
});
|
||||
}, [isLaunching, onBack, onFooterChange, providerId]);
|
||||
}, [
|
||||
isActionBlocked,
|
||||
isLaunching,
|
||||
actionLabel,
|
||||
isScheduleMode,
|
||||
launchInitialScan,
|
||||
mode,
|
||||
onBack,
|
||||
onFooterChange,
|
||||
]);
|
||||
|
||||
if (isLaunching) {
|
||||
return (
|
||||
<div className="flex min-h-[320px] items-center justify-center">
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<Spinner className="size-6" />
|
||||
<p className="text-sm font-medium">Launching scans...</p>
|
||||
<p className="text-sm font-medium">
|
||||
{!isScheduleMode ? "Launching scan..." : "Saving scan schedule..."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -121,13 +252,21 @@ export function LaunchStep({
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-6">
|
||||
{(providerId || providerUid) && (
|
||||
<EntityInfo
|
||||
cloudProvider={providerType ?? undefined}
|
||||
entityAlias={providerAlias ?? providerUid ?? providerId ?? undefined}
|
||||
entityId={providerUid ?? providerId ?? undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<TreeStatusIcon status={TREE_ITEM_STATUS.SUCCESS} className="size-6" />
|
||||
<h3 className="text-sm font-semibold">Connection validated!</h3>
|
||||
<h3 className="text-sm font-semibold">Account Connected!</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Choose how you want to launch scans for this provider.
|
||||
Your account is connected to Prowler and ready to Scan!
|
||||
</p>
|
||||
|
||||
{!providerId && (
|
||||
@@ -136,28 +275,57 @@ export function LaunchStep({
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-text-neutral-secondary text-sm">Scan schedule</p>
|
||||
<Select
|
||||
value={scheduleOption}
|
||||
onValueChange={(value) =>
|
||||
setScheduleOption(value as ScanScheduleOption)
|
||||
}
|
||||
disabled={isLaunching || !providerId}
|
||||
<Field>
|
||||
<FieldLabel>Mode</FieldLabel>
|
||||
<RadioGroup
|
||||
value={mode}
|
||||
onValueChange={(value) => setMode(value as LaunchMode)}
|
||||
className="flex flex-row flex-wrap gap-6"
|
||||
aria-label="Scan mode"
|
||||
>
|
||||
<SelectTrigger className="w-full max-w-[376px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={SCAN_SCHEDULE.DAILY}>
|
||||
Scan Daily (every 24 hours)
|
||||
</SelectItem>
|
||||
<SelectItem value={SCAN_SCHEDULE.SINGLE}>
|
||||
Run a single scan (no recurring schedule)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<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 &&
|
||||
(isManualOnly ? (
|
||||
<CloudFeatureBadge label="Requires subscription" size="sm" />
|
||||
) : (
|
||||
<CloudFeatureBadgeLink size="sm" />
|
||||
))}
|
||||
</label>
|
||||
</RadioGroup>
|
||||
</Field>
|
||||
|
||||
{!isAdvanced && (
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Scheduled scans are not available for this account. Run now to get
|
||||
immediate findings.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isLimitBlocked && (
|
||||
<p className="text-text-error-primary text-sm">
|
||||
You have reached your scan limit, so additional scans are not
|
||||
available right now.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isScheduleMode && (
|
||||
<ScanScheduleFields
|
||||
form={form}
|
||||
disabled={isLaunching || !providerId}
|
||||
showLaunchInitialScan
|
||||
showNextScheduledCopy
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user