From 76286f1186852f0731ca1dc71ad60579eff75afc Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:35:53 +0200 Subject: [PATCH] fix(ui): improve scan scheduling flows (#11684) --- ui/CHANGELOG.md | 8 ++ .../_components/accounts-selector.test.tsx | 15 ++ .../_components/accounts-selector.tsx | 12 +- .../providers/providers-page.utils.test.ts | 107 +++++++++++++- .../providers/providers-page.utils.ts | 76 ++++------ ui/components/providers/link-to-scans.tsx | 11 +- .../providers/table/column-providers.test.tsx | 131 ++++++++++++++++++ .../providers/table/column-providers.tsx | 20 ++- .../wizard/steps/launch-step.test.tsx | 53 +++++-- .../providers/wizard/steps/launch-step.tsx | 18 ++- .../scans/launch-scan-modal.test.tsx | 90 +++++++++++- ui/components/scans/launch-scan-modal.tsx | 25 +++- .../schedule/scan-schedule-fields.test.tsx | 49 ++++++- .../scans/schedule/scan-schedule-fields.tsx | 19 +-- ui/components/ui/table/data-table.test.tsx | 4 + ui/components/ui/table/data-table.tsx | 14 +- ui/lib/schedules.test.ts | 104 ++++++++++++-- ui/lib/schedules.ts | 66 ++++++++- ui/types/providers-table.ts | 2 + ui/types/providers.ts | 11 ++ 20 files changed, 705 insertions(+), 130 deletions(-) create mode 100644 ui/components/providers/table/column-providers.test.tsx diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index d1951c68a8..7d76c8a27f 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to the **Prowler UI** are documented in this file. +## [1.31.1] (Prowler v5.31.1) + +### 🔄 Changed + +- Schedule Scans provider table and launch flows now use provider schedule fields, restore OSS daily scheduling, default to the next local scan hour, and clarify provider selection in launch scan [(#11684)](https://github.com/prowler-cloud/prowler/pull/11684) + +--- + ## [1.31.0] (Prowler v5.31.0) ### 🚀 Added diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx index 8a738ef5e1..13f37fad4f 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx @@ -147,9 +147,24 @@ describe("AccountsSelector", () => { placeholder: "Search Providers...", emptyMessage: "No Providers found.", }); + expect(screen.getByText("All Providers")).toBeInTheDocument(); expect(screen.getByText("Production AWS")).toBeInTheDocument(); }); + it("supports contextual placeholder and empty-selection copy", () => { + render( + , + ); + + expect(screen.getByText("Select a Provider")).toBeInTheDocument(); + expect(screen.getByText("No provider selected")).toBeInTheDocument(); + }); + it("allows disabling search explicitly", () => { render(); diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx index 128784517f..8a7c08b11c 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx @@ -32,6 +32,9 @@ interface AccountsSelectorBaseProps { id?: string; disabledValues?: string[]; closeOnSelect?: boolean; + placeholder?: string; + emptySelectionLabel?: string; + clearSelectionLabel?: string; } /** Batch mode: caller controls both pending state and notification callback (all-or-nothing). */ @@ -73,6 +76,9 @@ export function AccountsSelector({ emptyMessage: "No Providers found.", }, closeOnSelect = false, + placeholder = "All Providers", + emptySelectionLabel = "All selected", + clearSelectionLabel = "Select All", }: AccountsSelectorProps) { const searchParams = useSearchParams(); const { navigateWithParams } = useUrlFilters(); @@ -163,7 +169,7 @@ export function AccountsSelector({ onOpenChange={closeOnSelect ? setSelectorOpen : undefined} > - {selectedLabel() || } + {selectedLabel() || } {visibleProviders.length > 0 ? ( @@ -187,7 +193,9 @@ export function AccountsSelector({ } }} > - {selectedIds.length === 0 ? "All selected" : "Select All"} + {selectedIds.length === 0 + ? emptySelectionLabel + : clearSelectionLabel} {visibleProviders.map((p) => { const value = getProviderValue(p); diff --git a/ui/app/(prowler)/providers/providers-page.utils.test.ts b/ui/app/(prowler)/providers/providers-page.utils.test.ts index 5727807d85..6cac891480 100644 --- a/ui/app/(prowler)/providers/providers-page.utils.test.ts +++ b/ui/app/(prowler)/providers/providers-page.utils.test.ts @@ -886,6 +886,103 @@ describe("loadProvidersAccountsViewData", () => { ).toBeUndefined(); }); + it("uses provider schedule attributes as authoritative when scan_hour is null", async () => { + // Given — provider-1 still has a materialized scheduled scan row, but the + // provider payload says the schedule was removed. + providersActionsMock.getProviders.mockResolvedValue({ + ...providersResponse, + data: [ + { + ...providersResponse.data[0], + attributes: { + ...providersResponse.data[0].attributes, + scan_enabled: true, + scan_frequency: SCHEDULE_FREQUENCY.DAILY, + scan_hour: null, + scan_timezone: "UTC", + scan_interval_hours: null, + scan_day_of_week: null, + scan_day_of_month: null, + next_scan_at: null, + last_scan_at: null, + }, + }, + providersResponse.data[1], + ], + }); + providersActionsMock.getAllProviders.mockResolvedValue(providersResponse); + scansActionsMock.getScans.mockResolvedValue({ + data: [ + { + type: "scans", + id: "scan-1", + attributes: { trigger: "scheduled", state: "scheduled" }, + relationships: { + provider: { data: { type: "providers", id: "provider-1" } }, + }, + }, + ], + }); + schedulesActionsMock.getSchedules.mockResolvedValue({ + data: [buildSchedule("provider-1", { scan_hour: 9 })], + }); + + // When + const viewData = await loadProvidersAccountsViewData({ + searchParams: {} satisfies SearchParamsProps, + isCloud: false, + }); + + // Then + const providerRow = findProviderRow(viewData.rows, "provider-1"); + expect(providerRow?.hasSchedule).toBe(false); + expect(providerRow?.scheduleSummary).toBeUndefined(); + expect(providerRow?.lastScanAt).toBeNull(); + }); + + it("builds provider schedule and last scan values from the provider payload", async () => { + // Given + providersActionsMock.getProviders.mockResolvedValue({ + ...providersResponse, + data: [ + { + ...providersResponse.data[0], + attributes: { + ...providersResponse.data[0].attributes, + scan_enabled: true, + scan_frequency: SCHEDULE_FREQUENCY.MONTHLY, + scan_hour: 8, + scan_timezone: "Europe/Madrid", + scan_interval_hours: null, + scan_day_of_week: null, + scan_day_of_month: 24, + next_scan_at: "2026-06-24T06:00:00Z", + last_scan_at: "2026-06-23T06:00:00Z", + }, + }, + providersResponse.data[1], + ], + }); + providersActionsMock.getAllProviders.mockResolvedValue(providersResponse); + scansActionsMock.getScans.mockResolvedValue({ data: [] }); + schedulesActionsMock.getSchedules.mockResolvedValue({ error: "Not found" }); + + // When + const viewData = await loadProvidersAccountsViewData({ + searchParams: {} satisfies SearchParamsProps, + isCloud: false, + }); + + // Then + const providerRow = findProviderRow(viewData.rows, "provider-1"); + expect(providerRow?.hasSchedule).toBe(true); + expect(providerRow?.scheduleSummary?.cadence).toBe("Monthly on the 24th"); + expect(providerRow?.scheduleSummary?.nextScanAt).toBe( + "2026-06-24T06:00:00Z", + ); + expect(providerRow?.lastScanAt).toBe("2026-06-23T06:00:00Z"); + }); + it("ignores paused or unconfigured schedules", async () => { // Given — provider-1 paused (disabled), provider-2 never configured. providersActionsMock.getProviders.mockResolvedValue(providersResponse); @@ -913,8 +1010,10 @@ describe("loadProvidersAccountsViewData", () => { ); }); - it("falls back to scan-based detection when /schedules is unavailable (OSS)", async () => { - // Given — /schedules errors, but provider-1 has a materialized scheduled scan. + it("does not infer provider schedules from materialized scans when /schedules is unavailable", async () => { + // Given — /schedules errors, and provider-1 still has a materialized + // scheduled scan. That scan is historical execution state, not schedule + // configuration. providersActionsMock.getProviders.mockResolvedValue(providersResponse); providersActionsMock.getAllProviders.mockResolvedValue(providersResponse); scansActionsMock.getScans.mockResolvedValue({ @@ -937,9 +1036,9 @@ describe("loadProvidersAccountsViewData", () => { isCloud: false, }); - // Then — scan-based path still flags the provider; no throw from the error. + // Then — only provider scan_* fields or /schedules can mark a schedule. expect(findProviderRow(viewData.rows, "provider-1")?.hasSchedule).toBe( - true, + false, ); expect(findProviderRow(viewData.rows, "provider-2")?.hasSchedule).toBe( false, diff --git a/ui/app/(prowler)/providers/providers-page.utils.ts b/ui/app/(prowler)/providers/providers-page.utils.ts index 32ce2a5321..0b589557bb 100644 --- a/ui/app/(prowler)/providers/providers-page.utils.ts +++ b/ui/app/(prowler)/providers/providers-page.utils.ts @@ -3,7 +3,6 @@ import { listOrganizationUnitsSafe, } from "@/actions/organizations/organizations"; import { getAllProviders, getProviders } from "@/actions/providers"; -import { getScans } from "@/actions/scans"; import { getSchedules } from "@/actions/schedules"; import { extractFiltersAndQuery, @@ -11,6 +10,7 @@ import { } from "@/lib/helper-filters"; import { buildProviderScheduleSummary, + buildScheduleAttributesFromProvider, buildSchedulesByProviderId, isScheduleConfigured, } from "@/lib/schedules"; @@ -33,7 +33,7 @@ import { ProvidersTableRow, ProvidersTableRowsInput, } from "@/types/providers-table"; -import { SCAN_TRIGGER, ScanProps, ScanScheduleSummary } from "@/types/scans"; +import { ScanScheduleSummary } from "@/types/scans"; import { ScheduleAttributes } from "@/types/schedules"; const PROVIDERS_STATUS_MAPPING = [ @@ -114,26 +114,6 @@ const createProviderGroupLookup = ( return lookup; }; -const ACTIVE_SCAN_STATES = new Set(["scheduled", "available", "executing"]); - -const buildScheduledProviderIds = (scans: ScanProps[]): Set => { - const scheduled = new Set(); - - for (const scan of scans) { - if ( - scan.attributes.trigger === SCAN_TRIGGER.SCHEDULED && - ACTIVE_SCAN_STATES.has(scan.attributes.state) - ) { - const providerId = scan.relationships.provider?.data?.id; - if (providerId) { - scheduled.add(providerId); - } - } - } - - return scheduled; -}; - // A schedule is backed by the Provider row itself, so its `/schedules` entry // exists before the first scheduled Scan is materialized — only enabled, // configured ones carry a displayable cadence summary. @@ -145,17 +125,33 @@ const buildProviderScheduleSummaryFor = ( ? buildProviderScheduleSummary(attributes, now) : undefined; +const getProviderLastScanAt = ( + provider: ProvidersApiResponse["data"][number], +): string | null => { + if ( + Object.prototype.hasOwnProperty.call(provider.attributes, "last_scan_at") + ) { + return provider.attributes.last_scan_at ?? null; + } + + return provider.attributes.connection.last_checked_at ?? null; +}; + const enrichProviders = ( providersResponse: ProvidersApiResponse | undefined, - scanScheduledProviderIds: Set, schedulesByProviderId: Record, ): ProvidersProviderRow[] => { const providerGroupLookup = createProviderGroupLookup(providersResponse); const now = new Date(); return (providersResponse?.data ?? []).map((provider) => { + const providerScheduleAttributes = buildScheduleAttributesFromProvider( + provider.attributes, + ); + const scheduleAttributes = + providerScheduleAttributes ?? schedulesByProviderId[provider.id]; const scheduleSummary = buildProviderScheduleSummaryFor( - schedulesByProviderId[provider.id], + scheduleAttributes, now, ); @@ -167,11 +163,11 @@ const enrichProviders = ( (providerGroup: { id: string }) => providerGroupLookup.get(providerGroup.id) ?? "Unknown Group", ) ?? [], - // A fired scheduled scan OR a configured schedule that hasn't fired yet. - hasSchedule: - scanScheduledProviderIds.has(provider.id) || - scheduleSummary !== undefined, + // Provider scan_* fields are authoritative when present; otherwise we + // only fall back to the /schedules resource, never materialized scans. + hasSchedule: scheduleSummary !== undefined, scheduleSummary, + lastScanAt: getProviderLastScanAt(provider), }; }); }; @@ -506,7 +502,6 @@ export async function loadProvidersAccountsViewData({ const [ providersResponse, allProvidersResponse, - scansResponse, schedulesResponse, organizationsResponse, organizationUnitsResponse, @@ -523,18 +518,8 @@ export async function loadProvidersAccountsViewData({ // Unfiltered fetch for ProviderTypeSelector — only needs distinct types; // TODO: Replace with a dedicated lightweight endpoint when available. resolveActionResult(getAllProviders()), - // Fetch active scheduled scans to flag providers whose schedule has fired. - resolveActionResult( - getScans({ - pageSize: 500, - filters: { - "filter[trigger]": SCAN_TRIGGER.SCHEDULED, - "filter[state__in]": "scheduled,available", - }, - }), - ), - // Fetch configured schedules to also flag providers whose schedule has not - // fired yet (best-effort: absent in OSS, where the helper yields no ids). + // Fetch configured schedules as a fallback when provider scan_* fields are + // absent (best-effort: typically empty in OSS). resolveActionResult(getSchedules()), isCloud ? listOrganizationsSafe() @@ -544,18 +529,11 @@ export async function loadProvidersAccountsViewData({ : Promise.resolve(emptyOrganizationUnitsResponse), ]); - const scanScheduledProviderIds = buildScheduledProviderIds( - scansResponse?.data ?? [], - ); const schedulesByProviderId = buildSchedulesByProviderId(schedulesResponse); const orgs = organizationsResponse?.data ?? []; const ous = organizationUnitsResponse?.data ?? []; - const providers = enrichProviders( - providersResponse, - scanScheduledProviderIds, - schedulesByProviderId, - ); + const providers = enrichProviders(providersResponse, schedulesByProviderId); const rows = buildProvidersTableRows({ isCloud, diff --git a/ui/components/providers/link-to-scans.tsx b/ui/components/providers/link-to-scans.tsx index fee1ec407d..cbdfa573a5 100644 --- a/ui/components/providers/link-to-scans.tsx +++ b/ui/components/providers/link-to-scans.tsx @@ -5,13 +5,12 @@ import { formatLocalTimeWithZone } from "@/lib/date-utils"; import type { ScanScheduleSummary } from "@/types/scans"; interface LinkToScansProps { - hasSchedule: boolean; schedule?: ScanScheduleSummary; } // Matches the scans table Schedule column: cadence on top, next-run local time -// underneath. Falls back to a plain label when the cadence is unknown. -export const LinkToScans = ({ hasSchedule, schedule }: LinkToScansProps) => { +// underneath. Falls back to None when no configured schedule is present. +export const LinkToScans = ({ schedule }: LinkToScansProps) => { if (schedule) { return ( { ); } - return ( - - {hasSchedule ? "Daily" : "None"} - - ); + return None; }; diff --git a/ui/components/providers/table/column-providers.test.tsx b/ui/components/providers/table/column-providers.test.tsx new file mode 100644 index 0000000000..c2e9abc18c --- /dev/null +++ b/ui/components/providers/table/column-providers.test.tsx @@ -0,0 +1,131 @@ +import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { + PROVIDERS_ROW_TYPE, + type ProvidersProviderRow, + type ProvidersTableRow, +} from "@/types/providers-table"; + +import { getColumnProviders } from "./column-providers"; + +vi.mock("@/components/shadcn", () => ({ + Badge: ({ children }: { children: ReactNode }) => {children}, +})); + +vi.mock("@/components/shadcn/checkbox/checkbox", () => ({ + Checkbox: () => null, +})); + +vi.mock("@/components/ui/code-snippet/code-snippet", () => ({ + CodeSnippet: ({ value }: { value: string }) => {value}, +})); + +vi.mock("@/components/ui/entities", () => ({ + DateWithTime: ({ dateTime }: { dateTime: string | null }) => ( + + ), + EntityInfo: ({ + entityAlias, + entityId, + }: { + entityAlias?: string; + entityId?: string; + }) => {entityAlias ?? entityId}, +})); + +vi.mock("@/components/ui/table", () => ({ + DataTableColumnHeader: ({ title }: { title: string }) => {title}, +})); + +vi.mock("@/components/ui/table/data-table-expand-all-toggle", () => ({ + DataTableExpandAllToggle: () => null, +})); + +vi.mock("@/components/ui/table/data-table-expandable-cell", () => ({ + DataTableExpandableCell: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("../link-to-scans", () => ({ + LinkToScans: () => null, +})); + +vi.mock("./data-table-row-actions", () => ({ + DataTableRowActions: () => null, +})); + +const providerRow: ProvidersProviderRow = { + id: "provider-1", + rowType: PROVIDERS_ROW_TYPE.PROVIDER, + type: "providers", + attributes: { + provider: "aws", + uid: "123456789012", + alias: "Production", + status: "completed", + resources: 0, + connection: { + connected: true, + last_checked_at: "2026-01-01T00:00:00Z", + }, + scanner_args: { + only_logs: false, + excluded_checks: [], + aws_retries_max_attempts: 3, + }, + inserted_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + created_by: { + object: "user", + id: "user-1", + }, + }, + relationships: { + secret: { data: { id: "secret-1", type: "secrets" } }, + provider_groups: { meta: { count: 0 }, data: [] }, + }, + groupNames: [], + hasSchedule: false, +}; + +function renderLastScanCell(row: ProvidersTableRow) { + const lastScanColumn = getColumnProviders( + {}, + [], + [], + [], + vi.fn(), + vi.fn(), + vi.fn(), + ).find((column) => column.id === "lastScan"); + + const cell = lastScanColumn?.cell; + if (typeof cell !== "function") { + throw new Error("Last Scan column cell renderer not found"); + } + + const element = cell({ + row: { original: row }, + } as unknown as Parameters[0]); + + render(<>{element as ReactNode}); +} + +describe("getColumnProviders", () => { + it("falls back to connection last_checked_at when lastScanAt is undefined", () => { + renderLastScanCell({ ...providerRow, lastScanAt: undefined }); + + expect(screen.getByText("2026-01-01T00:00:00Z")).toBeVisible(); + expect(screen.queryByText("Never")).not.toBeInTheDocument(); + }); + + it("treats a null lastScanAt as authoritative", () => { + renderLastScanCell({ ...providerRow, lastScanAt: null }); + + expect(screen.getByText("Never")).toBeVisible(); + expect(screen.queryByText("2026-01-01T00:00:00Z")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/providers/table/column-providers.tsx b/ui/components/providers/table/column-providers.tsx index c88c61b78e..3f93cd2b45 100644 --- a/ui/components/providers/table/column-providers.tsx +++ b/ui/components/providers/table/column-providers.tsx @@ -227,22 +227,25 @@ export function getColumnProviders( return -; } - const lastCheckedAt = (row.original as ProvidersProviderRow).attributes - .connection.last_checked_at; + const provider = row.original as ProvidersProviderRow; + const lastScanAt = + provider.lastScanAt !== undefined + ? provider.lastScanAt + : provider.attributes.connection.last_checked_at; - if (!lastCheckedAt) { + if (!lastScanAt) { return ( Never ); } - return ; + return ; }, enableSorting: false, }, { id: "scanSchedule", - size: 140, + size: 180, header: ({ column }) => ( ), @@ -255,12 +258,7 @@ export function getColumnProviders( ); } - return ( - - ); + return ; }, enableSorting: false, }, diff --git a/ui/components/providers/wizard/steps/launch-step.test.tsx b/ui/components/providers/wizard/steps/launch-step.test.tsx index 6cf7d3cfa5..e382bfbf71 100644 --- a/ui/components/providers/wizard/steps/launch-step.test.tsx +++ b/ui/components/providers/wizard/steps/launch-step.test.tsx @@ -79,7 +79,7 @@ describe("LaunchStep", () => { scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } }); }); - it("defaults to run now and locks schedule mode outside Cloud", async () => { + it("defaults to daily schedule mode and locks advanced cadence outside Cloud", async () => { // Given const onFooterChange = vi.fn(); seedConnectedProvider(); @@ -94,19 +94,20 @@ describe("LaunchStep", () => { // Then expect(screen.getByText("Account Connected!")).toBeInTheDocument(); - expect(screen.getByRole("radio", { name: "Run now" })).toBeChecked(); expect( screen.getByRole("radio", { name: "On a schedule" }), - ).toBeDisabled(); + ).toBeChecked(); + expect(screen.getByRole("radio", { name: "Run now" })).not.toBeChecked(); expect( - screen.queryByRole("combobox", { name: /repeats/i }), - ).not.toBeInTheDocument(); + screen.getByRole("radio", { name: "On a schedule" }), + ).toBeEnabled(); + expect(screen.getByRole("combobox", { name: /repeats/i })).toBeDisabled(); await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); - expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe("Launch scan"); + expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe("Save"); }); - it("launches only an on-demand scan and never creates a legacy daily schedule", async () => { + it("saves a legacy daily schedule by default", async () => { // Given const onClose = vi.fn(); const onFooterChange = vi.fn(); @@ -127,9 +128,43 @@ describe("LaunchStep", () => { }); // Then - await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalledTimes(1)); - const sentFormData = scanOnDemandMock.mock.calls[0]?.[0] as FormData; + await waitFor(() => expect(scheduleDailyMock).toHaveBeenCalledTimes(1)); + const sentFormData = scheduleDailyMock.mock.calls[0]?.[0] as FormData; expect(sentFormData.get("providerId")).toBe("provider-1"); + expect(scanOnDemandMock).not.toHaveBeenCalled(); + expect(updateScheduleMock).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("launches only an on-demand scan when run now is selected", async () => { + // Given + const user = userEvent.setup(); + const onClose = vi.fn(); + const onFooterChange = vi.fn(); + seedConnectedProvider(); + + render( + , + ); + await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); + + // When + await user.click(screen.getByRole("radio", { name: "Run now" })); + await waitFor(() => + expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe( + "Launch scan", + ), + ); + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalledTimes(1)); expect(scheduleDailyMock).not.toHaveBeenCalled(); expect(updateScheduleMock).not.toHaveBeenCalled(); expect(onClose).toHaveBeenCalledTimes(1); diff --git a/ui/components/providers/wizard/steps/launch-step.tsx b/ui/components/providers/wizard/steps/launch-step.tsx index 51a5727f63..59f6bcd578 100644 --- a/ui/components/providers/wizard/steps/launch-step.tsx +++ b/ui/components/providers/wizard/steps/launch-step.tsx @@ -93,17 +93,19 @@ export function LaunchStep({ const capability = capabilityProp ?? getScanScheduleCapability(isCloud()); const isManualOnly = capability === SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY; const isAdvanced = capability === SCAN_SCHEDULE_CAPABILITY.ADVANCED; + const isDailyLegacy = capability === SCAN_SCHEDULE_CAPABILITY.DAILY_LEGACY; const isBlocked = capability === SCAN_SCHEDULE_CAPABILITY.BLOCKED; + const canUseScheduleMode = isAdvanced || isDailyLegacy; const [isLaunching, setIsLaunching] = useState(false); const [mode, setMode] = useState( - isAdvanced ? LAUNCH_MODE.SCHEDULE : LAUNCH_MODE.NOW, + canUseScheduleMode ? LAUNCH_MODE.SCHEDULE : LAUNCH_MODE.NOW, ); const form = useForm({ resolver: zodResolver(scheduleFormSchema), defaultValues: getScheduleFormDefaults(), }); - const isScheduleMode = isAdvanced && mode === LAUNCH_MODE.SCHEDULE; + const isScheduleMode = canUseScheduleMode && mode === LAUNCH_MODE.SCHEDULE; const isLimitBlocked = mode === LAUNCH_MODE.NOW && isScanLimitReached; const isActionBlocked = isLaunching || @@ -129,10 +131,10 @@ export function LaunchStep({ })(); useEffect(() => { - if (!isAdvanced && mode !== LAUNCH_MODE.NOW) { + if (!canUseScheduleMode && mode !== LAUNCH_MODE.NOW) { setMode(LAUNCH_MODE.NOW); } - }, [isAdvanced, mode]); + }, [canUseScheduleMode, mode]); const launchOnDemandScan = async (): Promise => { if (!providerId || isBlocked) return null; @@ -327,10 +329,10 @@ export function LaunchStep({ On a schedule - {!isAdvanced && + {!canUseScheduleMode && !isBlocked && (isManualOnly ? ( @@ -341,7 +343,7 @@ export function LaunchStep({ - {!isAdvanced && !isBlocked && ( + {isManualOnly && !isBlocked && (

Scheduled scans are not available for this account. Run now to get immediate findings. @@ -361,6 +363,8 @@ export function LaunchStep({ disabled={isLaunching || !providerId} showLaunchInitialScan showNextScheduledCopy + canUseAdvancedSchedule={isAdvanced} + showCloudUpgradeBadge={isDailyLegacy} /> )} diff --git a/ui/components/scans/launch-scan-modal.test.tsx b/ui/components/scans/launch-scan-modal.test.tsx index 73e648a691..f413603caf 100644 --- a/ui/components/scans/launch-scan-modal.test.tsx +++ b/ui/components/scans/launch-scan-modal.test.tsx @@ -1,7 +1,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ComponentProps } from "react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { getScheduleMock, @@ -91,12 +91,14 @@ vi.mock("@/app/(prowler)/_overview/_components/accounts-selector", () => ({ onBatchChange, selectedValues, id, + placeholder = "All Providers", }: { disabledValues?: string[]; providers: { id: string; attributes: { alias: string; uid: string } }[]; onBatchChange: (filterKey: string, values: string[]) => void; selectedValues: string[]; id?: string; + placeholder?: string; }) => (

@@ -108,7 +110,7 @@ vi.mock("@/app/(prowler)/_overview/_components/accounts-selector", () => ({ onBatchChange("provider_id__in", [event.target.value]) } > - + {providers.map((provider) => (