mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-06-10 21:42:29 +00:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d636841832 | |||
| 870b32ed5d | |||
| 5cda01a0df | |||
| f80a56b88c | |||
| 9a9f0989de | |||
| 689916132a | |||
| 37a700dd4c | |||
| e38f249cd4 | |||
| 51c7e4f0b8 | |||
| 67e105cfeb | |||
| 48d7e7aa06 | |||
| 91b8f9dcce | |||
| 35748cc6b0 | |||
| 1d80e2dc17 | |||
| 14551245c4 | |||
| 4bc7a32159 | |||
| 81b636a2e7 | |||
| 3c5239a870 | |||
| f74f5eba0f | |||
| 9cf5c30d3e | |||
| a8998ad091 | |||
| c68b226582 | |||
| e62ae1cf0a | |||
| 21f20fa332 | |||
| 47267f39d0 | |||
| 1559cdf9e8 |
@@ -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");
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./schedules";
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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".
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user