fix(ui): improve scan scheduling flows (#11688)

Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
This commit is contained in:
Prowler Bot
2026-06-24 17:39:08 +02:00
committed by GitHub
parent 8b4868fbde
commit 2c3980d4eb
20 changed files with 705 additions and 130 deletions
+8
View File
@@ -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
@@ -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(
<AccountsSelector
providers={providers}
placeholder="Select a Provider"
emptySelectionLabel="No provider selected"
clearSelectionLabel="Clear provider selection"
/>,
);
expect(screen.getByText("Select a Provider")).toBeInTheDocument();
expect(screen.getByText("No provider selected")).toBeInTheDocument();
});
it("allows disabling search explicitly", () => {
render(<AccountsSelector providers={providers} search={false} />);
@@ -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}
>
<MultiSelectTrigger id={id} aria-labelledby={labelId}>
{selectedLabel() || <MultiSelectValue placeholder="All Providers" />}
{selectedLabel() || <MultiSelectValue placeholder={placeholder} />}
</MultiSelectTrigger>
<MultiSelectContent search={search}>
{visibleProviders.length > 0 ? (
@@ -187,7 +193,9 @@ export function AccountsSelector({
}
}}
>
{selectedIds.length === 0 ? "All selected" : "Select All"}
{selectedIds.length === 0
? emptySelectionLabel
: clearSelectionLabel}
</div>
{visibleProviders.map((p) => {
const value = getProviderValue(p);
@@ -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,
@@ -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<string> => {
const scheduled = new Set<string>();
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<string>,
schedulesByProviderId: Record<string, ScheduleAttributes>,
): 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,
+3 -8
View File
@@ -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 (
<StackedCell
@@ -21,9 +20,5 @@ export const LinkToScans = ({ hasSchedule, schedule }: LinkToScansProps) => {
);
}
return (
<span className="text-text-neutral-secondary text-sm">
{hasSchedule ? "Daily" : "None"}
</span>
);
return <span className="text-text-neutral-secondary text-sm">None</span>;
};
@@ -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 }) => <span>{children}</span>,
}));
vi.mock("@/components/shadcn/checkbox/checkbox", () => ({
Checkbox: () => null,
}));
vi.mock("@/components/ui/code-snippet/code-snippet", () => ({
CodeSnippet: ({ value }: { value: string }) => <code>{value}</code>,
}));
vi.mock("@/components/ui/entities", () => ({
DateWithTime: ({ dateTime }: { dateTime: string | null }) => (
<time>{dateTime}</time>
),
EntityInfo: ({
entityAlias,
entityId,
}: {
entityAlias?: string;
entityId?: string;
}) => <span>{entityAlias ?? entityId}</span>,
}));
vi.mock("@/components/ui/table", () => ({
DataTableColumnHeader: ({ title }: { title: string }) => <span>{title}</span>,
}));
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 }) => (
<div>{children}</div>
),
}));
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<typeof cell>[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();
});
});
@@ -227,22 +227,25 @@ export function getColumnProviders(
return <span className="text-text-neutral-tertiary text-sm">-</span>;
}
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 (
<span className="text-text-neutral-tertiary text-sm">Never</span>
);
}
return <DateWithTime dateTime={lastCheckedAt} showTime />;
return <DateWithTime dateTime={lastScanAt} showTime />;
},
enableSorting: false,
},
{
id: "scanSchedule",
size: 140,
size: 180,
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Scan Schedule" />
),
@@ -255,12 +258,7 @@ export function getColumnProviders(
);
}
return (
<LinkToScans
hasSchedule={row.original.hasSchedule}
schedule={row.original.scheduleSummary}
/>
);
return <LinkToScans schedule={row.original.scheduleSummary} />;
},
enableSorting: false,
},
@@ -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(
<LaunchStep
onBack={vi.fn()}
onClose={onClose}
onFooterChange={onFooterChange}
/>,
);
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);
@@ -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<LaunchMode>(
isAdvanced ? LAUNCH_MODE.SCHEDULE : LAUNCH_MODE.NOW,
canUseScheduleMode ? LAUNCH_MODE.SCHEDULE : LAUNCH_MODE.NOW,
);
const form = useForm<ScheduleFormValues>({
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<ActionErrorResult | null> => {
if (!providerId || isBlocked) return null;
@@ -327,10 +329,10 @@ export function LaunchStep({
<RadioGroupItem
value={LAUNCH_MODE.SCHEDULE}
aria-label="On a schedule"
disabled={!isAdvanced}
disabled={!canUseScheduleMode}
/>
On a schedule
{!isAdvanced &&
{!canUseScheduleMode &&
!isBlocked &&
(isManualOnly ? (
<CloudFeatureBadge label="Requires subscription" size="sm" />
@@ -341,7 +343,7 @@ export function LaunchStep({
</RadioGroup>
</Field>
{!isAdvanced && !isBlocked && (
{isManualOnly && !isBlocked && (
<p className="text-text-neutral-secondary text-sm">
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}
/>
)}
</div>
+88 -2
View File
@@ -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;
}) => (
<div>
<input aria-label="Search Providers" placeholder="Search Providers..." />
@@ -108,7 +110,7 @@ vi.mock("@/app/(prowler)/_overview/_components/accounts-selector", () => ({
onBatchChange("provider_id__in", [event.target.value])
}
>
<option value="">All Providers</option>
<option value="">{placeholder}</option>
{providers.map((provider) => (
<option
key={provider.id}
@@ -191,6 +193,10 @@ describe("LaunchScanModal", () => {
scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } });
});
afterEach(() => {
vi.useRealTimers();
});
it("shows a searchable provider selector", () => {
render(
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
@@ -199,6 +205,16 @@ describe("LaunchScanModal", () => {
expect(screen.getByPlaceholderText("Search Providers...")).toBeVisible();
});
it("uses a single-provider placeholder in the launch selector", () => {
render(
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
);
expect(
screen.getByRole("option", { name: "Select a Provider" }),
).toBeInTheDocument();
});
it("disables disconnected providers in the launch selector", () => {
render(
<LaunchScanModal
@@ -438,6 +454,64 @@ describe("LaunchScanModal", () => {
);
});
it("keeps the upcoming local hour when provider scan fields say there is no schedule", async () => {
// Given
const user = userEvent.setup();
vi.setSystemTime(new Date(2026, 5, 10, 11, 59, 0, 0));
getScheduleMock.mockResolvedValue({
data: {
type: "schedules",
id: provider.id,
attributes: {
scan_enabled: true,
scan_frequency: "DAILY",
scan_hour: 0,
scan_timezone: "UTC",
scan_interval_hours: null,
scan_day_of_week: null,
scan_day_of_month: null,
},
},
});
const providerWithoutSchedule = {
...provider,
attributes: {
...provider.attributes,
scan_enabled: true,
scan_frequency: "DAILY" as const,
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,
},
};
render(
<LaunchScanModal
open
onOpenChange={vi.fn()}
providers={[providerWithoutSchedule]}
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
/>,
);
// When
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
await user.click(screen.getByRole("radio", { name: "On a schedule" }));
// Then
expect(
await screen.findByRole("combobox", { name: "Scan Time" }),
).toHaveTextContent("12:00pm");
expect(
screen.getByRole("combobox", { name: "Scan Time" }),
).not.toHaveTextContent("12:00am");
expect(getScheduleMock).not.toHaveBeenCalled();
});
it("launches the initial scan when the checkbox is checked", async () => {
const user = userEvent.setup();
renderAdvanced();
@@ -460,6 +534,18 @@ describe("LaunchScanModal", () => {
);
});
it("disables Save Schedule until a provider is selected", async () => {
const user = userEvent.setup();
renderAdvanced();
await user.click(screen.getByRole("radio", { name: "On a schedule" }));
expect(
screen.getByRole("button", { name: /save schedule/i }),
).toBeDisabled();
expect(getScheduleMock).not.toHaveBeenCalled();
});
it("locks schedule mode outside ADVANCED (OSS default)", () => {
render(
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
+24 -1
View File
@@ -22,6 +22,7 @@ import { FormButtons } from "@/components/ui/form";
import { toast, ToastAction } from "@/components/ui/toast";
import { getActionErrorMessage, hasActionError } from "@/lib/action-errors";
import {
buildScheduleAttributesFromProvider,
getScanScheduleCapability,
getScheduleFormDefaults,
getScheduleFormValues,
@@ -123,6 +124,14 @@ function LaunchScanForm({
.filter((provider) => provider.attributes.connection.connected !== true)
.map((provider) => provider.id);
const getProviderScheduleAttributes = (id: string) => {
const selectedProvider = providers.find((provider) => provider.id === id);
return selectedProvider
? buildScheduleAttributesFromProvider(selectedProvider.attributes)
: undefined;
};
const loadSchedule = async (id: string) => {
requestedProviderRef.current = id;
if (!id) {
@@ -130,6 +139,13 @@ function LaunchScanForm({
return;
}
const providerScheduleAttributes = getProviderScheduleAttributes(id);
if (providerScheduleAttributes) {
scheduleForm.reset(getScheduleFormValues(providerScheduleAttributes));
setScheduleLoad(SCHEDULE_LOAD_STATE.LOADED);
return;
}
setScheduleLoad(SCHEDULE_LOAD_STATE.LOADING);
const response = (await getSchedule(id)) as
| ScheduleApiResponse
@@ -279,6 +295,9 @@ function LaunchScanForm({
}
selectedValues={providerId ? [providerId] : []}
closeOnSelect
placeholder="Select a Provider"
emptySelectionLabel="No provider selected"
clearSelectionLabel="Clear provider selection"
/>
{providerError && <FieldError>{providerError}</FieldError>}
</Field>
@@ -365,7 +384,11 @@ function LaunchScanForm({
}
loadingText={isScheduleMode ? "Saving..." : "Launching..."}
isDisabled={
isSubmitting || !providers.length || isScheduleLoading || isBlocked
isSubmitting ||
!providers.length ||
isScheduleLoading ||
isBlocked ||
(isScheduleMode && !providerId)
}
rightIcon={<Rocket className="size-4" />}
/>
@@ -27,7 +27,13 @@ beforeAll(() => {
});
});
function ScheduleFieldsHarness() {
function ScheduleFieldsHarness({
canUseAdvancedSchedule = true,
showCloudUpgradeBadge = false,
}: {
canUseAdvancedSchedule?: boolean;
showCloudUpgradeBadge?: boolean;
} = {}) {
const form = useForm<ScheduleFormValues>({
defaultValues: getScheduleFormDefaults(),
});
@@ -36,7 +42,8 @@ function ScheduleFieldsHarness() {
<ScanScheduleFields
form={form}
showNextScheduledCopy
canUseAdvancedSchedule
canUseAdvancedSchedule={canUseAdvancedSchedule}
showCloudUpgradeBadge={showCloudUpgradeBadge}
/>
);
}
@@ -73,4 +80,42 @@ describe("ScanScheduleFields", () => {
}),
).not.toBeInTheDocument();
});
it("uses ordinal copy for monthly schedules", async () => {
// Given
const user = userEvent.setup();
render(<ScheduleFieldsHarness />);
// When
await user.click(screen.getByRole("combobox", { name: /repeats/i }));
await user.click(screen.getByRole("option", { name: /monthly/i }));
// Then
expect(getHelperCopy(/Monthly on the 1st/)).toBeInTheDocument();
expect(getHelperCopy(/Monthly on the 1st/)).not.toHaveTextContent(
/Monthly on day/,
);
});
it("shows a single cloud badge beside the Scan Schedule title when advanced controls are locked", () => {
// Given
render(
<ScheduleFieldsHarness
canUseAdvancedSchedule={false}
showCloudUpgradeBadge
/>,
);
// Then
expect(screen.getAllByText("Available in Prowler Cloud")).toHaveLength(1);
expect(screen.getByText("Scan Schedule").parentElement).toHaveTextContent(
"Available in Prowler Cloud",
);
expect(screen.getByText("Scan Time").parentElement).not.toHaveTextContent(
"Available in Prowler Cloud",
);
expect(screen.getByText("Repeats").parentElement).not.toHaveTextContent(
"Available in Prowler Cloud",
);
});
});
@@ -17,6 +17,7 @@ import {
} from "@/components/shadcn";
import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge";
import {
formatDayOfMonth,
formatScheduleHour,
getBrowserTimezone,
getNextScheduledRun,
@@ -59,20 +60,18 @@ interface ScanScheduleFieldsProps {
* (interval/weekly/monthly) are disabled. Used for non-Cloud (OSS) accounts.
*/
canUseAdvancedSchedule?: boolean;
/** Render the "Available in Prowler Cloud" upsell badge on locked controls. */
/** Render the "Available in Prowler Cloud" upsell badge in the header. */
showCloudUpgradeBadge?: boolean;
}
function NumberSelect({
label,
labelAddon,
value,
values,
onChange,
disabled,
}: {
label: string;
labelAddon?: ReactNode;
value: number;
values: ReadonlyArray<{ value: number; label: string }>;
onChange: (value: number) => void;
@@ -80,10 +79,7 @@ function NumberSelect({
}) {
return (
<Field>
<div className="flex items-center justify-between gap-2">
<FieldLabel>{label}</FieldLabel>
{labelAddon}
</div>
<FieldLabel>{label}</FieldLabel>
<Select
value={String(value)}
onValueChange={(nextValue) => onChange(Number(nextValue))}
@@ -119,7 +115,7 @@ function getScheduleSummary({
case SCHEDULE_FREQUENCY.WEEKLY:
return `Weekly on ${SCHEDULE_WEEKDAY_LABELS[dayOfWeek] ?? SCHEDULE_WEEKDAY_LABELS[0]}`;
case SCHEDULE_FREQUENCY.MONTHLY:
return `Monthly on day ${dayOfMonth}`;
return `Monthly on the ${formatDayOfMonth(dayOfMonth)}`;
default:
return "Daily";
}
@@ -164,6 +160,7 @@ export function ScanScheduleFields({
<h3 className="text-text-neutral-primary text-sm font-medium">
Scan Schedule
</h3>
{cloudUpgradeBadge}
</div>
{headerAction}
</div>
@@ -175,7 +172,6 @@ export function ScanScheduleFields({
render={({ field }) => (
<NumberSelect
label="Scan Time"
labelAddon={cloudUpgradeBadge}
value={field.value}
values={HOUR_OPTIONS}
onChange={field.onChange}
@@ -189,10 +185,7 @@ export function ScanScheduleFields({
name="frequency"
render={({ field }) => (
<Field>
<div className="flex items-center justify-between gap-2">
<FieldLabel>Repeats</FieldLabel>
{cloudUpgradeBadge}
</div>
<FieldLabel>Repeats</FieldLabel>
<Select
value={
canUseAdvancedSchedule
@@ -139,7 +139,10 @@ describe("DataTable", () => {
// Then
expect(nameHeader).not.toHaveClass("sticky");
expect(nameCell).not.toHaveClass("sticky");
expect(nameHeader).toHaveClass("pr-6");
expect(nameCell).toHaveClass("pr-6");
expect(actionsHeaderElement).not.toHaveClass("sticky");
expect(actionsHeaderElement).not.toHaveClass("pr-6");
expect(actionsHeaderElement).not.toHaveClass("right-0");
expect(actionsHeaderElement).not.toHaveClass("z-20");
expect(actionsHeaderElement).toHaveClass("bg-bg-neutral-tertiary");
@@ -159,6 +162,7 @@ describe("DataTable", () => {
expect(actionsHeaderElement).not.toHaveClass("after:rounded-r-full");
expect(actionsHeaderElement.querySelector("div")).not.toBeInTheDocument();
expect(actionsCell).toHaveClass("sticky");
expect(actionsCell).not.toHaveClass("pr-6");
expect(actionsCell).toHaveClass("right-0");
expect(actionsCell).toHaveClass("z-20");
expect(actionsCell).toHaveClass("bg-bg-neutral-secondary");
+9 -5
View File
@@ -45,16 +45,20 @@ type DataTableRowAttributes = {
*/
const DEFAULT_COLUMN_SIZE = 150;
const ACTIONS_COLUMN_ID = "actions";
const TABLE_COLUMN_GAP_CLASS = "pr-6";
const STICKY_ACTION_COLUMN_CLASS = "sticky right-0 z-20 min-w-12";
const STICKY_ACTION_CELL_CLASS = `${STICKY_ACTION_COLUMN_CLASS} last:rounded-r-none! overflow-visible bg-bg-neutral-secondary before:pointer-events-none before:absolute before:inset-y-0 before:-left-8 before:w-8 before:bg-gradient-to-r before:from-transparent before:to-bg-neutral-secondary before:content-[''] group-hover:bg-bg-neutral-tertiary group-hover:before:to-bg-neutral-tertiary group-data-[state=selected]:bg-bg-neutral-tertiary group-data-[state=selected]:before:to-bg-neutral-tertiary`;
const getStickyActionColumnClassName = (
const getTableColumnClassName = (
columnId: string,
variant: "header" | "cell",
) => {
if (columnId !== ACTIONS_COLUMN_ID) return undefined;
const isActionsColumn = columnId === ACTIONS_COLUMN_ID;
return variant === "header" ? undefined : STICKY_ACTION_CELL_CLASS;
return cn(
!isActionsColumn && TABLE_COLUMN_GAP_CLASS,
isActionsColumn && variant === "cell" && STICKY_ACTION_CELL_CLASS,
);
};
interface DataTableProviderProps<TData, TValue> {
@@ -302,7 +306,7 @@ export function DataTable<TData, TValue>({
return (
<TableHead
key={header.id}
className={getStickyActionColumnClassName(
className={getTableColumnClassName(
header.column.id,
"header",
)}
@@ -348,7 +352,7 @@ export function DataTable<TData, TValue>({
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={getStickyActionColumnClassName(
className={getTableColumnClassName(
cell.column.id,
"cell",
)}
+92 -12
View File
@@ -2,12 +2,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import {
buildProviderScheduleSummary,
buildScheduleAttributesFromProvider,
buildSchedulesByProviderId,
buildScheduleUpdatePayload,
formatDayOfMonth,
formatScheduleHour,
getBrowserTimezone,
getNextScheduledRun,
getScanScheduleCapability,
getScheduleFormDefaults,
getScheduleFormValues,
isScheduleConfigured,
} from "@/lib/schedules";
@@ -161,6 +164,25 @@ describe("formatScheduleHour", () => {
});
});
describe("formatDayOfMonth", () => {
it.each([
[1, "1st"],
[2, "2nd"],
[3, "3rd"],
[4, "4th"],
[11, "11th"],
[12, "12th"],
[13, "13th"],
[21, "21st"],
[22, "22nd"],
[23, "23rd"],
[24, "24th"],
[31, "31st"],
])("formats day %i as %s", (day, expected) => {
expect(formatDayOfMonth(day)).toBe(expected);
});
});
describe("isScheduleConfigured", () => {
it("treats a null scan_hour as not configured", () => {
expect(isScheduleConfigured({ scan_hour: null })).toBe(false);
@@ -175,6 +197,26 @@ describe("isScheduleConfigured", () => {
});
});
describe("getScheduleFormDefaults", () => {
it("uses the current local hour when the clock is exactly on the hour", () => {
expect(
getScheduleFormDefaults(new Date(2026, 5, 10, 10, 0, 0, 0)).hour,
).toBe(10);
});
it("uses the next local hour when the current hour already started", () => {
expect(
getScheduleFormDefaults(new Date(2026, 5, 10, 10, 30, 0, 0)).hour,
).toBe(11);
});
it("wraps the upcoming hour from 23:xx to 0", () => {
expect(
getScheduleFormDefaults(new Date(2026, 5, 10, 23, 1, 0, 0)).hour,
).toBe(0);
});
});
describe("getScheduleFormValues", () => {
const buildAttributes = (
overrides: Partial<ScheduleAttributes> = {},
@@ -190,7 +232,9 @@ describe("getScheduleFormValues", () => {
});
it("returns defaults when there is no schedule", () => {
expect(getScheduleFormValues(null)).toEqual({
expect(
getScheduleFormValues(null, new Date(2026, 5, 10, 0, 0, 0, 0)),
).toEqual({
frequency: SCHEDULE_FREQUENCY.DAILY,
hour: 0,
dayOfWeek: 1,
@@ -201,16 +245,19 @@ describe("getScheduleFormValues", () => {
});
it("returns defaults when scan_hour is null (unconfigured provider)", () => {
expect(getScheduleFormValues(buildAttributes({ scan_hour: null }))).toEqual(
{
frequency: SCHEDULE_FREQUENCY.DAILY,
hour: 0,
dayOfWeek: 1,
dayOfMonth: 1,
intervalHours: 48,
launchInitialScan: false,
},
);
expect(
getScheduleFormValues(
buildAttributes({ scan_hour: null }),
new Date(2026, 5, 10, 0, 0, 0, 0),
),
).toEqual({
frequency: SCHEDULE_FREQUENCY.DAILY,
hour: 0,
dayOfWeek: 1,
dayOfMonth: 1,
intervalHours: 48,
launchInitialScan: false,
});
});
it("maps a configured schedule onto the form", () => {
@@ -439,6 +486,39 @@ describe("buildSchedulesByProviderId", () => {
});
});
describe("buildScheduleAttributesFromProvider", () => {
it("returns undefined when the provider payload does not include scan fields", () => {
expect(buildScheduleAttributesFromProvider({})).toBeUndefined();
});
it("keeps scan_hour null as an unconfigured provider schedule", () => {
const attributes = buildScheduleAttributesFromProvider({
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,
});
expect(attributes).toEqual({
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,
});
expect(isScheduleConfigured(attributes!)).toBe(false);
});
});
describe("buildProviderScheduleSummary", () => {
const buildAttributes = (
overrides: Partial<ScheduleAttributes> = {},
@@ -463,7 +543,7 @@ describe("buildProviderScheduleSummary", () => {
],
[
{ scan_frequency: SCHEDULE_FREQUENCY.MONTHLY, scan_day_of_month: 15 },
"Monthly on day 15",
"Monthly on the 15th",
],
[
{ scan_frequency: SCHEDULE_FREQUENCY.INTERVAL, scan_interval_hours: 72 },
+61 -5
View File
@@ -12,13 +12,21 @@ import {
type ScheduleUpdatePayload,
} from "@/types/schedules";
const DEFAULT_SCHEDULE_HOUR = 0;
const DEFAULT_DAY_OF_WEEK = 1;
const DEFAULT_DAY_OF_MONTH = 1;
// The backend (prowler-cloud) enforces SCAN_INTERVAL_HOURS_MIN = 24. 48 is well
// above that floor.
const SCAN_INTERVAL_HOURS_MIN = 24;
const DEFAULT_INTERVAL_HOURS = 48;
const ordinalRules = new Intl.PluralRules("en-US", { type: "ordinal" });
const DAY_OF_MONTH_SUFFIXES: Record<Intl.LDMLPluralRule, string> = {
zero: "th",
one: "st",
two: "nd",
few: "rd",
many: "th",
other: "th",
};
export const scheduleFormSchema = z.object({
frequency: z.enum(SCHEDULE_FREQUENCY),
@@ -65,6 +73,10 @@ export function formatScheduleHour(hour: number): string {
return `${displayHour}:00${period}`;
}
export function formatDayOfMonth(day: number): string {
return `${day}${DAY_OF_MONTH_SUFFIXES[ordinalRules.select(day)]}`;
}
export function getBrowserTimezone(): string {
if (typeof window === "undefined") {
return "UTC";
@@ -73,10 +85,19 @@ export function getBrowserTimezone(): string {
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
}
export function getScheduleFormDefaults(): ScheduleFormValues {
function getDefaultScheduleHour(now: Date): number {
const isOnTheHour =
now.getMinutes() === 0 &&
now.getSeconds() === 0 &&
now.getMilliseconds() === 0;
return (now.getHours() + (isOnTheHour ? 0 : 1)) % 24;
}
export function getScheduleFormDefaults(now = new Date()): ScheduleFormValues {
return {
frequency: SCHEDULE_FREQUENCY.DAILY,
hour: DEFAULT_SCHEDULE_HOUR,
hour: getDefaultScheduleHour(now),
dayOfWeek: DEFAULT_DAY_OF_WEEK,
dayOfMonth: DEFAULT_DAY_OF_MONTH,
intervalHours: DEFAULT_INTERVAL_HOURS,
@@ -86,8 +107,9 @@ export function getScheduleFormDefaults(): ScheduleFormValues {
export function getScheduleFormValues(
schedule?: ScheduleAttributes | null,
now = new Date(),
): ScheduleFormValues {
const defaults = getScheduleFormDefaults();
const defaults = getScheduleFormDefaults(now);
if (!schedule || schedule.scan_hour === null) {
return defaults;
@@ -148,6 +170,38 @@ export function buildSchedulesByProviderId(
return byProviderId;
}
interface ProviderScheduleAttributeSource {
scan_enabled?: boolean | null;
scan_frequency?: ScheduleAttributes["scan_frequency"] | null;
scan_hour?: number | null;
scan_timezone?: string | null;
scan_interval_hours?: number | null;
scan_day_of_week?: number | null;
scan_day_of_month?: number | null;
next_scan_at?: string | null;
last_scan_at?: string | null;
}
export function buildScheduleAttributesFromProvider(
attributes: ProviderScheduleAttributeSource,
): ScheduleAttributes | undefined {
if (!Object.prototype.hasOwnProperty.call(attributes, "scan_hour")) {
return undefined;
}
return {
scan_enabled: attributes.scan_enabled ?? true,
scan_frequency: attributes.scan_frequency ?? SCHEDULE_FREQUENCY.DAILY,
scan_hour: attributes.scan_hour ?? null,
scan_timezone: attributes.scan_timezone ?? "UTC",
scan_interval_hours: attributes.scan_interval_hours ?? null,
scan_day_of_week: attributes.scan_day_of_week ?? null,
scan_day_of_month: attributes.scan_day_of_month ?? null,
next_scan_at: attributes.next_scan_at,
last_scan_at: attributes.last_scan_at,
};
}
/**
* Whether a provider has an explicitly configured scan schedule.
*
@@ -224,7 +278,9 @@ export function getScheduleCadenceParts(
}
case SCHEDULE_FREQUENCY.MONTHLY:
return {
cadence: `Monthly on day ${attributes.scan_day_of_month ?? 1}`,
cadence: `Monthly on the ${formatDayOfMonth(
attributes.scan_day_of_month ?? 1,
)}`,
time,
};
case SCHEDULE_FREQUENCY.INTERVAL:
+2
View File
@@ -55,6 +55,8 @@ export interface ProvidersProviderRow
hasSchedule: boolean;
/** Cadence/next-run summary when the provider has a configured schedule. */
scheduleSummary?: ScanScheduleSummary;
/** Completed-at timestamp for the provider's last scan when exposed by API. */
lastScanAt?: string | null;
subRows?: ProvidersTableRow[];
}
+11
View File
@@ -1,3 +1,5 @@
import type { ScheduleFrequency } from "./schedules";
export const PROVIDER_TYPES = [
"aws",
"azure",
@@ -65,6 +67,15 @@ export interface ProviderProps {
};
inserted_at: string;
updated_at: string;
scan_frequency?: ScheduleFrequency | null;
scan_hour?: number | null;
scan_day_of_week?: number | null;
scan_day_of_month?: number | null;
scan_interval_hours?: number | null;
scan_timezone?: string | null;
scan_enabled?: boolean | null;
next_scan_at?: string | null;
last_scan_at?: string | null;
created_by: {
object: string;
id: string;