Compare commits

...

26 Commits

Author SHA1 Message Date
alejandrobailo d636841832 test(ui): align multiselect width test with content sizing 2026-06-10 19:01:44 +02:00
alejandrobailo 870b32ed5d fix(ui): show cadence in browser timezone with next_scan_at fallback 2026-06-10 19:01:44 +02:00
alejandrobailo 5cda01a0df refactor(ui): extract StackedCell primitive for two-line table cells 2026-06-10 19:01:44 +02:00
alejandrobailo f80a56b88c fix(ui): remove redundant date from the scan type cell 2026-06-10 17:49:01 +02:00
alejandrobailo 9a9f0989de feat(ui): add next and last scan columns to the scheduled tab 2026-06-10 17:49:01 +02:00
alejandrobailo 689916132a refactor(ui): extract local time formatting from DateWithTime 2026-06-10 17:49:00 +02:00
alejandrobailo 37a700dd4c style(ui): capitalize the scheduled scan alias 2026-06-10 14:57:45 +02:00
alejandrobailo e38f249cd4 feat(ui): mark pending schedule rows with a tooltip badge 2026-06-10 14:57:45 +02:00
alejandrobailo 51c7e4f0b8 fix(ui): align schedule removal with header and confirm before deleting 2026-06-10 14:57:44 +02:00
alejandrobailo 67e105cfeb fix(ui): hide fabricated id on pending schedule rows 2026-06-10 14:20:23 +02:00
alejandrobailo 48d7e7aa06 fix(ui): use useWatch so schedule fields react under React Compiler 2026-06-10 14:20:23 +02:00
alejandrobailo 91b8f9dcce chore(ui): remove temporary api-debug logging 2026-06-10 14:10:55 +02:00
alejandrobailo 35748cc6b0 feat(ui): schedule scans from the launch scan modal 2026-06-10 14:10:55 +02:00
alejandrobailo 1d80e2dc17 feat(ui): show pending schedules and cadence in scan jobs tabs 2026-06-10 13:55:32 +02:00
alejandrobailo 14551245c4 chore(ui): add temporary api-debug logging to scan actions 2026-06-10 13:55:32 +02:00
alejandrobailo 4bc7a32159 fix(ui): align scan schedule estimates and actions with backend contract
- Compute the INTERVAL next-run estimate from the backend anchor (next
  occurrence of scan_hour) instead of now + 48h
- Preserve a custom scan_interval_hours when editing instead of silently
  rewriting it to 48, with a dynamic frequency label
- Label the MANUAL_ONLY wizard action "Launch scan" instead of "Save"
2026-06-10 10:31:07 +02:00
alejandrobailo 81b636a2e7 feat(ui): add scan-jobs navigation and schedule row actions 2026-06-08 11:14:34 +02:00
alejandrobailo 3c5239a870 feat(ui): compute next scheduled run and lock advanced cadence in OSS 2026-06-08 11:14:30 +02:00
alejandrobailo f74f5eba0f fix(ui): keep filter dropdown on one line and size to content 2026-06-08 11:14:26 +02:00
alejandrobailo 9cf5c30d3e feat(ui): gate scan scheduling by capability for OSS and Cloud 2026-06-05 14:14:31 +02:00
alejandrobailo a8998ad091 chore(ui): add shared isCloud environment helper 2026-06-05 14:14:24 +02:00
alejandrobailo c68b226582 Merge remote-tracking branch 'origin/master' into feat/scan-schedule-ui 2026-06-05 09:48:03 +02:00
alejandrobailo e62ae1cf0a feat(ui): wire scan schedule UI into provider and scan tables 2026-06-04 11:22:03 +02:00
alejandrobailo 21f20fa332 feat(ui): add scan schedule modal and fields components 2026-06-04 11:22:03 +02:00
alejandrobailo 47267f39d0 feat(ui): add scan schedule server actions 2026-06-04 11:22:03 +02:00
alejandrobailo 1559cdf9e8 feat(ui): add scan schedule types and helpers
Schedule domain types and the schedules lib utilities with unit tests.
2026-06-04 11:22:03 +02:00
39 changed files with 3628 additions and 255 deletions
+10 -8
View File
@@ -157,18 +157,20 @@ export const scheduleDaily = async (formData: FormData) => {
const url = new URL(`${apiBaseUrl}/schedules/daily`);
const body = {
data: {
type: "daily-schedules",
attributes: {
provider_id: providerId,
},
},
};
try {
const response = await fetch(url.toString(), {
method: "POST",
headers,
body: JSON.stringify({
data: {
type: "daily-schedules",
attributes: {
provider_id: providerId,
},
},
}),
body: JSON.stringify(body),
});
return handleApiResponse(response, "/scans");
+1
View File
@@ -0,0 +1 @@
export * from "./schedules";
+86
View File
@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SCHEDULE_FREQUENCY } from "@/types/schedules";
const {
fetchMock,
getAuthHeadersMock,
handleApiErrorMock,
handleApiResponseMock,
revalidatePathMock,
} = vi.hoisted(() => ({
fetchMock: vi.fn(),
getAuthHeadersMock: vi.fn(),
handleApiErrorMock: vi.fn(),
handleApiResponseMock: vi.fn(),
revalidatePathMock: vi.fn(),
}));
vi.mock("next/cache", () => ({
revalidatePath: revalidatePathMock,
}));
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: getAuthHeadersMock,
}));
vi.mock("@/lib/server-actions-helper", () => ({
handleApiError: handleApiErrorMock,
handleApiResponse: handleApiResponseMock,
}));
import { removeSchedule, updateSchedule } from "./schedules";
const payload = {
scan_enabled: true,
scan_frequency: SCHEDULE_FREQUENCY.DAILY,
scan_hour: 12,
scan_timezone: "Europe/Madrid",
scan_interval_hours: null,
scan_day_of_week: null,
scan_day_of_month: null,
};
describe("schedule write actions revalidate only on success", () => {
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
fetchMock.mockResolvedValue(new Response(null, { status: 204 }));
handleApiErrorMock.mockReturnValue({ error: "Failed" });
});
it("revalidates /scans and /providers after a successful update", async () => {
handleApiResponseMock.mockResolvedValue({ success: true });
await updateSchedule("provider-1", payload);
expect(revalidatePathMock).toHaveBeenCalledWith("/scans");
expect(revalidatePathMock).toHaveBeenCalledWith("/providers");
});
it("does not revalidate when the update returns an error result", async () => {
handleApiResponseMock.mockResolvedValue({ error: "Schedule rejected" });
await updateSchedule("provider-1", payload);
expect(revalidatePathMock).not.toHaveBeenCalled();
});
it("revalidates /scans and /providers after a successful delete", async () => {
handleApiResponseMock.mockResolvedValue({ success: true });
await removeSchedule("provider-1");
expect(revalidatePathMock).toHaveBeenCalledWith("/scans");
expect(revalidatePathMock).toHaveBeenCalledWith("/providers");
});
it("does not revalidate when the delete returns an error result", async () => {
handleApiResponseMock.mockResolvedValue({ error: "Not allowed" });
await removeSchedule("provider-1");
expect(revalidatePathMock).not.toHaveBeenCalled();
});
});
+110
View File
@@ -0,0 +1,110 @@
"use server";
import { revalidatePath } from "next/cache";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
import type { ScheduleProps, ScheduleUpdatePayload } from "@/types/schedules";
function revalidateScheduleViews() {
revalidatePath("/scans");
revalidatePath("/providers");
}
export const getSchedule = async (providerId: string) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/schedules/${providerId}`);
url.searchParams.set("include", "provider");
try {
const response = await fetch(url.toString(), { headers });
return handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
};
/**
* Lists every schedule (one per provider), following pagination — the backend
* has no multi-provider filter.
*/
export const getSchedules = async () => {
const headers = await getAuthHeaders({ contentType: false });
const schedules: ScheduleProps[] = [];
const MAX_PAGES = 20;
try {
for (let page = 1; page <= MAX_PAGES; page++) {
const url = new URL(`${apiBaseUrl}/schedules`);
url.searchParams.set("page[number]", String(page));
url.searchParams.set("page[size]", "100");
const response = await fetch(url.toString(), { headers });
const result = await handleApiResponse(response);
if (result?.error) return result;
schedules.push(...(result?.data ?? []));
const totalPages = result?.meta?.pagination?.pages ?? 1;
if (page >= totalPages) break;
}
return { data: schedules };
} catch (error) {
return handleApiError(error);
}
};
export const updateSchedule = async (
providerId: string,
payload: ScheduleUpdatePayload,
) => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/schedules/${providerId}`);
const body = {
data: {
type: "schedules",
id: providerId,
attributes: payload,
},
};
try {
const response = await fetch(url.toString(), {
method: "PATCH",
headers,
body: JSON.stringify(body),
});
const result = await handleApiResponse(response);
if (!result?.error) {
revalidateScheduleViews();
}
return result;
} catch (error) {
return handleApiError(error);
}
};
export const removeSchedule = async (providerId: string) => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/schedules/${providerId}`);
try {
const response = await fetch(url.toString(), {
method: "DELETE",
headers,
});
const result = await handleApiResponse(response);
if (!result?.error) {
revalidateScheduleViews();
}
return result;
} catch (error) {
return handleApiError(error);
}
};
+112 -3
View File
@@ -2,8 +2,10 @@ import { Suspense } from "react";
import { getAllProviders } from "@/actions/providers";
import { getScans } from "@/actions/scans";
import { getSchedules } from "@/actions/schedules";
import { auth } from "@/auth.config";
import {
buildPendingScheduleRows,
getScanJobsTab,
getScanJobsTabFilters,
getScanJobsUserFilters,
@@ -13,10 +15,19 @@ import { ScansProvidersEmptyState } from "@/components/scans/scans-providers-emp
import { SkeletonTableScans } from "@/components/scans/table";
import { ScanJobsTable } from "@/components/scans/table/scan-jobs-table";
import { ContentLayout } from "@/components/ui";
import {
describeScheduleCadence,
getNextScheduledRunInTimezone,
getScheduleCadenceParts,
isScheduleConfigured,
} from "@/lib/schedules";
import {
ProviderProps,
SCAN_JOBS_TAB,
SCAN_TRIGGER,
ScanProps,
ScheduleAttributes,
ScheduleProps,
SearchParamsProps,
} from "@/types";
@@ -31,6 +42,37 @@ const getFilterSearchQuery = (
return value ?? "";
};
const parseCsvParam = (value?: string | string[]): string[] => {
const rawValue = Array.isArray(value) ? value.join(",") : value;
if (!rawValue) return [];
return rawValue
.split(",")
.map((item) => item.trim())
.filter(Boolean);
};
/** Applies the table's provider filters to synthetic pending-schedule rows. */
const filterProvidersForPendingRows = (
providers: ProviderProps[],
searchParams: SearchParamsProps,
): ProviderProps[] => {
const uids = parseCsvParam(
searchParams["filter[provider_uid__in]"] ??
searchParams["filter[provider_uid]"],
);
const types = parseCsvParam(
searchParams["filter[provider_type__in]"] ??
searchParams["filter[provider_type]"],
);
return providers.filter(
(provider) =>
(uids.length === 0 || uids.includes(provider.attributes.uid)) &&
(types.length === 0 || types.includes(provider.attributes.provider)),
);
};
const getActiveScanCount = async (
searchParams: SearchParamsProps,
): Promise<number> => {
@@ -95,7 +137,10 @@ export default async function Scans({
/>
}
>
<SSRDataTableScans searchParams={resolvedSearchParams} />
<SSRDataTableScans
searchParams={resolvedSearchParams}
providers={providers}
/>
</Suspense>
</ScansPageShell>
)}
@@ -105,8 +150,10 @@ export default async function Scans({
const SSRDataTableScans = async ({
searchParams,
providers,
}: {
searchParams: SearchParamsProps;
providers: ProviderProps[];
}) => {
const tab = getScanJobsTab(searchParams.tab);
@@ -142,7 +189,7 @@ const SSRDataTableScans = async ({
const included = scansData?.included;
const meta = scansData && "meta" in scansData ? scansData.meta : undefined;
const expandedScansData =
const expandedScansData: ScanProps[] =
scans?.map((scan: ScanProps) => {
const providerId = scan.relationships?.provider?.data?.id;
@@ -163,9 +210,71 @@ const SSRDataTableScans = async ({
};
}) || [];
const needsSchedules =
tab === SCAN_JOBS_TAB.SCHEDULED ||
expandedScansData.some(
(scan) => scan.attributes.trigger === SCAN_TRIGGER.SCHEDULED,
);
const schedulesResult = needsSchedules ? await getSchedules() : null;
const schedulesByProviderId: Record<string, ScheduleAttributes> = {};
if (schedulesResult && !schedulesResult.error) {
for (const schedule of (schedulesResult.data ?? []) as ScheduleProps[]) {
schedulesByProviderId[schedule.id] = schedule.attributes;
}
}
const scansWithSchedule = expandedScansData.map((scan) => {
if (scan.attributes.trigger !== SCAN_TRIGGER.SCHEDULED) return scan;
const providerId = scan.relationships?.provider?.data?.id;
const schedule = providerId ? schedulesByProviderId[providerId] : undefined;
if (!schedule || !isScheduleConfigured(schedule)) return scan;
// Absent field (older API) -> client estimate; explicit null (paused) -> no time.
const nextScanAt =
schedule.next_scan_at === undefined && schedule.scan_enabled
? (getNextScheduledRunInTimezone(schedule, new Date())?.toISOString() ??
null)
: (schedule.next_scan_at ?? null);
return {
...scan,
providerSchedule: {
summary: describeScheduleCadence(schedule),
cadence: getScheduleCadenceParts(schedule).cadence,
nextScanAt,
lastScanAt: schedule.last_scan_at ?? null,
},
};
});
let tableData = scansWithSchedule;
if (tab === SCAN_JOBS_TAB.SCHEDULED) {
// Append pending rows only after the last page of real rows.
const totalPages = meta?.pagination?.pages ?? 0;
if (page >= totalPages) {
const coveredProviderIds = new Set(
scansWithSchedule
.map((scan) => scan.relationships?.provider?.data?.id)
.filter((id): id is string => Boolean(id)),
);
tableData = [
...scansWithSchedule,
...buildPendingScheduleRows({
providers: filterProvidersForPendingRows(providers, searchParams),
schedulesByProviderId,
coveredProviderIds,
now: new Date(),
}),
];
}
}
return (
<ScanJobsTable
data={expandedScansData}
data={tableData}
meta={meta}
tab={tab}
hasFilters={hasUserFilters}
+8 -3
View File
@@ -10,15 +10,20 @@ interface LinkToScansProps {
}
export const LinkToScans = ({ hasSchedule, providerUid }: LinkToScansProps) => {
// Match the key the scans filter bar binds to (`provider_uid__in`) so the
// provider is pre-selected, and encode UIDs with URL-unsafe chars (e.g.
// GitHub UIDs like https://github.com/org/repo).
const scansHref = `/scans?${new URLSearchParams({
"filter[provider_uid__in]": providerUid ?? "",
}).toString()}`;
return (
<div className="flex items-center gap-1">
<span className="text-text-neutral-secondary text-sm">
{hasSchedule ? "Daily" : "None"}
</span>
<Button asChild variant="link" size="sm" className="text-xs">
<Link href={`/scans?filter[provider_uid]=${providerUid}`}>
View Jobs
</Link>
<Link href={scansHref}>View Jobs</Link>
</Button>
</div>
);
@@ -1,7 +1,7 @@
import { Row } from "@tanstack/react-table";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations";
import {
@@ -10,7 +10,17 @@ import {
ProvidersTableRow,
} from "@/types/providers-table";
const checkConnectionProviderMock = vi.hoisted(() => vi.fn());
const { checkConnectionProviderMock, getScheduleMock, pushMock } = vi.hoisted(
() => ({
checkConnectionProviderMock: vi.fn(),
getScheduleMock: vi.fn(),
pushMock: vi.fn(),
}),
);
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock }),
}));
vi.mock("@/actions/organizations/organizations", () => ({
updateOrganizationName: vi.fn(),
@@ -20,6 +30,10 @@ vi.mock("@/actions/providers/providers", () => ({
checkConnectionProvider: checkConnectionProviderMock,
}));
vi.mock("@/actions/schedules", () => ({
getSchedule: getScheduleMock,
}));
vi.mock("../forms/delete-form", () => ({
DeleteForm: () => null,
}));
@@ -32,6 +46,26 @@ vi.mock("../forms/edit-name-form", () => ({
EditNameForm: () => null,
}));
vi.mock("@/components/scans/schedule/edit-scan-schedule-modal", () => ({
EDIT_SCAN_SCHEDULE_STATE: {
LOADING: "loading",
LOADED: "loaded",
ERROR: "error",
},
EditScanScheduleModal: ({
open,
provider,
}: {
open: boolean;
provider?: { providerId: string };
}) =>
open ? (
<div role="dialog" aria-label="Edit Scan Schedule">
Editing schedule for {provider?.providerId}
</div>
) : null,
}));
vi.mock("@/components/ui", () => ({
useToast: () => ({ toast: vi.fn() }),
}));
@@ -143,6 +177,23 @@ const createOuRow = () =>
}) as unknown as Row<ProvidersTableRow>;
describe("DataTableRowActions", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
beforeEach(() => {
getScheduleMock.mockResolvedValue({
data: {
type: "schedules",
id: "provider-1",
attributes: { scan_hour: null },
relationships: {
provider: { data: { type: "providers", id: "provider-1" } },
},
},
});
});
it("renders Add Credentials for provider rows without credentials", async () => {
// Given
const user = userEvent.setup();
@@ -163,12 +214,97 @@ describe("DataTableRowActions", () => {
// Then
expect(screen.getByText("Edit Provider Alias")).toBeInTheDocument();
// Advanced schedule editing is gated to Prowler Cloud subscribed accounts.
expect(screen.queryByText("Edit Scan Schedule")).not.toBeInTheDocument();
expect(screen.getByText("Add Credentials")).toBeInTheDocument();
expect(screen.getByText("Test Connection")).toBeInTheDocument();
expect(screen.getByText("Delete Provider")).toBeInTheDocument();
expect(screen.queryByText("Update Credentials")).not.toBeInTheDocument();
});
it("navigates to the provider-filtered scan jobs from View Scan Jobs", async () => {
// Given
const user = userEvent.setup();
render(
<DataTableRowActions
row={createRow(true)}
hasSelection={false}
isRowSelected={false}
testableProviderIds={[]}
onClearSelection={vi.fn()}
onOpenProviderWizard={vi.fn()}
onOpenOrganizationWizard={vi.fn()}
/>,
);
// When
await user.click(screen.getByRole("button"));
await user.click(screen.getByText("View Scan Jobs"));
// Then: navigates with the key the scans filter bar binds to
// (provider_uid__in), URL-encoded, so the provider is pre-selected.
expect(pushMock).toHaveBeenCalledWith(
"/scans?filter%5Bprovider_uid__in%5D=111111111111",
);
});
it("URL-encodes provider UIDs that contain unsafe characters (e.g. GitHub)", async () => {
// Given a GitHub provider whose UID is a URL.
const user = userEvent.setup();
const row = createRow(true);
(
row.original as unknown as { attributes: { uid: string } }
).attributes.uid = "https://github.com/prowler-cloud/prowler";
render(
<DataTableRowActions
row={row}
hasSelection={false}
isRowSelected={false}
testableProviderIds={[]}
onClearSelection={vi.fn()}
onOpenProviderWizard={vi.fn()}
onOpenOrganizationWizard={vi.fn()}
/>,
);
// When
await user.click(screen.getByRole("button"));
await user.click(screen.getByText("View Scan Jobs"));
// Then the ':' and '/' are encoded instead of leaking into the URL raw.
expect(pushMock).toHaveBeenCalledWith(
"/scans?filter%5Bprovider_uid__in%5D=https%3A%2F%2Fgithub.com%2Fprowler-cloud%2Fprowler",
);
});
it("opens Edit Scan Schedule for Prowler Cloud subscribed provider rows", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
const user = userEvent.setup();
render(
<DataTableRowActions
row={createRow(true)}
hasSelection={false}
isRowSelected={false}
testableProviderIds={[]}
onClearSelection={vi.fn()}
onOpenProviderWizard={vi.fn()}
onOpenOrganizationWizard={vi.fn()}
/>,
);
// When
await user.click(screen.getByRole("button"));
await user.click(screen.getByText("Edit Scan Schedule"));
// Then
expect(
screen.getByRole("dialog", { name: /edit scan schedule/i }),
).toHaveTextContent("Editing schedule for provider-1");
});
it("renders Update Credentials for provider rows with credentials", async () => {
// Given
const user = userEvent.setup();
@@ -1,16 +1,31 @@
"use client";
import { Row } from "@tanstack/react-table";
import { KeyRound, Pencil, Rocket, Trash2 } from "lucide-react";
import {
CalendarClock,
KeyRound,
Pencil,
Rocket,
Timer,
Trash2,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { updateOrganizationName } from "@/actions/organizations/organizations";
import { updateProvider } from "@/actions/providers";
import { getSchedule } from "@/actions/schedules";
import {
ORG_WIZARD_INTENT,
OrgWizardInitialData,
ProviderWizardInitialData,
} from "@/components/providers/wizard/types";
import {
EDIT_SCAN_SCHEDULE_STATE,
EditScanScheduleModal,
type EditScanScheduleState,
type ScanScheduleProvider,
} from "@/components/scans/schedule/edit-scan-schedule-modal";
import {
ActionDropdown,
ActionDropdownDangerZone,
@@ -20,6 +35,8 @@ import { Modal } from "@/components/shadcn/modal";
import { useToast } from "@/components/ui";
import { runWithConcurrencyLimit } from "@/lib/concurrency";
import { testProviderConnection } from "@/lib/provider-helpers";
import { getScanScheduleCapability } from "@/lib/schedules";
import { isCloud } from "@/lib/shared/env";
import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations";
import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
import {
@@ -29,6 +46,11 @@ import {
ProvidersOrganizationRow,
ProvidersTableRow,
} from "@/types/providers-table";
import {
SCAN_SCHEDULE_CAPABILITY,
type ScanScheduleCapability,
type ScheduleApiResponse,
} from "@/types/schedules";
import { DeleteForm } from "../forms/delete-form";
import { DeleteOrganizationForm } from "../forms/delete-organization-form";
@@ -46,6 +68,13 @@ interface DataTableRowActionsProps {
onClearSelection: () => void;
onOpenProviderWizard: (initialData?: ProviderWizardInitialData) => void;
onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void;
/**
* Schedule capability override. Absent in OSS (defaults to a Cloud-vs-non-Cloud
* decision). The prowler-cloud overlay injects a billing-aware capability so
* only subscribed Cloud accounts can open the advanced schedule editor (which
* talks to the new schedule API).
*/
capability?: ScanScheduleCapability;
}
function collectTestableChildProviderIds(rows: ProvidersTableRow[]): string[] {
@@ -200,11 +229,20 @@ export function DataTableRowActions({
onClearSelection,
onOpenProviderWizard,
onOpenOrganizationWizard,
capability,
}: DataTableRowActionsProps) {
const canEditSchedule =
(capability ?? getScanScheduleCapability(isCloud())) ===
SCAN_SCHEDULE_CAPABILITY.ADVANCED;
const [isEditOpen, setIsEditOpen] = useState(false);
const [isScheduleOpen, setIsScheduleOpen] = useState(false);
const [scheduleState, setScheduleState] = useState<EditScanScheduleState>({
kind: EDIT_SCAN_SCHEDULE_STATE.LOADING,
});
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { toast } = useToast();
const router = useRouter();
const rowData = row.original;
const isOrganizationRow = isProvidersOrganizationRow(rowData);
@@ -215,6 +253,14 @@ export function DataTableRowActions({
const providerAlias = provider?.attributes.alias ?? null;
const providerSecretId = provider?.relationships.secret.data?.id ?? null;
const hasSecret = Boolean(provider?.relationships.secret.data);
const scheduleProvider: ScanScheduleProvider | undefined = provider
? {
providerId,
providerType,
providerUid,
providerAlias,
}
: undefined;
const orgGroupKind = isOrganizationRow ? rowData.groupKind : null;
const childTestableIds = isOrganizationRow
@@ -283,6 +329,40 @@ export function DataTableRowActions({
await handleBulkTest(childTestableIds);
};
const openScheduleEditor = async () => {
if (!providerId) {
setScheduleState({
kind: EDIT_SCAN_SCHEDULE_STATE.ERROR,
message: "Provider ID is not available.",
});
setIsScheduleOpen(true);
return;
}
setScheduleState({ kind: EDIT_SCAN_SCHEDULE_STATE.LOADING });
setIsScheduleOpen(true);
const response = (await getSchedule(providerId)) as
| ScheduleApiResponse
| { error?: string };
if (!response || ("error" in response && response.error)) {
setScheduleState({
kind: EDIT_SCAN_SCHEDULE_STATE.ERROR,
message:
response && "error" in response && response.error
? response.error
: "Failed to load scan schedule.",
});
return;
}
setScheduleState({
kind: EDIT_SCAN_SCHEDULE_STATE.LOADED,
schedule: "data" in response ? response.data : null,
});
};
// When this row is part of the selection, only show "Test Connection"
if (hasSelection && isRowSelected) {
const bulkCount =
@@ -364,6 +444,12 @@ export function DataTableRowActions({
<DeleteForm providerId={providerId} setIsOpen={setIsDeleteOpen} />
)}
</Modal>
<EditScanScheduleModal
open={isScheduleOpen}
onOpenChange={setIsScheduleOpen}
provider={scheduleProvider}
state={scheduleState}
/>
<div className="relative flex items-center justify-end gap-2">
<ActionDropdown>
<ActionDropdownItem
@@ -371,6 +457,27 @@ export function DataTableRowActions({
label="Edit Provider Alias"
onSelect={() => setIsEditOpen(true)}
/>
<ActionDropdownItem
icon={<Timer />}
label="View Scan Jobs"
onSelect={() => {
// Use the same key the scans filter bar binds to
// (`provider_uid__in`) so the provider is pre-selected in the UI,
// and URLSearchParams to encode UIDs that contain URL-unsafe chars
// (e.g. GitHub UIDs like https://github.com/org/repo).
const params = new URLSearchParams({
"filter[provider_uid__in]": providerUid,
});
router.push(`/scans?${params.toString()}`);
}}
/>
{canEditSchedule && (
<ActionDropdownItem
icon={<CalendarClock />}
label="Edit Scan Schedule"
onSelect={() => void openScheduleEditor()}
/>
)}
<ActionDropdownItem
icon={<KeyRound />}
label={hasSecret ? "Update Credentials" : "Add Credentials"}
@@ -1,21 +1,32 @@
import { act, render, waitFor } from "@testing-library/react";
import { act, 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";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { SCAN_JOBS_TAB } from "@/types";
import {
SCAN_SCHEDULE_CAPABILITY,
SCHEDULE_FREQUENCY,
} from "@/types/schedules";
import { LaunchStep } from "./launch-step";
const { scheduleDailyMock, scanOnDemandMock, toastMock } = vi.hoisted(() => ({
scheduleDailyMock: vi.fn(),
scanOnDemandMock: vi.fn(),
toastMock: vi.fn(),
}));
const { scanOnDemandMock, scheduleDailyMock, toastMock, updateScheduleMock } =
vi.hoisted(() => ({
scanOnDemandMock: vi.fn(),
scheduleDailyMock: vi.fn(),
toastMock: vi.fn(),
updateScheduleMock: vi.fn(),
}));
vi.mock("@/actions/scans", () => ({
scheduleDaily: scheduleDailyMock,
scanOnDemand: scanOnDemandMock,
scheduleDaily: scheduleDailyMock,
}));
vi.mock("@/actions/schedules", () => ({
updateSchedule: updateScheduleMock,
}));
vi.mock("@/components/ui", () => ({
@@ -27,64 +38,304 @@ vi.mock("@/components/ui", () => ({
}),
}));
const seedConnectedProvider = () => {
useProviderWizardStore.setState({
providerId: "provider-1",
providerType: "gcp",
providerUid: "project-123",
mode: "add",
});
};
const lastFooterConfig = (onFooterChange: ReturnType<typeof vi.fn>) =>
onFooterChange.mock.calls.at(-1)?.[0];
describe("LaunchStep", () => {
beforeEach(() => {
vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({
resolvedOptions: () => ({ timeZone: "Europe/Madrid" }),
} as Intl.DateTimeFormat);
sessionStorage.clear();
localStorage.clear();
updateScheduleMock.mockReset();
scheduleDailyMock.mockReset();
scanOnDemandMock.mockReset();
toastMock.mockReset();
useProviderWizardStore.getState().reset();
});
it("launches a daily scan and shows toast", async () => {
// Given
const onClose = vi.fn();
const onFooterChange = vi.fn();
useProviderWizardStore.setState({
providerId: "provider-1",
providerType: "gcp",
providerUid: "project-123",
mode: "add",
afterEach(() => {
vi.unstubAllEnvs();
});
describe("Prowler OSS (non-Cloud)", () => {
beforeEach(() => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
scheduleDailyMock.mockResolvedValue({ data: { id: "schedule-1" } });
});
scheduleDailyMock.mockResolvedValue({ data: { id: "scan-1" } });
it("renders the schedule UI with advanced cadences locked and no timezone field", async () => {
// Given
const onFooterChange = vi.fn();
seedConnectedProvider();
render(
<LaunchStep
onBack={vi.fn()}
onClose={onClose}
onFooterChange={onFooterChange}
/>,
);
render(
<LaunchStep
onBack={vi.fn()}
onClose={vi.fn()}
onFooterChange={onFooterChange}
/>,
);
await waitFor(() => {
expect(onFooterChange).toHaveBeenCalled();
// Then
expect(screen.getByText("Account Connected!")).toBeInTheDocument();
expect(
screen.queryByRole("combobox", { name: /timezone/i }),
).not.toBeInTheDocument();
// Daily is shown but the "Repeats" selector is locked for OSS.
expect(screen.getByRole("combobox", { name: /repeats/i })).toBeDisabled();
await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe("Save");
});
// When
const initialFooterConfig = onFooterChange.mock.calls.at(-1)?.[0];
await act(async () => {
initialFooterConfig.onAction?.();
it("saves via the legacy daily endpoint and never the new schedule API", async () => {
// Given
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 act(async () => {
lastFooterConfig(onFooterChange)?.onAction?.();
});
// Then
await waitFor(() => expect(scheduleDailyMock).toHaveBeenCalledTimes(1));
const sentFormData = scheduleDailyMock.mock.calls[0]?.[0] as FormData;
expect(sentFormData.get("providerId")).toBe("provider-1");
expect(updateScheduleMock).not.toHaveBeenCalled();
expect(scanOnDemandMock).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalledTimes(1);
});
// Then
await waitFor(() => {
expect(scheduleDailyMock).toHaveBeenCalledTimes(1);
it("also launches an on-demand scan when the checkbox is checked", async () => {
// Given
const user = userEvent.setup();
const onFooterChange = vi.fn();
seedConnectedProvider();
scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } });
render(
<LaunchStep
onBack={vi.fn()}
onClose={vi.fn()}
onFooterChange={onFooterChange}
/>,
);
await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
// When
await user.click(
screen.getByRole("checkbox", { name: /launch an initial scan now/i }),
);
await act(async () => {
lastFooterConfig(onFooterChange)?.onAction?.();
});
// Then
await waitFor(() => expect(scheduleDailyMock).toHaveBeenCalledTimes(1));
expect(scanOnDemandMock).toHaveBeenCalledTimes(1);
expect(updateScheduleMock).not.toHaveBeenCalled();
});
});
describe("Prowler Cloud subscribed", () => {
beforeEach(() => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
updateScheduleMock.mockResolvedValue({ data: { id: "provider-1" } });
});
const sentFormData = scheduleDailyMock.mock.calls[0]?.[0] as FormData;
expect(sentFormData.get("providerId")).toBe("provider-1");
expect(onClose).toHaveBeenCalledTimes(1);
expect(scanOnDemandMock).not.toHaveBeenCalled();
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({
title: "Scan Launched",
}),
);
const toastPayload = toastMock.mock.calls[0]?.[0];
expect(toastPayload.action.props.children.props.href).toBe(
`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`,
);
it("enables advanced cadences and saves through the new schedule API", async () => {
// Given
const onClose = vi.fn();
const onFooterChange = vi.fn();
seedConnectedProvider();
render(
<LaunchStep
onBack={vi.fn()}
onClose={onClose}
onFooterChange={onFooterChange}
/>,
);
// Then advanced cadence selector is enabled
expect(
screen.getByRole("combobox", { name: /repeats/i }),
).not.toBeDisabled();
await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
// When
await act(async () => {
lastFooterConfig(onFooterChange)?.onAction?.();
});
// Then
await waitFor(() => expect(updateScheduleMock).toHaveBeenCalledTimes(1));
expect(updateScheduleMock).toHaveBeenCalledWith(
"provider-1",
expect.objectContaining({
scan_enabled: true,
scan_frequency: SCHEDULE_FREQUENCY.DAILY,
scan_hour: expect.any(Number),
scan_timezone: "Europe/Madrid",
scan_day_of_week: null,
scan_day_of_month: null,
}),
);
expect(scheduleDailyMock).not.toHaveBeenCalled();
expect(scanOnDemandMock).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalledTimes(1);
const toastPayload = toastMock.mock.calls[0]?.[0];
expect(toastPayload.action.props.children.props.href).toBe(
`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`,
);
});
it("also launches an on-demand scan when the checkbox is checked", async () => {
// Given
const user = userEvent.setup();
const onFooterChange = vi.fn();
seedConnectedProvider();
scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } });
render(
<LaunchStep
onBack={vi.fn()}
onClose={vi.fn()}
onFooterChange={onFooterChange}
/>,
);
await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
// When
await user.click(
screen.getByRole("checkbox", { name: /launch an initial scan now/i }),
);
await act(async () => {
lastFooterConfig(onFooterChange)?.onAction?.();
});
// Then
await waitFor(() => expect(updateScheduleMock).toHaveBeenCalledTimes(1));
expect(scanOnDemandMock).toHaveBeenCalledTimes(1);
expect(scheduleDailyMock).not.toHaveBeenCalled();
});
it("does not launch an initial scan when the schedule save fails", async () => {
// Given
const user = userEvent.setup();
const onFooterChange = vi.fn();
seedConnectedProvider();
updateScheduleMock.mockResolvedValue({ error: "Schedule failed" });
render(
<LaunchStep
onBack={vi.fn()}
onClose={vi.fn()}
onFooterChange={onFooterChange}
/>,
);
await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
// When
await user.click(
screen.getByRole("checkbox", { name: /launch an initial scan now/i }),
);
await act(async () => {
lastFooterConfig(onFooterChange)?.onAction?.();
});
// Then
await waitFor(() => expect(updateScheduleMock).toHaveBeenCalledTimes(1));
expect(scanOnDemandMock).not.toHaveBeenCalled();
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({ title: "Unable to save scan schedule" }),
);
});
});
describe("Prowler Cloud trial/onboarding (manual scan only)", () => {
beforeEach(() => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } });
});
it("hides scheduling and only launches a manual scan", async () => {
// Given
const onClose = vi.fn();
const onFooterChange = vi.fn();
seedConnectedProvider();
render(
<LaunchStep
onBack={vi.fn()}
onClose={onClose}
onFooterChange={onFooterChange}
capability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
/>,
);
// Then no schedule cadence selector is rendered
expect(
screen.queryByRole("combobox", { name: /repeats/i }),
).not.toBeInTheDocument();
await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
expect(lastFooterConfig(onFooterChange)?.actionDisabled).toBe(false);
// The action launches a scan here, so it must not be labeled "Save".
expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe("Launch scan");
// When
await act(async () => {
lastFooterConfig(onFooterChange)?.onAction?.();
});
// Then
await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalledTimes(1));
expect(updateScheduleMock).not.toHaveBeenCalled();
expect(scheduleDailyMock).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalledTimes(1);
});
it("disables the action and shows the limit copy when over limit", async () => {
// Given
const onFooterChange = vi.fn();
seedConnectedProvider();
render(
<LaunchStep
onBack={vi.fn()}
onClose={vi.fn()}
onFooterChange={onFooterChange}
capability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
isScanLimitReached
/>,
);
// Then
expect(screen.getByText(/reached your scan limit/i)).toBeInTheDocument();
await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
expect(lastFooterConfig(onFooterChange)?.actionDisabled).toBe(true);
});
});
});
@@ -1,21 +1,34 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { useForm } 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 { Spinner } from "@/components/shadcn/spinner/spinner";
import { TreeStatusIcon } from "@/components/shadcn/tree-view/tree-status-icon";
import { CloudFeatureBadge } 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 +36,63 @@ import {
WizardFooterConfig,
} from "./footer-controls";
const SCAN_SCHEDULE = {
DAILY: "daily",
SINGLE: "single",
} as const;
type ScanScheduleOption = (typeof SCAN_SCHEDULE)[keyof typeof SCAN_SCHEDULE];
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 [isLaunching, setIsLaunching] = useState(false);
const [scheduleOption, setScheduleOption] = useState<ScanScheduleOption>(
SCAN_SCHEDULE.DAILY,
);
const launchActionRef = useRef<() => void>(() => {});
const form = useForm<ScheduleFormValues>({
resolver: zodResolver(scheduleFormSchema),
defaultValues: getScheduleFormDefaults(),
});
const handleLaunchScan = async () => {
if (!providerId) {
return;
}
const capability = capabilityProp ?? getScanScheduleCapability(isCloud());
const isManualOnly = capability === SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY;
const isAdvanced = capability === SCAN_SCHEDULE_CAPABILITY.ADVANCED;
const isActionBlocked =
isLaunching || !providerId || (isManualOnly && isScanLimitReached);
setIsLaunching(true);
const launchOnDemandScan = async (): Promise<{ error?: unknown } | null> => {
if (!providerId) return null;
const formData = new FormData();
formData.set("providerId", providerId);
const result =
scheduleOption === SCAN_SCHEDULE.DAILY
? await scheduleDaily(formData)
: await scanOnDemand(formData);
return scanOnDemand(formData);
};
if (result?.error) {
const handleManualScan = async () => {
setIsLaunching(true);
const scanResult = await launchOnDemandScan();
if (scanResult?.error) {
setIsLaunching(false);
toast({
variant: "destructive",
title: "Unable to launch scan",
description: String(result.error),
description: String(scanResult.error),
});
return;
}
@@ -75,11 +100,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,32 +110,100 @@ 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 goToScans = (
<ToastAction altText="Go to scans" asChild>
<Link href={`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`}>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;
}
const launched = result.status === SAVE_SCHEDULE_STATUS.SAVED_AND_LAUNCHED;
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 (isManualOnly) {
void handleManualScan();
return;
}
void handleSaveSchedule();
};
useEffect(() => {
const actionLabel = isManualOnly
? isLaunching
? "Launching scan..."
: "Launch scan"
: isLaunching
? "Saving..."
: "Save";
onFooterChange({
showBack: true,
backLabel: "Back",
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, isManualOnly, 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">
{isManualOnly ? "Launching scan..." : "Saving scan schedule..."}
</p>
</div>
</div>
);
@@ -121,13 +211,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 +234,35 @@ 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)
}
{isManualOnly ? (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<h3 className="text-text-neutral-primary text-sm font-medium">
Scan Schedule
</h3>
<CloudFeatureBadge label="Available after onboarding" size="sm" />
</div>
<p className="text-text-neutral-secondary text-sm">
Scheduled scans are not available yet. For now you can launch a
manual scan to get immediate findings.
</p>
{isScanLimitReached && (
<p className="text-text-error-primary text-sm">
You have reached your scan limit, so additional scans are not
available right now.
</p>
)}
</div>
) : (
<ScanScheduleFields
form={form}
disabled={isLaunching || !providerId}
>
<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>
showLaunchInitialScan
showNextScheduledCopy
canUseAdvancedSchedule={isAdvanced}
showCloudUpgradeBadge={!isAdvanced}
/>
)}
</div>
);
}
+154 -7
View File
@@ -3,13 +3,35 @@ import userEvent from "@testing-library/user-event";
import type { ComponentProps } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { refreshMock, scanOnDemandMock, searchParamsValue, toastMock } =
vi.hoisted(() => ({
refreshMock: vi.fn(),
scanOnDemandMock: vi.fn(),
searchParamsValue: { current: "" },
toastMock: vi.fn(),
}));
const {
getScheduleMock,
refreshMock,
scanOnDemandMock,
scheduleDailyMock,
searchParamsValue,
toastMock,
updateScheduleMock,
} = vi.hoisted(() => ({
getScheduleMock: vi.fn(),
refreshMock: vi.fn(),
scanOnDemandMock: vi.fn(),
scheduleDailyMock: vi.fn(),
searchParamsValue: { current: "" },
toastMock: vi.fn(),
updateScheduleMock: vi.fn(),
}));
class ResizeObserverMock {
observe() {}
unobserve() {}
disconnect() {}
}
Object.defineProperty(globalThis, "ResizeObserver", {
writable: true,
configurable: true,
value: ResizeObserverMock,
});
vi.mock("next/navigation", () => ({
useRouter: () => ({
@@ -20,6 +42,12 @@ vi.mock("next/navigation", () => ({
vi.mock("@/actions/scans", () => ({
scanOnDemand: scanOnDemandMock,
scheduleDaily: scheduleDailyMock,
}));
vi.mock("@/actions/schedules", () => ({
getSchedule: getScheduleMock,
updateSchedule: updateScheduleMock,
}));
vi.mock("@/components/ui/toast", () => ({
@@ -95,6 +123,8 @@ vi.mock("@/app/(prowler)/_overview/_components/accounts-selector", () => ({
),
}));
import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules";
import { LaunchScanModal } from "./launch-scan-modal";
const provider = {
@@ -303,4 +333,121 @@ describe("LaunchScanModal", () => {
expect(refreshMock).not.toHaveBeenCalled();
expect(onOpenChange).not.toHaveBeenCalledWith(false);
});
describe("schedule mode", () => {
const weeklyScheduleResponse = {
data: {
type: "schedules",
id: provider.id,
attributes: {
scan_enabled: true,
scan_frequency: "WEEKLY",
scan_hour: 9,
scan_timezone: "Europe/Madrid",
scan_interval_hours: null,
scan_day_of_week: 1,
scan_day_of_month: null,
},
},
};
beforeEach(() => {
getScheduleMock.mockResolvedValue(weeklyScheduleResponse);
updateScheduleMock.mockResolvedValue({ data: { id: provider.id } });
});
const renderAdvanced = () =>
render(
<LaunchScanModal
open
onOpenChange={vi.fn()}
providers={[provider]}
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
/>,
);
it("prefills the provider schedule and saves it through the new API", async () => {
const user = userEvent.setup();
renderAdvanced();
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
await user.click(screen.getByRole("radio", { name: "On a schedule" }));
await waitFor(() =>
expect(getScheduleMock).toHaveBeenCalledWith(provider.id),
);
await user.click(
await screen.findByRole("button", { name: /save schedule/i }),
);
// The payload carries the fetched WEEKLY schedule, proving the prefill.
await waitFor(() =>
expect(updateScheduleMock).toHaveBeenCalledWith(
provider.id,
expect.objectContaining({
scan_enabled: true,
scan_frequency: "WEEKLY",
scan_hour: 9,
scan_day_of_week: 1,
}),
),
);
expect(scanOnDemandMock).not.toHaveBeenCalled();
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({ title: "Scan schedule saved" }),
);
});
it("launches the initial scan when the checkbox is checked", async () => {
const user = userEvent.setup();
renderAdvanced();
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
await user.click(screen.getByRole("radio", { name: "On a schedule" }));
await user.click(
await screen.findByLabelText(
"Launch an initial scan now for immediate findings",
),
);
await user.click(screen.getByRole("button", { name: /save schedule/i }));
await waitFor(() => expect(updateScheduleMock).toHaveBeenCalled());
await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalledTimes(1));
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({
title: "Scan schedule saved and initial scan launched",
}),
);
});
it("locks schedule mode outside ADVANCED (OSS default)", () => {
render(
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
);
expect(
screen.getByRole("radio", { name: "On a schedule" }),
).toBeDisabled();
expect(getScheduleMock).not.toHaveBeenCalled();
});
it("hides the mode selector and blocks over-limit accounts in MANUAL_ONLY", () => {
render(
<LaunchScanModal
open
onOpenChange={vi.fn()}
providers={[provider]}
capability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
isScanLimitReached
/>,
);
expect(screen.queryByRole("radio")).not.toBeInTheDocument();
expect(screen.getByText(/reached your scan limit/i)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /launch scan/i }),
).toBeDisabled();
});
});
});
+250 -20
View File
@@ -1,23 +1,48 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { CloudCog, Rocket } from "lucide-react";
import { CloudCog, Loader2, Rocket } from "lucide-react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useForm } from "react-hook-form";
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."),
@@ -26,33 +51,112 @@ const launchScanSchema = z.object({
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 }: LaunchScanFormProps) {
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 providerId = form.watch("providerId");
const isAdvanced = capability === SCAN_SCHEDULE_CAPABILITY.ADVANCED;
const isManualOnly = capability === SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY;
const isScheduleMode = 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 onSubmit = form.handleSubmit(async ({ providerId, scanAlias }) => {
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) => {
setMode(nextMode as LaunchMode);
if (nextMode === LAUNCH_MODE.SCHEDULE) void loadSchedule(providerId);
};
const launchNow = form.handleSubmit(async ({ providerId, scanAlias }) => {
const formData = new FormData();
formData.set("providerId", providerId);
const trimmedAlias = scanAlias?.trim();
@@ -87,10 +191,62 @@ function LaunchScanForm({ providers, onClose }: LaunchScanFormProps) {
router.refresh();
});
const saveSchedule = async () => {
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 (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;
const isSubmitting =
form.formState.isSubmitting || scheduleForm.formState.isSubmitting;
const isScheduleLoading = scheduleLoad === SCHEDULE_LOAD_STATE.LOADING;
const isLimitBlocked = isManualOnly && isScanLimitReached;
return (
<form onSubmit={onSubmit} className="flex flex-col gap-8">
@@ -108,9 +264,7 @@ function LaunchScanForm({ providers, onClose }: LaunchScanFormProps) {
providers={providers}
disabledValues={disconnectedProviderIds}
onBatchChange={(_, values) =>
form.setValue("providerId", values.at(-1) ?? "", {
shouldValidate: true,
})
handleProviderChange(values.at(-1) ?? "")
}
selectedValues={providerId ? [providerId] : []}
closeOnSelect
@@ -118,23 +272,93 @@ function LaunchScanForm({ providers, onClose }: LaunchScanFormProps) {
{providerError && <FieldError>{providerError}</FieldError>}
</Field>
<Field>
<FieldLabel htmlFor="launch-scan-alias">Alias (optional)</FieldLabel>
<Input
id="launch-scan-alias"
aria-label="Alias"
{...form.register("scanAlias")}
{!isManualOnly && (
<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>
)}
{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 && (
<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
/>
{aliasError && <FieldError>{aliasError}</FieldError>}
</Field>
)}
{rootError && <FieldError>{rootError}</FieldError>}
<FormButtons
onCancel={onClose}
submitText={isSubmitting ? "Launching..." : "Launch Scan"}
loadingText="Launching..."
isDisabled={isSubmitting || !providers.length}
submitText={
isSubmitting
? isScheduleMode
? "Saving..."
: "Launching..."
: isScheduleMode
? "Save Schedule"
: "Launch Scan"
}
loadingText={isScheduleMode ? "Saving..." : "Launching..."}
isDisabled={
isSubmitting ||
!providers.length ||
isScheduleLoading ||
isLimitBlocked
}
rightIcon={<Rocket className="size-4" />}
/>
</form>
@@ -145,7 +369,11 @@ export function LaunchScanModal({
open,
onOpenChange,
providers,
capability,
isScanLimitReached = false,
}: LaunchScanModalProps) {
const resolvedCapability = capability ?? getScanScheduleCapability(isCloud());
return (
<Modal
open={open}
@@ -157,6 +385,8 @@ export function LaunchScanModal({
<LaunchScanForm
providers={providers}
onClose={() => onOpenChange(false)}
capability={resolvedCapability}
isScanLimitReached={isScanLimitReached}
/>
</Modal>
);
+8
View File
@@ -18,6 +18,7 @@ import {
import { useScansStore } from "@/store";
import { SCAN_JOBS_TAB, SCAN_TAB_LABELS, type ScanJobsTab } from "@/types";
import type { ProviderProps } from "@/types/providers";
import type { ScanScheduleCapability } from "@/types/schedules";
import { CliImportBanner } from "./cli-import-banner";
import { LaunchScanModal } from "./launch-scan-modal";
@@ -29,6 +30,9 @@ interface ScansPageShellProps {
hasManageScansPermission: boolean;
activeScanCount?: number;
children: ReactNode;
/** Cloud overlay seam for the launch-scan modal. */
scanScheduleCapability?: ScanScheduleCapability;
isScanLimitReached?: boolean;
}
export function ScansPageShell({
@@ -36,6 +40,8 @@ export function ScansPageShell({
hasManageScansPermission,
activeScanCount = 0,
children,
scanScheduleCapability,
isScanLimitReached,
}: ScansPageShellProps) {
const router = useRouter();
const pathname = usePathname();
@@ -138,6 +144,8 @@ export function ScansPageShell({
open={launchOpen}
onOpenChange={handleLaunchOpenChange}
providers={providers}
capability={scanScheduleCapability}
isScanLimitReached={isScanLimitReached}
/>
</div>
);
+104 -2
View File
@@ -8,6 +8,7 @@ import {
} from "@/types";
import {
buildPendingScheduleRows,
formatScanDuration,
getScanAlias,
getScanFindingsSummary,
@@ -109,9 +110,9 @@ describe("scans.utils", () => {
it("formats scan labels and durations for table display", () => {
expect(getScanAlias(makeScan(""))).toBe("-");
expect(getScanAlias(makeScan("Daily scheduled scan", "scheduled"))).toBe(
"scheduled scan",
"Scheduled Scan",
);
expect(getScanAlias(makeScan("", "scheduled"))).toBe("scheduled scan");
expect(getScanAlias(makeScan("", "scheduled"))).toBe("Scheduled Scan");
expect(getScanAlias(makeScan("Production scan"))).toBe("Production scan");
expect(formatScanDuration(73)).toBe("1 min 13 sec");
expect(formatScanDuration(null)).toBe("-");
@@ -161,3 +162,104 @@ describe("scans.utils", () => {
expect(getScanFindingsSummary(makeScan("x").attributes)).toBeNull();
});
});
describe("buildPendingScheduleRows", () => {
const now = new Date("2026-06-10T10:30:00Z");
const makeProvider = (id: string) =>
({
id,
type: "providers",
attributes: {
provider: "aws",
uid: `uid-${id}`,
alias: `alias-${id}`,
},
relationships: {},
}) as never;
const weeklySchedule = {
scan_enabled: true,
scan_frequency: "WEEKLY",
scan_hour: 9,
scan_timezone: "Europe/Madrid",
scan_interval_hours: null,
scan_day_of_week: 1,
scan_day_of_month: null,
} as const;
it("synthesizes a pending row for a configured schedule without scan rows", () => {
const rows = buildPendingScheduleRows({
providers: [makeProvider("p1")],
schedulesByProviderId: { p1: weeklySchedule },
coveredProviderIds: new Set(),
now,
});
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe("pending-schedule-p1");
expect(rows[0].attributes.state).toBe("scheduled");
expect(rows[0].attributes.trigger).toBe("scheduled");
expect(rows[0].pendingSchedule?.summary).toBe(
"Weekly on Monday @ 9:00am (Europe/Madrid)",
);
expect(rows[0].pendingSchedule?.cadence).toBe("Weekly on Monday");
expect(rows[0].providerInfo?.uid).toBe("uid-p1");
});
it("prefers the server-computed next_scan_at and carries last_scan_at", () => {
const rows = buildPendingScheduleRows({
providers: [makeProvider("p1")],
schedulesByProviderId: {
p1: {
...weeklySchedule,
next_scan_at: "2026-06-15T00:00:00Z",
last_scan_at: "2026-06-01T10:00:00Z",
},
},
coveredProviderIds: new Set(),
now,
});
expect(rows[0].attributes.scheduled_at).toBe("2026-06-15T00:00:00Z");
expect(rows[0].pendingSchedule?.nextScanAt).toBe("2026-06-15T00:00:00Z");
expect(rows[0].pendingSchedule?.lastScanAt).toBe("2026-06-01T10:00:00Z");
});
it("falls back to a client estimate when next_scan_at is absent", () => {
const rows = buildPendingScheduleRows({
providers: [makeProvider("p1")],
schedulesByProviderId: { p1: weeklySchedule },
coveredProviderIds: new Set(),
now,
});
expect(rows[0].attributes.scheduled_at).not.toBeNull();
expect(rows[0].pendingSchedule?.lastScanAt).toBeNull();
});
it("skips providers already covered by a real scheduled scan row", () => {
const rows = buildPendingScheduleRows({
providers: [makeProvider("p1")],
schedulesByProviderId: { p1: weeklySchedule },
coveredProviderIds: new Set(["p1"]),
now,
});
expect(rows).toHaveLength(0);
});
it("skips unconfigured and disabled schedules", () => {
const rows = buildPendingScheduleRows({
providers: [makeProvider("p1"), makeProvider("p2"), makeProvider("p3")],
schedulesByProviderId: {
p1: { ...weeklySchedule, scan_hour: null },
p2: { ...weeklySchedule, scan_enabled: false },
},
coveredProviderIds: new Set(),
now,
});
expect(rows).toHaveLength(0);
});
});
+79 -1
View File
@@ -1,5 +1,12 @@
import {
describeScheduleCadence,
getNextScheduledRunInTimezone,
getScheduleCadenceParts,
isScheduleConfigured,
} from "@/lib/schedules";
import {
DEFAULT_SCAN_JOBS_TAB,
type ProviderProps,
SCAN_JOBS_TAB,
SCAN_STATE,
SCAN_TRIGGER,
@@ -9,6 +16,7 @@ import {
type ScanProps,
type ScanState,
type ScanTrigger,
type ScheduleAttributes,
type SearchParamsProps,
} from "@/types";
@@ -136,7 +144,7 @@ export function getScanJobsTabFilters(
export function getScanAlias(scan: ScanProps): string {
if (scan.attributes.trigger === SCAN_TRIGGER.SCHEDULED)
return "scheduled scan";
return "Scheduled Scan";
return scan.attributes.name?.trim() || "-";
}
@@ -179,6 +187,76 @@ export function getScanStatusFilterOptions(
];
}
export interface BuildPendingScheduleRowsParams {
providers: ProviderProps[];
schedulesByProviderId: Record<string, ScheduleAttributes>;
/** Providers that already have a real `state=scheduled` Scan row. */
coveredProviderIds: Set<string>;
now: Date;
}
/**
* Synthesizes Scheduled-tab rows for configured schedules without a Scan row
* yet (the backend only creates one after each run).
*/
export function buildPendingScheduleRows({
providers,
schedulesByProviderId,
coveredProviderIds,
now,
}: BuildPendingScheduleRowsParams): ScanProps[] {
return providers.flatMap((provider) => {
if (coveredProviderIds.has(provider.id)) return [];
const schedule = schedulesByProviderId[provider.id];
if (!schedule || !isScheduleConfigured(schedule) || !schedule.scan_enabled)
return [];
// Prefer the server-computed next fire time; fall back to a client estimate.
const nextScanAt =
schedule.next_scan_at ??
getNextScheduledRunInTimezone(schedule, now)?.toISOString() ??
null;
return [
{
type: "scans" as const,
id: `pending-schedule-${provider.id}`,
attributes: {
name: "",
trigger: SCAN_TRIGGER.SCHEDULED,
state: SCAN_STATE.SCHEDULED,
unique_resource_count: 0,
progress: 0,
scanner_args: null,
duration: null,
started_at: null,
inserted_at: now.toISOString(),
completed_at: null,
scheduled_at: nextScanAt,
next_scan_at: null,
},
relationships: {
provider: { data: { type: "providers", id: provider.id } },
// No Celery task behind synthetic rows.
task: { data: { type: "tasks", id: "" } },
},
providerInfo: {
provider: provider.attributes.provider,
uid: provider.attributes.uid,
alias: provider.attributes.alias,
},
pendingSchedule: {
summary: describeScheduleCadence(schedule),
cadence: getScheduleCadenceParts(schedule).cadence,
nextScanAt,
lastScanAt: schedule.last_scan_at ?? null,
},
},
];
});
}
function getNumericValue(
source: Record<string, unknown>,
keys: string[],
@@ -0,0 +1,165 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { refreshMock, removeScheduleMock, toastMock, updateScheduleMock } =
vi.hoisted(() => ({
refreshMock: vi.fn(),
removeScheduleMock: vi.fn(),
toastMock: vi.fn(),
updateScheduleMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: refreshMock }),
}));
vi.mock("@/actions/schedules", () => ({
removeSchedule: removeScheduleMock,
updateSchedule: updateScheduleMock,
}));
vi.mock("@/components/ui/toast", () => ({
toast: toastMock,
}));
vi.mock("@/components/ui/entities", () => ({
EntityInfo: () => null,
}));
vi.mock("@/components/shadcn/modal", () => ({
Modal: ({
children,
open,
title,
}: {
children: React.ReactNode;
open: boolean;
title: string;
}) =>
open ? (
<div role="dialog" aria-label={title}>
{children}
</div>
) : null,
}));
import type { ScheduleProps } from "@/types/schedules";
import {
EDIT_SCAN_SCHEDULE_STATE,
EditScanScheduleModal,
} from "./edit-scan-schedule-modal";
const provider = {
providerId: "p1",
providerType: "aws" as const,
providerUid: "123456789012",
providerAlias: "Production",
};
const schedule: ScheduleProps = {
type: "schedules",
id: "p1",
attributes: {
scan_enabled: true,
scan_frequency: "DAILY",
scan_hour: 4,
scan_timezone: "UTC",
scan_interval_hours: null,
scan_day_of_week: null,
scan_day_of_month: null,
},
relationships: {
provider: { data: { type: "providers", id: "p1" } },
},
};
const renderLoaded = () =>
render(
<EditScanScheduleModal
open
onOpenChange={vi.fn()}
provider={provider}
state={{ kind: EDIT_SCAN_SCHEDULE_STATE.LOADED, schedule }}
/>,
);
describe("EditScanScheduleModal remove flow", () => {
beforeEach(() => {
vi.clearAllMocks();
removeScheduleMock.mockResolvedValue({ success: true });
});
it("asks for confirmation before removing the schedule", async () => {
const user = userEvent.setup();
renderLoaded();
await user.click(
screen.getByRole("button", { name: /remove scan schedule/i }),
);
expect(removeScheduleMock).not.toHaveBeenCalled();
expect(
screen.getByRole("dialog", { name: "Are you absolutely sure?" }),
).toBeInTheDocument();
});
it("removes the schedule only after confirming", async () => {
const user = userEvent.setup();
renderLoaded();
await user.click(
screen.getByRole("button", { name: /remove scan schedule/i }),
);
const confirmDialog = screen.getByRole("dialog", {
name: "Are you absolutely sure?",
});
await user.click(
within(confirmDialog).getByRole("button", { name: "Remove" }),
);
await waitFor(() => expect(removeScheduleMock).toHaveBeenCalledWith("p1"));
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({ title: "Scan schedule removed" }),
);
});
it("hides the remove button when the provider has no configured schedule", () => {
render(
<EditScanScheduleModal
open
onOpenChange={vi.fn()}
provider={provider}
state={{
kind: EDIT_SCAN_SCHEDULE_STATE.LOADED,
schedule: {
...schedule,
attributes: { ...schedule.attributes, scan_hour: null },
},
}}
/>,
);
expect(
screen.queryByRole("button", { name: /remove scan schedule/i }),
).not.toBeInTheDocument();
});
it("keeps the schedule when the confirmation is cancelled", async () => {
const user = userEvent.setup();
renderLoaded();
await user.click(
screen.getByRole("button", { name: /remove scan schedule/i }),
);
const confirmDialog = screen.getByRole("dialog", {
name: "Are you absolutely sure?",
});
await user.click(
within(confirmDialog).getByRole("button", { name: "Cancel" }),
);
expect(removeScheduleMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,227 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { CircleX, Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { removeSchedule, updateSchedule } from "@/actions/schedules";
import { Button, FieldError } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { EntityInfo } from "@/components/ui/entities";
import { FormButtons } from "@/components/ui/form";
import { toast } from "@/components/ui/toast";
import {
buildScheduleUpdatePayload,
getScheduleFormValues,
isScheduleConfigured,
scheduleFormSchema,
} from "@/lib/schedules";
import type { ProviderType, ScheduleProps } from "@/types";
import type { ScheduleFormValues } from "@/types/schedules";
import { ScanScheduleFields } from "./scan-schedule-fields";
export interface ScanScheduleProvider {
providerId: string;
providerType: ProviderType;
providerUid: string;
providerAlias: string | null;
}
export const EDIT_SCAN_SCHEDULE_STATE = {
LOADING: "loading",
LOADED: "loaded",
ERROR: "error",
} as const;
export type EditScanScheduleState =
| { kind: typeof EDIT_SCAN_SCHEDULE_STATE.LOADING }
| {
kind: typeof EDIT_SCAN_SCHEDULE_STATE.LOADED;
schedule: ScheduleProps | null;
}
| { kind: typeof EDIT_SCAN_SCHEDULE_STATE.ERROR; message: string };
interface EditScanScheduleFormProps {
provider: ScanScheduleProvider;
schedule: ScheduleProps | null;
onClose: () => void;
}
interface EditScanScheduleModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
provider?: ScanScheduleProvider;
state: EditScanScheduleState;
}
function EditScanScheduleForm({
provider,
schedule,
onClose,
}: EditScanScheduleFormProps) {
const router = useRouter();
const [isConfirmRemoveOpen, setIsConfirmRemoveOpen] = useState(false);
const [isRemoving, setIsRemoving] = useState(false);
const form = useForm<ScheduleFormValues>({
resolver: zodResolver(scheduleFormSchema),
defaultValues: getScheduleFormValues(schedule?.attributes),
});
const hasSchedule = schedule
? isScheduleConfigured(schedule.attributes)
: false;
const onSubmit = form.handleSubmit(async (values) => {
const result = await updateSchedule(
provider.providerId,
buildScheduleUpdatePayload(values),
);
if (result?.error) {
form.setError("root", { message: String(result.error) });
return;
}
toast({
title: "Scan schedule saved",
description: "The scan schedule was updated successfully.",
});
onClose();
router.refresh();
});
const handleRemove = async () => {
setIsRemoving(true);
const result = await removeSchedule(provider.providerId);
setIsRemoving(false);
setIsConfirmRemoveOpen(false);
if (result?.error) {
form.setError("root", { message: String(result.error) });
return;
}
toast({
title: "Scan schedule removed",
description: "The scan schedule was removed successfully.",
});
onClose();
router.refresh();
};
const isSubmitting = form.formState.isSubmitting;
const rootError = form.formState.errors.root?.message;
return (
<form onSubmit={onSubmit} className="flex flex-col gap-8">
<EntityInfo
cloudProvider={provider.providerType}
entityAlias={provider.providerAlias ?? provider.providerUid}
entityId={provider.providerUid}
/>
<ScanScheduleFields
form={form}
disabled={isSubmitting || isRemoving}
headerAction={
hasSchedule ? (
<Button
type="button"
variant="ghost"
onClick={() => setIsConfirmRemoveOpen(true)}
disabled={isSubmitting || isRemoving}
className="text-text-error-primary"
>
<CircleX className="size-4" />
Remove Scan Schedule
</Button>
) : undefined
}
/>
{rootError && <FieldError>{rootError}</FieldError>}
<FormButtons
onCancel={onClose}
submitText={isSubmitting ? "Saving..." : "Save"}
loadingText="Saving..."
isDisabled={isSubmitting || isRemoving}
/>
<Modal
open={isConfirmRemoveOpen}
onOpenChange={setIsConfirmRemoveOpen}
title="Are you absolutely sure?"
description="This action cannot be undone. The scan schedule for this provider will be removed and scans will no longer run automatically."
>
<div className="flex w-full justify-end gap-4">
<Button
type="button"
variant="ghost"
size="lg"
onClick={() => setIsConfirmRemoveOpen(false)}
disabled={isRemoving}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
size="lg"
onClick={() => void handleRemove()}
disabled={isRemoving}
>
<CircleX className="size-4" />
{isRemoving ? "Removing..." : "Remove"}
</Button>
</div>
</Modal>
</form>
);
}
export function EditScanScheduleModal({
open,
onOpenChange,
provider,
state,
}: EditScanScheduleModalProps) {
const close = () => onOpenChange(false);
return (
<Modal
open={open}
onOpenChange={onOpenChange}
title="Edit Scan Schedule"
size="2xl"
className="gap-8"
>
{state.kind === EDIT_SCAN_SCHEDULE_STATE.LOADING && (
<div className="flex min-h-[240px] items-center justify-center gap-3">
<Loader2 className="size-5 animate-spin" />
<span className="text-sm">Loading scan schedule...</span>
</div>
)}
{state.kind === EDIT_SCAN_SCHEDULE_STATE.ERROR && (
<div className="flex flex-col gap-6">
<FieldError>{state.message}</FieldError>
<Button type="button" variant="outline" onClick={close}>
Close
</Button>
</div>
)}
{state.kind === EDIT_SCAN_SCHEDULE_STATE.LOADED && provider && (
<EditScanScheduleForm
key={`${provider.providerId}-${state.schedule?.attributes.scan_hour ?? "none"}`}
provider={provider}
schedule={state.schedule}
onClose={close}
/>
)}
</Modal>
);
}
@@ -0,0 +1,115 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SCHEDULE_FREQUENCY, type ScheduleFormValues } from "@/types/schedules";
const { scanOnDemandMock, scheduleDailyMock, updateScheduleMock } = vi.hoisted(
() => ({
scanOnDemandMock: vi.fn(),
scheduleDailyMock: vi.fn(),
updateScheduleMock: vi.fn(),
}),
);
vi.mock("@/actions/scans", () => ({
scanOnDemand: scanOnDemandMock,
scheduleDaily: scheduleDailyMock,
}));
vi.mock("@/actions/schedules", () => ({
updateSchedule: updateScheduleMock,
}));
import {
SAVE_SCHEDULE_STATUS,
saveScheduleWithInitialScan,
} from "./save-schedule";
const values: ScheduleFormValues = {
frequency: SCHEDULE_FREQUENCY.WEEKLY,
hour: 9,
dayOfWeek: 1,
dayOfMonth: 1,
intervalHours: 48,
launchInitialScan: false,
};
describe("saveScheduleWithInitialScan", () => {
beforeEach(() => {
vi.clearAllMocks();
updateScheduleMock.mockResolvedValue({});
scheduleDailyMock.mockResolvedValue({});
scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } });
});
it("saves through the schedules API with the mapped payload", async () => {
const result = await saveScheduleWithInitialScan({
providerId: "p1",
values,
});
expect(result.status).toBe(SAVE_SCHEDULE_STATUS.SAVED);
expect(updateScheduleMock).toHaveBeenCalledWith(
"p1",
expect.objectContaining({
scan_enabled: true,
scan_frequency: SCHEDULE_FREQUENCY.WEEKLY,
scan_hour: 9,
scan_day_of_week: 1,
}),
);
expect(scheduleDailyMock).not.toHaveBeenCalled();
expect(scanOnDemandMock).not.toHaveBeenCalled();
});
it("uses the legacy daily endpoint when requested", async () => {
const result = await saveScheduleWithInitialScan({
providerId: "p1",
values,
useLegacyDaily: true,
});
expect(result.status).toBe(SAVE_SCHEDULE_STATUS.SAVED);
expect(scheduleDailyMock).toHaveBeenCalledTimes(1);
expect(updateScheduleMock).not.toHaveBeenCalled();
});
it("returns an error status when the schedule save fails", async () => {
updateScheduleMock.mockResolvedValue({ error: "boom" });
const result = await saveScheduleWithInitialScan({
providerId: "p1",
values,
});
expect(result).toEqual({
status: SAVE_SCHEDULE_STATUS.ERROR,
message: "boom",
});
expect(scanOnDemandMock).not.toHaveBeenCalled();
});
it("launches the initial scan when requested", async () => {
const result = await saveScheduleWithInitialScan({
providerId: "p1",
values: { ...values, launchInitialScan: true },
});
expect(result.status).toBe(SAVE_SCHEDULE_STATUS.SAVED_AND_LAUNCHED);
const formData = scanOnDemandMock.mock.calls[0][0] as FormData;
expect(formData.get("providerId")).toBe("p1");
});
it("reports partial success when the initial scan fails", async () => {
scanOnDemandMock.mockResolvedValue({ error: "limit reached" });
const result = await saveScheduleWithInitialScan({
providerId: "p1",
values: { ...values, launchInitialScan: true },
});
expect(result).toEqual({
status: SAVE_SCHEDULE_STATUS.SAVED_SCAN_FAILED,
message: "limit reached",
});
});
});
@@ -0,0 +1,70 @@
import { scanOnDemand, scheduleDaily } from "@/actions/scans";
import { updateSchedule } from "@/actions/schedules";
import { buildScheduleUpdatePayload } from "@/lib/schedules";
import type { ScheduleFormValues } from "@/types/schedules";
export const SAVE_SCHEDULE_STATUS = {
ERROR: "error",
SAVED: "saved",
SAVED_AND_LAUNCHED: "saved_and_launched",
SAVED_SCAN_FAILED: "saved_scan_failed",
} as const;
export type SaveScheduleStatus =
(typeof SAVE_SCHEDULE_STATUS)[keyof typeof SAVE_SCHEDULE_STATUS];
export interface SaveScheduleParams {
providerId: string;
values: ScheduleFormValues;
/** Save through the legacy `/schedules/daily` endpoint (OSS / non-Cloud). */
useLegacyDaily?: boolean;
}
export interface SaveScheduleResult {
status: SaveScheduleStatus;
message?: string;
}
/** Saves a provider's scan schedule and optionally launches the initial scan. */
export async function saveScheduleWithInitialScan({
providerId,
values,
useLegacyDaily = false,
}: SaveScheduleParams): Promise<SaveScheduleResult> {
let scheduleResult: { error?: unknown } | null;
if (useLegacyDaily) {
const formData = new FormData();
formData.set("providerId", providerId);
scheduleResult = await scheduleDaily(formData);
} else {
scheduleResult = await updateSchedule(
providerId,
buildScheduleUpdatePayload(values),
);
}
if (scheduleResult?.error) {
return {
status: SAVE_SCHEDULE_STATUS.ERROR,
message: String(scheduleResult.error),
};
}
if (!values.launchInitialScan) {
return { status: SAVE_SCHEDULE_STATUS.SAVED };
}
const formData = new FormData();
formData.set("providerId", providerId);
const scanResult = await scanOnDemand(formData);
if (scanResult?.error) {
return {
status: SAVE_SCHEDULE_STATUS.SAVED_SCAN_FAILED,
message: String(scanResult.error),
};
}
return { status: SAVE_SCHEDULE_STATUS.SAVED_AND_LAUNCHED };
}
@@ -0,0 +1,275 @@
"use client";
import { format } from "date-fns";
import { CalendarClock } from "lucide-react";
import type { ReactNode } from "react";
import { Controller, type UseFormReturn, useWatch } from "react-hook-form";
import {
Checkbox,
Field,
FieldLabel,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shadcn";
import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge";
import {
formatScheduleHour,
getBrowserTimezone,
getNextScheduledRun,
} from "@/lib/schedules";
import {
SCHEDULE_FREQUENCY,
SCHEDULE_WEEKDAY_LABELS,
type ScheduleFormValues,
} from "@/types/schedules";
// The INTERVAL label is resolved at render time from the form's intervalHours.
const FREQUENCY_OPTIONS = [
{ value: SCHEDULE_FREQUENCY.DAILY, label: "Daily" },
{ value: SCHEDULE_FREQUENCY.INTERVAL, label: null },
{ value: SCHEDULE_FREQUENCY.WEEKLY, label: "Weekly" },
{ value: SCHEDULE_FREQUENCY.MONTHLY, label: "Monthly" },
] as const;
const WEEKDAY_OPTIONS = SCHEDULE_WEEKDAY_LABELS.map((label, value) => ({
value,
label,
}));
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, hour) => ({
value: hour,
label: formatScheduleHour(hour),
}));
const MONTH_DAY_OPTIONS = Array.from({ length: 28 }, (_, index) => index + 1);
interface ScanScheduleFieldsProps {
form: UseFormReturn<ScheduleFormValues>;
disabled?: boolean;
showLaunchInitialScan?: boolean;
showNextScheduledCopy?: boolean;
/** Rendered at the right of the "Scan Schedule" header row. */
headerAction?: ReactNode;
/**
* When false, the frequency is locked to `Daily` and the advanced cadences
* (interval/weekly/monthly) are disabled. Used for non-Cloud (OSS) accounts.
*/
canUseAdvancedSchedule?: boolean;
/** Render the "Available in Prowler Cloud" upsell badge on locked controls. */
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;
disabled?: boolean;
}) {
return (
<Field>
<div className="flex items-center justify-between gap-2">
<FieldLabel>{label}</FieldLabel>
{labelAddon}
</div>
<Select
value={String(value)}
onValueChange={(nextValue) => onChange(Number(nextValue))}
disabled={disabled}
>
<SelectTrigger aria-label={label}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{values.map((option) => (
<SelectItem key={option.value} value={String(option.value)}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
);
}
export function ScanScheduleFields({
form,
disabled = false,
showLaunchInitialScan = false,
showNextScheduledCopy = false,
headerAction,
canUseAdvancedSchedule = true,
showCloudUpgradeBadge = false,
}: ScanScheduleFieldsProps) {
// useWatch, not form.watch: form.watch re-renders are dropped by React Compiler memoization.
const control = form.control;
const frequency = useWatch({ control, name: "frequency" });
const hour = useWatch({ control, name: "hour" });
const dayOfWeek = useWatch({ control, name: "dayOfWeek" });
const dayOfMonth = useWatch({ control, name: "dayOfMonth" });
const intervalHours = useWatch({ control, name: "intervalHours" });
const timezone = getBrowserTimezone();
const frequencyLabel = (option: (typeof FREQUENCY_OPTIONS)[number]) =>
option.label ?? `Every ${intervalHours} hours`;
// In OSS (non-Cloud) the advanced cadence and time are locked: `/schedules/daily`
// ignores them, so they are display-only with a Cloud upsell.
const advancedDisabled = disabled || !canUseAdvancedSchedule;
const cloudUpgradeBadge = showCloudUpgradeBadge ? (
<CloudFeatureBadgeLink size="sm" />
) : null;
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<CalendarClock className="text-text-neutral-primary size-5" />
<h3 className="text-text-neutral-primary text-sm font-medium">
Scan Schedule
</h3>
</div>
{headerAction}
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Controller
control={form.control}
name="hour"
render={({ field }) => (
<NumberSelect
label="Scan Time"
labelAddon={cloudUpgradeBadge}
value={field.value}
values={HOUR_OPTIONS}
onChange={field.onChange}
disabled={advancedDisabled}
/>
)}
/>
<Controller
control={form.control}
name="frequency"
render={({ field }) => (
<Field>
<div className="flex items-center justify-between gap-2">
<FieldLabel>Repeats</FieldLabel>
{cloudUpgradeBadge}
</div>
<Select
value={
canUseAdvancedSchedule
? field.value
: SCHEDULE_FREQUENCY.DAILY
}
onValueChange={field.onChange}
disabled={advancedDisabled}
>
<SelectTrigger aria-label="Repeats">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FREQUENCY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{frequencyLabel(option)}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
/>
</div>
{frequency === SCHEDULE_FREQUENCY.WEEKLY && (
<Controller
control={form.control}
name="dayOfWeek"
render={({ field }) => (
<NumberSelect
label="Day of week"
value={field.value}
values={WEEKDAY_OPTIONS}
onChange={field.onChange}
disabled={disabled}
/>
)}
/>
)}
{frequency === SCHEDULE_FREQUENCY.MONTHLY && (
<Controller
control={form.control}
name="dayOfMonth"
render={({ field }) => (
<NumberSelect
label="Day of month"
value={field.value}
values={MONTH_DAY_OPTIONS.map((day) => ({
value: day,
label: String(day),
}))}
onChange={field.onChange}
disabled={disabled}
/>
)}
/>
)}
{showNextScheduledCopy &&
(canUseAdvancedSchedule ? (
<p className="text-text-neutral-secondary text-sm">
The next scheduled scan will start on:{" "}
{format(
getNextScheduledRun(
{
frequency,
hour,
dayOfWeek,
dayOfMonth,
intervalHours,
launchInitialScan: false,
},
new Date(),
),
"MMM d, yyyy",
)}{" "}
@ {formatScheduleHour(hour)} {timezone}
</p>
) : (
<p className="text-text-neutral-secondary text-sm">
A daily scan will run automatically once the account is connected.
</p>
))}
{showLaunchInitialScan && (
<Controller
control={form.control}
name="launchInitialScan"
render={({ field }) => (
<label className="flex items-center gap-3 text-sm font-medium">
<Checkbox
checked={field.value}
onCheckedChange={(checked) => field.onChange(checked === true)}
disabled={disabled}
aria-label="Launch an initial scan now for immediate findings"
/>
<span>Launch an initial scan now for immediate findings</span>
</label>
)}
/>
)}
</div>
);
}
@@ -1,10 +1,31 @@
"use client";
import { getScanAlias } from "@/components/scans/scans.utils";
import {
Badge,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn";
import { EntityInfo } from "@/components/ui/entities";
import type { ScanProps } from "@/types";
export function ScanInfoCell({ scan }: { scan: ScanProps }) {
// Synthetic pending rows have no Scan behind them yet, so there is no id.
if (scan.pendingSchedule) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="tag">Pending</Badge>
</TooltipTrigger>
<TooltipContent>
This scan has not been created yet. Its details will appear after the
first scheduled run.
</TooltipContent>
</Tooltip>
);
}
return (
<div className="max-w-[240px] min-w-0">
<EntityInfo
@@ -1,18 +1,23 @@
"use client";
import { getScanScheduleLabel } from "@/components/scans/scans.utils";
import { DateWithTime } from "@/components/ui/entities";
import { StackedCell } from "@/components/shadcn";
import { formatLocalTimeWithZone } from "@/lib/date-utils";
import type { ScanProps } from "@/types";
// Trigger label on top, cadence (in the browser's timezone) underneath.
export function ScheduleCell({ scan }: { scan: ScanProps }) {
const schedule = scan.providerSchedule;
const localTime = formatLocalTimeWithZone(schedule?.nextScanAt ?? null);
return (
<div className="flex flex-col gap-1">
<span className="text-text-neutral-primary text-sm">
{getScanScheduleLabel(scan.attributes.trigger)}
</span>
{scan.attributes.scheduled_at && (
<DateWithTime dateTime={scan.attributes.scheduled_at} showTime />
)}
</div>
<StackedCell
primary={getScanScheduleLabel(scan.attributes.trigger)}
secondary={
schedule
? `${schedule.cadence ?? schedule.summary}${localTime ? ` @ ${localTime}` : ""}`
: undefined
}
/>
);
}
@@ -9,6 +9,18 @@ vi.mock("@/components/shadcn", () => ({
<span>{children}</span>
),
Progress: () => <div />,
StackedCell: ({
primary,
secondary,
}: {
primary: React.ReactNode;
secondary?: React.ReactNode;
}) => (
<div>
<span>{primary}</span>
{secondary ? <span>{secondary}</span> : null}
</div>
),
}));
vi.mock("@/components/ui/entities", () => ({
@@ -148,6 +160,8 @@ describe("getScanJobsColumns", () => {
"account",
"scanInfo",
"scanSchedule",
"nextScan",
"lastScan",
"actions",
]);
});
+56 -27
View File
@@ -2,9 +2,10 @@
import type { ColumnDef } from "@tanstack/react-table";
import { DateWithTime } from "@/components/ui/entities";
import { StackedCell } from "@/components/shadcn";
import { DataTableColumnHeader } from "@/components/ui/table";
import { StatusBadge } from "@/components/ui/table/status-badge";
import { formatLocalDate, formatLocalTimeWithZone } from "@/lib/date-utils";
import { SCAN_JOBS_TAB, type ScanJobsTab, type ScanProps } from "@/types";
import { formatScanDuration } from "../scans.utils";
@@ -48,15 +49,59 @@ const getScanScheduleColumn = (title: string): ColumnDef<ScanProps> => ({
cell: ({ row }) => <ScheduleCell scan={row.original} />,
});
const getScheduleSummary = (scan: ScanProps) =>
scan.pendingSchedule ?? scan.providerSchedule;
const renderDateCell = (value: string | null) => {
const date = formatLocalDate(value);
if (!date) return <span>-</span>;
return (
<StackedCell primary={date} secondary={formatLocalTimeWithZone(value)} />
);
};
const scheduledScanScheduleColumn: ColumnDef<ScanProps> = {
id: "scanSchedule",
accessorFn: (row) => row.attributes.scheduled_at,
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Schedule" />
),
cell: ({ row }) => (
<DateWithTime dateTime={row.original.attributes.scheduled_at} showTime />
// Cadence on top, local fire time underneath.
cell: ({ row }) => {
const schedule = getScheduleSummary(row.original);
if (!schedule) return <span>-</span>;
return (
<StackedCell
primary={schedule.cadence ?? schedule.summary}
secondary={formatLocalTimeWithZone(
row.original.attributes.scheduled_at,
)}
/>
);
},
enableSorting: false,
};
const nextScanColumn: ColumnDef<ScanProps> = {
id: "nextScan",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Next Scan" />
),
// Real rows carry their fire time in scheduled_at; pending rows are
// synthesized with the server-computed next_scan_at in the same field.
cell: ({ row }) => renderDateCell(row.original.attributes.scheduled_at),
enableSorting: false,
};
const lastScanColumn: ColumnDef<ScanProps> = {
id: "lastScan",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Last Scan" />
),
cell: ({ row }) =>
renderDateCell(getScheduleSummary(row.original)?.lastScanAt ?? null),
enableSorting: false,
};
@@ -104,14 +149,11 @@ const activeColumns = (): ColumnDef<ScanProps>[] => [
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Launched" />
),
cell: ({ row }) => (
<DateWithTime
dateTime={
row.original.attributes.started_at ||
row.original.attributes.inserted_at
}
/>
),
cell: ({ row }) =>
renderDateCell(
row.original.attributes.started_at ||
row.original.attributes.inserted_at,
),
enableSorting: false,
},
actionsColumn,
@@ -141,9 +183,7 @@ const completedColumns = (): ColumnDef<ScanProps>[] => [
param="updated_at"
/>
),
cell: ({ row }) => (
<DateWithTime dateTime={row.original.attributes.completed_at} />
),
cell: ({ row }) => renderDateCell(row.original.attributes.completed_at),
},
actionsColumn,
];
@@ -152,19 +192,8 @@ const scheduledColumns = (): ColumnDef<ScanProps>[] => [
accountColumn,
scanInfoColumn,
scheduledScanScheduleColumn,
/*
* TODO: Restore this column when the API exposes the last completed scan date for this schedule.
* {
* id: "lastScan",
* header: ({ column }) => (
* <DataTableColumnHeader column={column} title="Last Run" />
* ),
* cell: ({ row }) => (
* <DateWithTime dateTime={row.original.attributes.completed_at} />
* ),
* enableSorting: false,
* },
*/
nextScanColumn,
lastScanColumn,
actionsColumn,
];
@@ -1,19 +1,24 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ScanProps } from "@/types";
import { ScanJobsRowActions } from "./scan-jobs-row-actions";
const { downloadScanZipMock, getTaskMock, pushMock, toastMock } = vi.hoisted(
() => ({
downloadScanZipMock: vi.fn(),
getTaskMock: vi.fn(),
pushMock: vi.fn(),
toastMock: vi.fn(),
}),
);
const {
downloadScanZipMock,
getScheduleMock,
getTaskMock,
pushMock,
toastMock,
} = vi.hoisted(() => ({
downloadScanZipMock: vi.fn(),
getScheduleMock: vi.fn(),
getTaskMock: vi.fn(),
pushMock: vi.fn(),
toastMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({
@@ -29,6 +34,10 @@ vi.mock("@/actions/task", () => ({
getTask: getTaskMock,
}));
vi.mock("@/actions/schedules", () => ({
getSchedule: getScheduleMock,
}));
vi.mock("@/lib/helper", () => ({
downloadScanZip: downloadScanZipMock,
}));
@@ -53,6 +62,26 @@ vi.mock("@/components/scans/edit-alias-modal", () => ({
) : null,
}));
vi.mock("@/components/scans/schedule/edit-scan-schedule-modal", () => ({
EDIT_SCAN_SCHEDULE_STATE: {
LOADING: "loading",
LOADED: "loaded",
ERROR: "error",
},
EditScanScheduleModal: ({
open,
provider,
}: {
open: boolean;
provider?: { providerId: string };
}) =>
open ? (
<div role="dialog" aria-label="Edit Scan Schedule">
Editing schedule for {provider?.providerId}
</div>
) : null,
}));
const makeScan = (
overrides: Partial<ScanProps["attributes"]> = {},
): ScanProps => ({
@@ -80,6 +109,19 @@ const makeScan = (
});
describe("ScanJobsRowActions", () => {
beforeEach(() => {
getScheduleMock.mockResolvedValue({
data: {
type: "schedules",
id: "provider-1",
attributes: { scan_hour: null },
relationships: {
provider: { data: { type: "providers", id: "provider-1" } },
},
},
});
});
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
@@ -95,7 +137,9 @@ describe("ScanJobsRowActions", () => {
await user.click(
screen.getByRole("button", { name: /open actions menu/i }),
);
await user.click(screen.getByRole("menuitem", { name: /^edit$/i }));
await user.click(
screen.getByRole("menuitem", { name: /edit scan alias/i }),
);
// Then
expect(
@@ -103,8 +147,31 @@ describe("ScanJobsRowActions", () => {
).toHaveTextContent("Editing Production scan");
});
it("does not render the legacy Edit Scan Schedule option", async () => {
it("opens Edit Scan Schedule for Prowler Cloud subscribed scan rows", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
const user = userEvent.setup();
render(<ScanJobsRowActions scan={makeScan()} />);
// When
await user.click(
screen.getByRole("button", { name: /open actions menu/i }),
);
await user.click(
screen.getByRole("menuitem", { name: /edit scan schedule/i }),
);
// Then
expect(
screen.getByRole("dialog", { name: /edit scan schedule/i }),
).toHaveTextContent("Editing schedule for provider-1");
});
it("hides Edit Scan Schedule outside Prowler Cloud (OSS)", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
const user = userEvent.setup();
render(<ScanJobsRowActions scan={makeScan()} />);
@@ -120,24 +187,6 @@ describe("ScanJobsRowActions", () => {
).not.toBeInTheDocument();
});
it("does not render cancel scan while the scan cancellation API is missing", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
const user = userEvent.setup();
render(<ScanJobsRowActions scan={makeScan()} />);
// When
await user.click(
screen.getByRole("button", { name: /open actions menu/i }),
);
// Then
expect(
screen.queryByRole("menuitem", { name: /cancel scan/i }),
).not.toBeInTheDocument();
});
it("links completed scans to compliance from the actions menu", async () => {
// Given
const user = userEvent.setup();
@@ -1,6 +1,7 @@
"use client";
import {
CalendarClock,
Download,
Eye,
Pencil,
@@ -10,6 +11,7 @@ import {
import { useRouter } from "next/navigation";
import { useState } from "react";
import { getSchedule } from "@/actions/schedules";
import { getTask } from "@/actions/task";
import { getScanErrorDetails } from "@/actions/task/task.adapter";
import { EditAliasModal } from "@/components/scans/edit-alias-modal";
@@ -17,6 +19,12 @@ import {
ScanErrorDetailsModal,
type ScanErrorDetailsState,
} from "@/components/scans/scan-error-details-modal";
import {
EDIT_SCAN_SCHEDULE_STATE,
EditScanScheduleModal,
type EditScanScheduleState,
type ScanScheduleProvider,
} from "@/components/scans/schedule/edit-scan-schedule-modal";
import {
ActionDropdown,
ActionDropdownItem,
@@ -24,16 +32,36 @@ import {
import { useToast } from "@/components/ui";
import { toLocalDateString } from "@/lib/date-utils";
import { downloadScanZip } from "@/lib/helper";
import type { ScanProps } from "@/types";
import { getScanScheduleCapability } from "@/lib/schedules";
import { isCloud } from "@/lib/shared/env";
import type { ProviderType, ScanProps, ScheduleApiResponse } from "@/types";
import {
SCAN_SCHEDULE_CAPABILITY,
type ScanScheduleCapability,
} from "@/types/schedules";
interface ScanJobsRowActionsProps {
scan: ScanProps;
/**
* Schedule capability override. Only for Prowler Cloud.
*/
capability?: ScanScheduleCapability;
}
export function ScanJobsRowActions({ scan }: ScanJobsRowActionsProps) {
export function ScanJobsRowActions({
scan,
capability,
}: ScanJobsRowActionsProps) {
const router = useRouter();
const canEditSchedule =
(capability ?? getScanScheduleCapability(isCloud())) ===
SCAN_SCHEDULE_CAPABILITY.ADVANCED;
const { toast } = useToast();
const [editOpen, setEditOpen] = useState(false);
const [scheduleOpen, setScheduleOpen] = useState(false);
const [scheduleState, setScheduleState] = useState<EditScanScheduleState>({
kind: EDIT_SCAN_SCHEDULE_STATE.LOADING,
});
const [errorOpen, setErrorOpen] = useState(false);
const [errorState, setErrorState] = useState<ScanErrorDetailsState>({
kind: "idle",
@@ -43,6 +71,15 @@ export function ScanJobsRowActions({ scan }: ScanJobsRowActionsProps) {
const isFailed = scanState === "failed";
const taskId = scan.relationships.task.data?.id;
const scanDate = toLocalDateString(scan.attributes.completed_at);
const providerId = scan.relationships.provider.data?.id;
const scheduleProvider: ScanScheduleProvider | undefined = providerId
? {
providerId,
providerType: (scan.providerInfo?.provider ?? "aws") as ProviderType,
providerUid: scan.providerInfo?.uid ?? providerId,
providerAlias: scan.providerInfo?.alias ?? null,
}
: undefined;
const openFindings = () => {
if (!isCompleted || !scanDate) return;
@@ -96,9 +133,58 @@ export function ScanJobsRowActions({ scan }: ScanJobsRowActionsProps) {
setErrorState({ kind: "loaded", details });
};
const openScheduleEditor = async () => {
if (!providerId) {
setScheduleState({
kind: EDIT_SCAN_SCHEDULE_STATE.ERROR,
message: "Provider ID is not available for this scan.",
});
setScheduleOpen(true);
return;
}
setScheduleState({ kind: EDIT_SCAN_SCHEDULE_STATE.LOADING });
setScheduleOpen(true);
const response = (await getSchedule(providerId)) as
| ScheduleApiResponse
| { error?: string };
if (!response || ("error" in response && response.error)) {
setScheduleState({
kind: EDIT_SCAN_SCHEDULE_STATE.ERROR,
message:
response && "error" in response && response.error
? response.error
: "Failed to load scan schedule.",
});
return;
}
setScheduleState({
kind: EDIT_SCAN_SCHEDULE_STATE.LOADED,
schedule: "data" in response ? response.data : null,
});
};
return (
<div className="flex items-center justify-end">
<ActionDropdown>
{canEditSchedule && (
<ActionDropdownItem
icon={<CalendarClock />}
label="Edit Scan Schedule"
onSelect={() => void openScheduleEditor()}
/>
)}
{/* Synthetic pending rows have no Scan to alias. */}
{!scan.pendingSchedule && (
<ActionDropdownItem
icon={<Pencil />}
label="Edit Scan Alias"
onSelect={() => setEditOpen(true)}
/>
)}
{isCompleted && (
<>
<ActionDropdownItem
@@ -126,12 +212,6 @@ export function ScanJobsRowActions({ scan }: ScanJobsRowActionsProps) {
onSelect={() => void openErrorDetails()}
/>
)}
{/* TODO: Expand Edit to also cover schedule once the backend exposes a schedule update endpoint. */}
<ActionDropdownItem
icon={<Pencil />}
label="Edit"
onSelect={() => setEditOpen(true)}
/>
{/* TODO: Restore Cancel Scan once the backend exposes a public scan cancellation endpoint. */}
</ActionDropdown>
@@ -147,6 +227,13 @@ export function ScanJobsRowActions({ scan }: ScanJobsRowActionsProps) {
onOpenChange={setErrorOpen}
state={errorState}
/>
<EditScanScheduleModal
open={scheduleOpen}
onOpenChange={setScheduleOpen}
provider={scheduleProvider}
state={scheduleState}
/>
</div>
);
}
+1
View File
@@ -20,6 +20,7 @@ export * from "./select/multiselect";
export * from "./select/select";
export * from "./separator/separator";
export * from "./skeleton/skeleton";
export * from "./stacked-cell/stacked-cell";
export * from "./tabs/generic-tabs";
export * from "./tabs/tabs";
export * from "./textarea/textarea";
@@ -214,7 +214,7 @@ describe("MultiSelect", () => {
).not.toBeInTheDocument();
});
it("uses a normalized dropdown width instead of growing with the longest item", async () => {
it("sizes the dropdown to its content with a capped width", async () => {
const user = userEvent.setup();
render(
@@ -233,10 +233,8 @@ describe("MultiSelect", () => {
await user.click(screen.getByRole("combobox"));
expect(screen.getByRole("dialog")).toHaveClass(
"w-[min(var(--radix-popover-trigger-width),calc(100vw-2rem))]",
);
expect(screen.getByRole("dialog")).toHaveClass("max-w-[24rem]");
expect(screen.getByRole("dialog")).toHaveClass("sm:w-max");
expect(screen.getByRole("dialog")).toHaveClass("sm:max-w-[22rem]");
});
it("keeps long option lists scrollable inside the dropdown", async () => {
+3 -3
View File
@@ -313,8 +313,8 @@ export function MultiSelectContent({
const widthClasses =
width === "wide"
? "w-[min(max(var(--radix-popover-trigger-width),24rem),calc(100vw-2rem))] max-w-[32rem]"
: "w-[min(var(--radix-popover-trigger-width),calc(100vw-2rem))] max-w-[24rem]";
? "w-[calc(100vw-2rem)] sm:w-max sm:min-w-[min(max(var(--radix-popover-trigger-width),24rem),32rem)] sm:max-w-[32rem]"
: "w-[calc(100vw-2rem)] sm:w-max sm:min-w-[min(var(--radix-popover-trigger-width),22rem)] sm:max-w-[22rem]";
function handleSearchValueChange(searchValue: string) {
if (!canSearch || !searchValue.trim()) return;
@@ -426,7 +426,7 @@ export function MultiSelectItem({
onSelect?.(value);
}}
>
<span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden whitespace-nowrap">
{children}
</span>
<CheckIcon
@@ -0,0 +1,20 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { StackedCell } from "./stacked-cell";
describe("StackedCell", () => {
it("renders primary and secondary lines", () => {
render(<StackedCell primary="Jun 15, 2026" secondary="12:00AM MAD" />);
expect(screen.getByText("Jun 15, 2026")).toBeInTheDocument();
expect(screen.getByText("12:00AM MAD")).toBeInTheDocument();
});
it("omits the secondary line when not provided", () => {
const { container } = render(<StackedCell primary="Manual" />);
expect(screen.getByText("Manual")).toBeInTheDocument();
expect(container.querySelectorAll("span")).toHaveLength(1);
});
});
@@ -0,0 +1,36 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
interface StackedCellProps {
primary: ReactNode;
secondary?: ReactNode;
className?: string;
}
/**
* Presentational shell for two-line table cells (the DateWithTime look):
* primary line on top, muted secondary line underneath. Content/formatting
* belongs to the caller.
*/
export function StackedCell({
primary,
secondary,
className,
}: StackedCellProps) {
return (
<div
data-slot="stacked-cell"
className={cn("flex flex-col gap-1", className)}
>
<span className="text-text-neutral-primary text-sm whitespace-nowrap">
{primary}
</span>
{secondary ? (
<span className="text-text-neutral-tertiary text-xs font-medium">
{secondary}
</span>
) : null}
</div>
);
}
+8 -13
View File
@@ -5,6 +5,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { formatLocalTimeWithZone } from "@/lib/date-utils";
import { cn } from "@/lib/utils";
interface DateWithTimeProps {
@@ -29,18 +30,12 @@ export const DateWithTime = ({
}
const formattedDate = format(date, "MMM dd, yyyy");
const formattedTime = format(date, "h:mma");
const timezone =
Intl.DateTimeFormat()
.resolvedOptions()
.timeZone.split("/")
.pop()
?.substring(0, 3)
.toUpperCase() || "";
const timeWithZone = formatLocalTimeWithZone(dateTime);
const fullText = showTime
? `${formattedDate} ${formattedTime} ${timezone}`
: formattedDate;
const fullText =
showTime && timeWithZone
? `${formattedDate} ${timeWithZone}`
: formattedDate;
const content = (
<div
@@ -59,14 +54,14 @@ export const DateWithTime = ({
>
{formattedDate}
</span>
{showTime && (
{showTime && timeWithZone && (
<span
className={cn(
"text-text-neutral-tertiary text-xs font-medium whitespace-nowrap",
inline && "truncate",
)}
>
{formattedTime} {timezone}
{timeWithZone}
</span>
)}
</div>
+35
View File
@@ -22,6 +22,41 @@ export function toLocalDateString(
}
}
/** Local date in the app's table format (e.g. "Jun 15, 2026"), as shown by DateWithTime. */
export function formatLocalDate(
value: string | null | undefined,
): string | undefined {
if (!value) return undefined;
try {
const date = parseISO(value);
if (isNaN(date.getTime())) return undefined;
return format(date, "MMM dd, yyyy");
} catch {
return undefined;
}
}
/** Local time with the browser's short zone label (e.g. "12:00AM MAD"), as shown by DateWithTime. */
export function formatLocalTimeWithZone(
value: string | null | undefined,
): string | undefined {
if (!value) return undefined;
try {
const date = parseISO(value);
if (isNaN(date.getTime())) return undefined;
const zone =
Intl.DateTimeFormat()
.resolvedOptions()
.timeZone.split("/")
.pop()
?.substring(0, 3)
.toUpperCase() || "";
return `${format(date, "h:mma")} ${zone}`.trim();
} catch {
return undefined;
}
}
/**
* Formats a duration in seconds to a human-readable string like "2h 5m 30s".
*/
+391
View File
@@ -0,0 +1,391 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
buildScheduleUpdatePayload,
formatScheduleHour,
getBrowserTimezone,
getNextScheduledRun,
getScanScheduleCapability,
getScheduleFormValues,
isScheduleConfigured,
} from "@/lib/schedules";
import {
SCAN_SCHEDULE_CAPABILITY,
SCHEDULE_FREQUENCY,
type ScheduleAttributes,
} from "@/types/schedules";
describe("schedule payload mapping", () => {
beforeEach(() => {
vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({
resolvedOptions: () => ({ timeZone: "Europe/Madrid" }),
} as Intl.DateTimeFormat);
});
it("maps daily schedules and clears unused fields", () => {
// Given
const values = {
frequency: SCHEDULE_FREQUENCY.DAILY,
hour: 8,
dayOfWeek: 2,
dayOfMonth: 12,
intervalHours: 48,
launchInitialScan: false,
};
// When
const payload = buildScheduleUpdatePayload(values);
// Then
expect(payload).toEqual({
scan_enabled: true,
scan_frequency: SCHEDULE_FREQUENCY.DAILY,
scan_hour: 8,
scan_timezone: "Europe/Madrid",
scan_interval_hours: null,
scan_day_of_week: null,
scan_day_of_month: null,
});
});
it("maps every 48 hours schedules as an interval", () => {
// Given
const values = {
frequency: SCHEDULE_FREQUENCY.INTERVAL,
hour: 23,
dayOfWeek: 0,
dayOfMonth: 1,
intervalHours: 48,
launchInitialScan: false,
};
// When
const payload = buildScheduleUpdatePayload(values);
// Then
expect(payload).toEqual({
scan_enabled: true,
scan_frequency: SCHEDULE_FREQUENCY.INTERVAL,
scan_hour: 23,
scan_timezone: "Europe/Madrid",
scan_interval_hours: 48,
scan_day_of_week: null,
scan_day_of_month: null,
});
});
it("preserves a custom interval instead of rewriting it to 48 hours", () => {
// Given a schedule whose interval was set outside the UI (e.g. bulk API)
const values = {
frequency: SCHEDULE_FREQUENCY.INTERVAL,
hour: 5,
dayOfWeek: 0,
dayOfMonth: 1,
intervalHours: 72,
launchInitialScan: false,
};
// When
const payload = buildScheduleUpdatePayload(values);
// Then
expect(payload.scan_interval_hours).toBe(72);
});
it("maps weekly schedules with 0 as Sunday and clears interval/month", () => {
// Given
const values = {
frequency: SCHEDULE_FREQUENCY.WEEKLY,
hour: 6,
dayOfWeek: 0,
dayOfMonth: 28,
intervalHours: 48,
launchInitialScan: false,
};
// When
const payload = buildScheduleUpdatePayload(values);
// Then
expect(payload).toEqual({
scan_enabled: true,
scan_frequency: SCHEDULE_FREQUENCY.WEEKLY,
scan_hour: 6,
scan_timezone: "Europe/Madrid",
scan_interval_hours: null,
scan_day_of_week: 0,
scan_day_of_month: null,
});
});
it("maps monthly schedules and keeps scan_hour 0 (not falsy-coerced)", () => {
// Given
const values = {
frequency: SCHEDULE_FREQUENCY.MONTHLY,
hour: 0,
dayOfWeek: 5,
dayOfMonth: 28,
intervalHours: 48,
launchInitialScan: false,
};
// When
const payload = buildScheduleUpdatePayload(values);
// Then
expect(payload).toEqual({
scan_enabled: true,
scan_frequency: SCHEDULE_FREQUENCY.MONTHLY,
scan_hour: 0,
scan_timezone: "Europe/Madrid",
scan_interval_hours: null,
scan_day_of_week: null,
scan_day_of_month: 28,
});
});
});
describe("formatScheduleHour", () => {
it.each([
[0, "12:00am"],
[12, "12:00pm"],
[13, "1:00pm"],
[23, "11:00pm"],
[-1, "11:00pm"],
[24, "12:00am"],
])("formats hour %i as %s", (hour, expected) => {
expect(formatScheduleHour(hour)).toBe(expected);
});
});
describe("isScheduleConfigured", () => {
it("treats a null scan_hour as not configured", () => {
expect(isScheduleConfigured({ scan_hour: null })).toBe(false);
});
it("treats scan_hour 0 (midnight) as configured", () => {
expect(isScheduleConfigured({ scan_hour: 0 })).toBe(true);
});
it("treats a set scan_hour as configured", () => {
expect(isScheduleConfigured({ scan_hour: 14 })).toBe(true);
});
});
describe("getScheduleFormValues", () => {
const buildAttributes = (
overrides: Partial<ScheduleAttributes> = {},
): ScheduleAttributes => ({
scan_enabled: true,
scan_frequency: SCHEDULE_FREQUENCY.WEEKLY,
scan_hour: 9,
scan_timezone: "Europe/Madrid",
scan_interval_hours: null,
scan_day_of_week: 4,
scan_day_of_month: 20,
...overrides,
});
it("returns defaults when there is no schedule", () => {
expect(getScheduleFormValues(null)).toEqual({
frequency: SCHEDULE_FREQUENCY.DAILY,
hour: 0,
dayOfWeek: 1,
dayOfMonth: 1,
intervalHours: 48,
launchInitialScan: false,
});
});
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,
},
);
});
it("maps a configured schedule onto the form", () => {
expect(getScheduleFormValues(buildAttributes())).toEqual({
frequency: SCHEDULE_FREQUENCY.WEEKLY,
hour: 9,
dayOfWeek: 4,
dayOfMonth: 20,
intervalHours: 48,
launchInitialScan: false,
});
});
it("keeps a custom interval from the stored schedule", () => {
const values = getScheduleFormValues(
buildAttributes({
scan_frequency: SCHEDULE_FREQUENCY.INTERVAL,
scan_interval_hours: 72,
scan_day_of_week: null,
scan_day_of_month: null,
}),
);
expect(values.frequency).toBe(SCHEDULE_FREQUENCY.INTERVAL);
expect(values.intervalHours).toBe(72);
});
it("falls back to default day fields when the schedule leaves them null", () => {
const values = getScheduleFormValues(
buildAttributes({ scan_day_of_week: null, scan_day_of_month: null }),
);
expect(values.dayOfWeek).toBe(1);
expect(values.dayOfMonth).toBe(1);
});
});
describe("getNextScheduledRun", () => {
const baseValues = {
frequency: SCHEDULE_FREQUENCY.DAILY,
hour: 14,
dayOfWeek: 5,
dayOfMonth: 15,
intervalHours: 48,
launchInitialScan: false,
};
// Wednesday 2026-06-10 10:30 local.
const now = new Date(2026, 5, 10, 10, 30, 0, 0);
const parts = (date: Date) => ({
year: date.getFullYear(),
month: date.getMonth(),
day: date.getDate(),
hour: date.getHours(),
});
it("DAILY: same day when the hour is still ahead", () => {
expect(
parts(getNextScheduledRun({ ...baseValues, hour: 14 }, now)),
).toEqual({ year: 2026, month: 5, day: 10, hour: 14 });
});
it("DAILY: next day when the hour already passed", () => {
expect(parts(getNextScheduledRun({ ...baseValues, hour: 8 }, now))).toEqual(
{
year: 2026,
month: 5,
day: 11,
hour: 8,
},
);
});
it("INTERVAL: anchors at the next occurrence of the hour, like DAILY", () => {
// The backend derives the INTERVAL anchor as today/tomorrow at scan_hour
// and fires the first run there; repeats only start after that.
expect(
parts(
getNextScheduledRun(
{ ...baseValues, frequency: SCHEDULE_FREQUENCY.INTERVAL, hour: 14 },
now,
),
),
).toEqual({ year: 2026, month: 5, day: 10, hour: 14 });
expect(
parts(
getNextScheduledRun(
{ ...baseValues, frequency: SCHEDULE_FREQUENCY.INTERVAL, hour: 8 },
now,
),
),
).toEqual({ year: 2026, month: 5, day: 11, hour: 8 });
});
it("WEEKLY: advances to the target weekday this week", () => {
expect(
parts(
getNextScheduledRun(
{
...baseValues,
frequency: SCHEDULE_FREQUENCY.WEEKLY,
dayOfWeek: 5,
hour: 9,
},
now,
),
),
).toEqual({ year: 2026, month: 5, day: 12, hour: 9 });
});
it("WEEKLY: jumps a week when the target day/hour already passed today", () => {
expect(
parts(
getNextScheduledRun(
{
...baseValues,
frequency: SCHEDULE_FREQUENCY.WEEKLY,
dayOfWeek: 3,
hour: 8,
},
now,
),
),
).toEqual({ year: 2026, month: 5, day: 17, hour: 8 });
});
it("MONTHLY: this month when the day is still ahead", () => {
expect(
parts(
getNextScheduledRun(
{
...baseValues,
frequency: SCHEDULE_FREQUENCY.MONTHLY,
dayOfMonth: 15,
},
now,
),
),
).toEqual({ year: 2026, month: 5, day: 15, hour: 14 });
});
it("MONTHLY: next month when the day already passed", () => {
expect(
parts(
getNextScheduledRun(
{
...baseValues,
frequency: SCHEDULE_FREQUENCY.MONTHLY,
dayOfMonth: 5,
},
now,
),
),
).toEqual({ year: 2026, month: 6, day: 5, hour: 14 });
});
});
describe("browser timezone", () => {
it("falls back to UTC when browser timezone is unavailable", () => {
// Given
vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({
resolvedOptions: () => ({}),
} as Intl.DateTimeFormat);
// When / Then
expect(getBrowserTimezone()).toBe("UTC");
});
});
describe("scan schedule capability", () => {
it("returns DAILY_LEGACY for non-Cloud (OSS)", () => {
expect(getScanScheduleCapability(false)).toBe(
SCAN_SCHEDULE_CAPABILITY.DAILY_LEGACY,
);
});
it("returns ADVANCED for Cloud", () => {
expect(getScanScheduleCapability(true)).toBe(
SCAN_SCHEDULE_CAPABILITY.ADVANCED,
);
});
});
+238
View File
@@ -0,0 +1,238 @@
import { z } from "zod";
import {
SCAN_SCHEDULE_CAPABILITY,
type ScanScheduleCapability,
SCHEDULE_FREQUENCY,
SCHEDULE_WEEKDAY_LABELS,
type ScheduleAttributes,
type ScheduleFormValues,
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;
export const scheduleFormSchema = z.object({
frequency: z.enum(SCHEDULE_FREQUENCY),
hour: z.number().int().min(0).max(23),
dayOfWeek: z.number().int().min(0).max(6),
dayOfMonth: z.number().int().min(1).max(28),
intervalHours: z.number().int().min(SCAN_INTERVAL_HOURS_MIN),
launchInitialScan: z.boolean(),
});
/**
* Default scan-schedule capability for the current environment.
*
* Pure function (no side effects) so it is trivial to unit-test. Prowler OSS has
* no billing, so the only distinction it can make is Cloud vs non-Cloud:
* non-Cloud → legacy daily-only, Cloud → full scheduling. The prowler-cloud
* overlay computes its own (billing-aware) capability and passes it down via the
* optional `capability` prop, overriding this default — no billing concept ever
* leaks into OSS.
*/
export function getScanScheduleCapability(
isCloud: boolean,
): ScanScheduleCapability {
return isCloud
? SCAN_SCHEDULE_CAPABILITY.ADVANCED
: SCAN_SCHEDULE_CAPABILITY.DAILY_LEGACY;
}
export function formatScheduleHour(hour: number): string {
const normalizedHour = ((hour % 24) + 24) % 24;
const period = normalizedHour >= 12 ? "pm" : "am";
const displayHour = normalizedHour % 12 === 0 ? 12 : normalizedHour % 12;
return `${displayHour}:00${period}`;
}
export function getBrowserTimezone(): string {
if (typeof window === "undefined") {
return "UTC";
}
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
}
export function getScheduleFormDefaults(): ScheduleFormValues {
return {
frequency: SCHEDULE_FREQUENCY.DAILY,
hour: DEFAULT_SCHEDULE_HOUR,
dayOfWeek: DEFAULT_DAY_OF_WEEK,
dayOfMonth: DEFAULT_DAY_OF_MONTH,
intervalHours: DEFAULT_INTERVAL_HOURS,
launchInitialScan: false,
};
}
export function getScheduleFormValues(
schedule?: ScheduleAttributes | null,
): ScheduleFormValues {
const defaults = getScheduleFormDefaults();
if (!schedule || schedule.scan_hour === null) {
return defaults;
}
return {
frequency: schedule.scan_frequency,
hour: schedule.scan_hour,
dayOfWeek: schedule.scan_day_of_week ?? defaults.dayOfWeek,
dayOfMonth: schedule.scan_day_of_month ?? defaults.dayOfMonth,
intervalHours: schedule.scan_interval_hours ?? defaults.intervalHours,
launchInitialScan: false,
};
}
export function buildScheduleUpdatePayload(
values: ScheduleFormValues,
): ScheduleUpdatePayload {
return {
scan_enabled: true,
scan_frequency: values.frequency,
scan_hour: values.hour,
scan_timezone: getBrowserTimezone(),
scan_interval_hours:
values.frequency === SCHEDULE_FREQUENCY.INTERVAL
? values.intervalHours
: null,
scan_day_of_week:
values.frequency === SCHEDULE_FREQUENCY.WEEKLY ? values.dayOfWeek : null,
scan_day_of_month:
values.frequency === SCHEDULE_FREQUENCY.MONTHLY
? values.dayOfMonth
: null,
};
}
/**
* Whether a provider has an explicitly configured scan schedule.
*
* The schedule resource is backed by the Provider row itself: an unconfigured
* provider — or one whose schedule was removed (DELETE resets the schedule to
* defaults: `scan_hour=null`, but leaves `scan_enabled=true`) — comes back with
* `scan_hour === null`. So `scan_hour` is the canonical "is configured" signal;
* `scan_enabled` is NOT, because a freshly created provider already reports
* `scan_enabled=true`.
*/
export function isScheduleConfigured(
attributes: Pick<ScheduleAttributes, "scan_hour">,
): boolean {
return attributes.scan_hour !== null;
}
/**
* Computes the next time a schedule would run, as a local `Date`. Pure: `now` is
* injected so it is deterministic to test. Computation is done in the browser's
* local time, which matches the timezone shown next to it (`getBrowserTimezone`),
* so no timezone-conversion library is needed. This is an estimate for display;
* the backend is the source of truth for the actual fire time.
*
* INTERVAL shares the DAILY computation: the backend anchors its first run at
* the next occurrence of `scan_hour`.
*/
export function getNextScheduledRun(
values: ScheduleFormValues,
now: Date,
): Date {
const next = new Date(now);
next.setHours(values.hour, 0, 0, 0);
switch (values.frequency) {
case SCHEDULE_FREQUENCY.WEEKLY: {
let delta = (values.dayOfWeek - next.getDay() + 7) % 7;
if (delta === 0 && next <= now) delta = 7;
next.setDate(next.getDate() + delta);
return next;
}
case SCHEDULE_FREQUENCY.MONTHLY: {
next.setDate(values.dayOfMonth);
if (next <= now) {
next.setMonth(next.getMonth() + 1, values.dayOfMonth);
}
return next;
}
default: {
// DAILY and INTERVAL (the interval anchor is the next occurrence of the hour)
if (next <= now) next.setDate(next.getDate() + 1);
return next;
}
}
}
export interface ScheduleCadenceParts {
/** e.g. "Weekly on Monday" */
cadence: string;
/** e.g. "9:00am (Europe/Madrid)" */
time: string;
}
export function getScheduleCadenceParts(
attributes: ScheduleAttributes,
): ScheduleCadenceParts {
const time = `${formatScheduleHour(attributes.scan_hour ?? 0)} (${attributes.scan_timezone})`;
switch (attributes.scan_frequency) {
case SCHEDULE_FREQUENCY.WEEKLY: {
const weekday =
SCHEDULE_WEEKDAY_LABELS[attributes.scan_day_of_week ?? 0] ??
SCHEDULE_WEEKDAY_LABELS[0];
return { cadence: `Weekly on ${weekday}`, time };
}
case SCHEDULE_FREQUENCY.MONTHLY:
return {
cadence: `Monthly on day ${attributes.scan_day_of_month ?? 1}`,
time,
};
case SCHEDULE_FREQUENCY.INTERVAL:
return {
cadence: `Every ${attributes.scan_interval_hours ?? 0} hours`,
time,
};
default:
return { cadence: "Daily", time };
}
}
/** Human-readable cadence, e.g. "Weekly on Monday @ 9:00am (Europe/Madrid)". */
export function describeScheduleCadence(
attributes: ScheduleAttributes,
): string {
const { cadence, time } = getScheduleCadenceParts(attributes);
return `${cadence} @ ${time}`;
}
/**
* Next-run estimate honoring the schedule's own timezone: `toLocaleString`
* converts `now` to that timezone's wall-clock and the offset is applied back.
*/
export function getNextScheduledRunInTimezone(
attributes: ScheduleAttributes,
now: Date,
): Date | null {
if (attributes.scan_hour === null) return null;
let timezoneNow: Date;
try {
timezoneNow = new Date(
now.toLocaleString("en-US", { timeZone: attributes.scan_timezone }),
);
} catch {
timezoneNow = new Date(now);
}
if (Number.isNaN(timezoneNow.getTime())) timezoneNow = new Date(now);
const target = getNextScheduledRun(
getScheduleFormValues(attributes),
timezoneNow,
);
return new Date(now.getTime() + (target.getTime() - timezoneNow.getTime()));
}
+14
View File
@@ -0,0 +1,14 @@
/**
* Shared environment helpers.
*/
/**
* Whether the UI is running inside a Prowler Cloud deployment.
*
* `NEXT_PUBLIC_*` vars are statically inlined by Next.js wherever the literal
* `process.env.NEXT_PUBLIC_IS_CLOUD_ENV` appears in source, so keeping this read
* inside a helper is safe.
*/
export function isCloud(): boolean {
return process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
}
+1
View File
@@ -10,5 +10,6 @@ export * from "./providers";
export * from "./providers-table";
export * from "./resources";
export * from "./scans";
export * from "./schedules";
export * from "./tasks";
export * from "./tree";
+13
View File
@@ -83,12 +83,25 @@ export interface ScanProviderInfo {
connected: boolean;
}
/** Cadence summary computed from `/schedules`, e.g. "Weekly on Monday @ 9:00am". */
export interface ScanScheduleSummary {
summary: string;
/** e.g. "Weekly on Monday" */
cadence?: string;
nextScanAt?: string | null;
lastScanAt?: string | null;
}
export interface ScanProps {
type: "scans";
id: string;
attributes: ScanAttributes;
relationships: ScanRelationships;
providerInfo?: ScanResultProviderInfo;
/** Only on synthetic Scheduled-tab rows for schedules that have not fired yet. */
pendingSchedule?: ScanScheduleSummary;
/** Current schedule cadence of the scan's provider, when configured. */
providerSchedule?: ScanScheduleSummary;
}
export interface ScanEntityProviderInfo {
+97
View File
@@ -0,0 +1,97 @@
import type { ProviderProps } from "./providers";
export const SCHEDULE_FREQUENCY = {
DAILY: "DAILY",
INTERVAL: "INTERVAL",
WEEKLY: "WEEKLY",
MONTHLY: "MONTHLY",
} as const;
export type ScheduleFrequency =
(typeof SCHEDULE_FREQUENCY)[keyof typeof SCHEDULE_FREQUENCY];
/** Weekday names indexed by cron convention (0=Sunday). */
export const SCHEDULE_WEEKDAY_LABELS = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
] as const;
/**
* Scan-schedule capability modes. In Prowler OSS this is resolved purely from
* the runtime environment (Cloud vs non-Cloud); the prowler-cloud overlay
* computes a billing-aware capability and injects it via the `capability` prop.
*
* - `ADVANCED`: full scheduling through the new `/schedules/{providerId}` API
* (Prowler Cloud, subscribed/paid).
* - `DAILY_LEGACY`: Prowler OSS / non-Cloud. Only the legacy `Daily` schedule
* (`/schedules/daily`) plus optional on-demand scans are allowed.
* - `MANUAL_ONLY`: Prowler Cloud trial/onboarding. No schedules at all, only a
* manual on-demand scan subject to the account quota.
*/
export const SCAN_SCHEDULE_CAPABILITY = {
ADVANCED: "ADVANCED",
DAILY_LEGACY: "DAILY_LEGACY",
MANUAL_ONLY: "MANUAL_ONLY",
} as const;
export type ScanScheduleCapability =
(typeof SCAN_SCHEDULE_CAPABILITY)[keyof typeof SCAN_SCHEDULE_CAPABILITY];
export interface ScheduleAttributes {
scan_enabled: boolean;
scan_frequency: ScheduleFrequency;
scan_hour: number | null;
scan_timezone: string;
scan_interval_hours: number | null;
scan_day_of_week: number | null;
scan_day_of_month: number | null;
/** Read-only, server-computed next fire time; null when paused/unconfigured. */
next_scan_at?: string | null;
/** Read-only, completed_at of the provider's last completed scan. */
last_scan_at?: string | null;
}
export interface ScheduleRelationships {
provider: {
data: {
type: "providers";
id: string;
};
};
}
export interface ScheduleProps {
type: "schedules";
id: string;
attributes: ScheduleAttributes;
relationships: ScheduleRelationships;
}
export interface ScheduleApiResponse {
data: ScheduleProps;
included?: ProviderProps[];
}
export interface ScheduleUpdatePayload {
scan_enabled: boolean;
scan_frequency: ScheduleFrequency;
scan_hour: number;
scan_timezone: string;
scan_interval_hours: number | null;
scan_day_of_week: number | null;
scan_day_of_month: number | null;
}
export interface ScheduleFormValues {
frequency: ScheduleFrequency;
hour: number;
dayOfWeek: number;
dayOfMonth: number;
intervalHours: number;
launchInitialScan: boolean;
}