feat(ui): per-provider scan schedule management gated by capability (#11521)

This commit is contained in:
Alejandro Bailo
2026-06-18 15:47:03 +02:00
committed by GitHub
parent 853610bbbf
commit 908d2ce766
63 changed files with 5539 additions and 953 deletions
+27
View File
@@ -28,6 +28,10 @@ import {
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: getAuthHeadersMock,
GENERIC_SERVER_ERROR_MESSAGE:
"Server is temporarily unavailable. Please try again in a few minutes.",
sanitizeErrorMessage: (message: string, fallback: string) =>
/<html\b|<\/?body\b|<\/?h1\b/i.test(message) ? fallback : message.trim(),
composeSort,
FG_FAIL_FIRST,
FG_RECENT_LAST_SEEN,
@@ -167,6 +171,29 @@ describe("getResourceEvents", () => {
});
});
it("returns a generic error when a gateway returns HTML", async () => {
// Given
const mockResponse = new Response(
"<html><head><title>502 Bad Gateway</title></head><body><h1>502 Bad Gateway</h1></body></html>",
{
status: 502,
statusText: "Bad Gateway",
headers: { "content-type": "text/html" },
},
);
fetchMock.mockResolvedValue(mockResponse);
// When
const result = await getResourceEvents("resource-123");
// Then
expect(result).toEqual({
error:
"Server is temporarily unavailable. Please try again in a few minutes.",
status: 502,
});
});
it("returns generic error when fetch throws", async () => {
// Given
fetchMock.mockRejectedValue(new Error("Network failure"));
+17 -6
View File
@@ -4,7 +4,13 @@ import { redirect } from "next/navigation";
import { getLatestFindings } from "@/actions/findings";
import { listOrganizationsSafe } from "@/actions/organizations/organizations";
import { apiBaseUrl, FINDINGS_FILTERED_SORT, getAuthHeaders } from "@/lib";
import {
apiBaseUrl,
FINDINGS_FILTERED_SORT,
GENERIC_SERVER_ERROR_MESSAGE,
getAuthHeaders,
sanitizeErrorMessage,
} from "@/lib";
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
import { handleApiResponse } from "@/lib/server-actions-helper";
import { OrganizationResource } from "@/types/organizations";
@@ -193,22 +199,27 @@ export const getResourceEvents = async (
if (!response.ok) {
const rawText = await response.text().catch(() => "");
const contentType =
response.headers.get("content-type")?.toLowerCase() || "";
const defaultError = "An error occurred while fetching events.";
const fallbackError = contentType.includes("text/html")
? GENERIC_SERVER_ERROR_MESSAGE
: response.statusText || defaultError;
try {
const errorData = rawText ? JSON.parse(rawText) : null;
return {
error:
const errorMessage =
errorData?.errors?.[0]?.detail ||
errorData?.error ||
errorData?.message ||
rawText ||
response.statusText ||
defaultError,
fallbackError;
return {
error: sanitizeErrorMessage(String(errorMessage), fallbackError),
status: response.status,
};
} catch {
return {
error: rawText || response.statusText || defaultError,
error: sanitizeErrorMessage(rawText || fallbackError, fallbackError),
status: response.status,
};
}
+34 -1
View File
@@ -14,6 +14,8 @@ const {
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
GENERIC_SERVER_ERROR_MESSAGE:
"Server is temporarily unavailable. Please try again in a few minutes.",
getAuthHeaders: getAuthHeadersMock,
getErrorMessage: (error: unknown) =>
error instanceof Error ? error.message : String(error),
@@ -28,7 +30,7 @@ vi.mock("@/lib/sentry-breadcrumbs", () => ({
addScanOperation: vi.fn(),
}));
import { launchOrganizationScans } from "./scans";
import { getExportsZip, launchOrganizationScans } from "./scans";
describe("launchOrganizationScans", () => {
beforeEach(() => {
@@ -69,3 +71,34 @@ describe("launchOrganizationScans", () => {
expect(result.failureCount).toBe(0);
});
});
describe("getExportsZip", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
});
it("returns a generic server error when the report endpoint returns HTML", async () => {
// Given
fetchMock.mockResolvedValue(
new Response(
"<html><head><title>502 Bad Gateway</title></head><body><h1>502 Bad Gateway</h1></body></html>",
{
status: 502,
statusText: "Bad Gateway",
headers: { "content-type": "text/html" },
},
),
);
// When
const result = await getExportsZip("scan-123");
// Then
expect(result).toEqual({
error:
"Server is temporarily unavailable. Please try again in a few minutes.",
});
});
});
+41 -12
View File
@@ -3,7 +3,12 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { apiBaseUrl, getAuthHeaders, getErrorMessage } from "@/lib";
import {
apiBaseUrl,
GENERIC_SERVER_ERROR_MESSAGE,
getAuthHeaders,
getErrorMessage,
} from "@/lib";
import {
COMPLIANCE_REPORT_DISPLAY_NAMES,
type ComplianceReportType,
@@ -162,18 +167,20 @@ export const scheduleDaily = async (formData: FormData) => {
const url = new URL(`${apiBaseUrl}/schedules/daily`);
try {
const response = await fetch(url.toString(), {
method: "POST",
headers,
body: JSON.stringify({
const body = {
data: {
type: "daily-schedules",
attributes: {
provider_id: providerId,
},
},
}),
};
try {
const response = await fetch(url.toString(), {
method: "POST",
headers,
body: JSON.stringify(body),
});
return handleApiResponse(response, "/scans");
@@ -249,6 +256,27 @@ export const launchOrganizationScans = async (
return summary;
};
async function getScanReportErrorMessage(
response: Response,
fallbackMessage: string,
): Promise<string> {
const contentType = response.headers.get("content-type")?.toLowerCase() || "";
if (contentType.includes("text/html")) {
return GENERIC_SERVER_ERROR_MESSAGE;
}
const errorData = await response.json().catch(() => null);
return (
errorData?.errors?.[0]?.detail ||
errorData?.errors?.detail ||
errorData?.error ||
errorData?.message ||
(response.status >= 500 ? GENERIC_SERVER_ERROR_MESSAGE : fallbackMessage)
);
}
export const updateScan = async (formData: FormData) => {
const headers = await getAuthHeaders({ contentType: true });
@@ -300,11 +328,11 @@ export const getExportsZip = async (scanId: string) => {
}
if (!response.ok) {
const errorData = await response.json();
throw new Error(
errorData?.errors?.detail ||
await getScanReportErrorMessage(
response,
"Unable to fetch scan report. Contact support if the issue continues.",
),
);
}
@@ -375,10 +403,11 @@ const _fetchScanBinary = async (
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData?.errors?.detail ||
await getScanReportErrorMessage(
response,
`Unable to retrieve ${errorLabel}. Contact support if the issue continues.`,
),
);
}
+1
View File
@@ -0,0 +1 @@
export * from "./schedules";
+103
View File
@@ -0,0 +1,103 @@
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 { getSchedule, removeSchedule, updateSchedule } from "./schedules";
const PROVIDER_ID = "1795f636-37e6-42f6-b158-d4faaa64e0fc";
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_ID, 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_ID, payload);
expect(revalidatePathMock).not.toHaveBeenCalled();
});
it("revalidates /scans and /providers after a successful delete", async () => {
handleApiResponseMock.mockResolvedValue({ success: true });
await removeSchedule(PROVIDER_ID);
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_ID);
expect(revalidatePathMock).not.toHaveBeenCalled();
});
it("rejects non-UUID provider ids without issuing a request", async () => {
const malicious = "../users/me";
expect(await getSchedule(malicious)).toEqual({
error: "Invalid provider id.",
});
expect(await updateSchedule(malicious, payload)).toEqual({
error: "Invalid provider id.",
});
expect(await removeSchedule(malicious)).toEqual({
error: "Invalid provider id.",
});
expect(fetchMock).not.toHaveBeenCalled();
});
});
+128
View File
@@ -0,0 +1,128 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
import type { ScheduleProps, ScheduleUpdatePayload } from "@/types/schedules";
// SSRF guard: the id is interpolated into the request URL, so only UUIDs pass.
const providerIdSchema = z.uuid();
function parseProviderId(providerId: string): string | null {
const parsed = providerIdSchema.safeParse(providerId);
return parsed.success ? parsed.data : null;
}
function revalidateScheduleViews() {
revalidatePath("/scans");
revalidatePath("/providers");
}
export const getSchedule = async (providerId: string) => {
const id = parseProviderId(providerId);
if (!id) return { error: "Invalid provider id." };
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/schedules/${id}`);
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 id = parseProviderId(providerId);
if (!id) return { error: "Invalid provider id." };
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/schedules/${id}`);
const body = {
data: {
type: "schedules",
id,
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 id = parseProviderId(providerId);
if (!id) return { error: "Invalid provider id." };
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/schedules/${id}`);
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);
}
};
@@ -14,16 +14,26 @@ const scansActionsMock = vi.hoisted(() => ({
getScans: vi.fn(),
}));
const schedulesActionsMock = vi.hoisted(() => ({
getSchedules: vi.fn(),
}));
vi.mock("@/actions/providers", () => providersActionsMock);
vi.mock(
"@/actions/organizations/organizations",
() => organizationsActionsMock,
);
vi.mock("@/actions/scans", () => scansActionsMock);
vi.mock("@/actions/schedules", () => schedulesActionsMock);
import { SearchParamsProps } from "@/types";
import { ProvidersApiResponse } from "@/types/providers";
import { ProvidersProviderRow } from "@/types/providers-table";
import {
SCHEDULE_FREQUENCY,
type ScheduleAttributes,
type ScheduleProps,
} from "@/types/schedules";
import {
buildProvidersTableRows,
@@ -156,6 +166,33 @@ const toProviderRow = (
},
});
const buildSchedule = (
providerId: string,
overrides: Partial<ScheduleAttributes> = {},
): ScheduleProps => ({
type: "schedules",
id: providerId,
attributes: {
scan_enabled: true,
scan_frequency: SCHEDULE_FREQUENCY.DAILY,
scan_hour: 9,
scan_timezone: "Europe/Madrid",
scan_interval_hours: null,
scan_day_of_week: null,
scan_day_of_month: null,
...overrides,
},
relationships: {
provider: { data: { type: "providers", id: providerId } },
},
});
const findProviderRow = (
rows: { id: string }[],
providerId: string,
): ProvidersProviderRow | undefined =>
rows.find((row) => row.id === providerId) as ProvidersProviderRow | undefined;
describe("buildProvidersTableRows", () => {
it("returns a flat providers table for OSS", () => {
// Given
@@ -751,4 +788,98 @@ describe("loadProvidersAccountsViewData", () => {
viewData.rows.every((row) => row.rowType === PROVIDERS_ROW_TYPE.PROVIDER),
).toBe(true);
});
it("surfaces the real cadence (not a hardcoded label) from a configured schedule with no materialized scan yet", async () => {
// Given — provider-1 has a WEEKLY schedule but the backend has not yet
// created a Scan row (the gap between configuring and the first fire).
providersActionsMock.getProviders.mockResolvedValue(providersResponse);
providersActionsMock.getAllProviders.mockResolvedValue(providersResponse);
scansActionsMock.getScans.mockResolvedValue({ data: [] });
schedulesActionsMock.getSchedules.mockResolvedValue({
data: [
buildSchedule("provider-1", {
scan_frequency: SCHEDULE_FREQUENCY.WEEKLY,
scan_hour: 9,
scan_day_of_week: 1,
}),
],
});
// When
const viewData = await loadProvidersAccountsViewData({
searchParams: {} satisfies SearchParamsProps,
isCloud: false,
});
// Then — the row carries the Weekly cadence, not "Daily".
const providerRow = findProviderRow(viewData.rows, "provider-1");
expect(providerRow?.hasSchedule).toBe(true);
expect(providerRow?.scheduleSummary?.cadence).toBe("Weekly on Monday");
expect(findProviderRow(viewData.rows, "provider-2")?.hasSchedule).toBe(
false,
);
expect(
findProviderRow(viewData.rows, "provider-2")?.scheduleSummary,
).toBeUndefined();
});
it("ignores paused or unconfigured schedules", async () => {
// Given — provider-1 paused (disabled), provider-2 never configured.
providersActionsMock.getProviders.mockResolvedValue(providersResponse);
providersActionsMock.getAllProviders.mockResolvedValue(providersResponse);
scansActionsMock.getScans.mockResolvedValue({ data: [] });
schedulesActionsMock.getSchedules.mockResolvedValue({
data: [
buildSchedule("provider-1", { scan_enabled: false, scan_hour: 9 }),
buildSchedule("provider-2", { scan_enabled: true, scan_hour: null }),
],
});
// When
const viewData = await loadProvidersAccountsViewData({
searchParams: {} satisfies SearchParamsProps,
isCloud: false,
});
// Then
expect(findProviderRow(viewData.rows, "provider-1")?.hasSchedule).toBe(
false,
);
expect(findProviderRow(viewData.rows, "provider-2")?.hasSchedule).toBe(
false,
);
});
it("falls back to scan-based detection when /schedules is unavailable (OSS)", async () => {
// Given — /schedules errors, but provider-1 has a materialized scheduled scan.
providersActionsMock.getProviders.mockResolvedValue(providersResponse);
providersActionsMock.getAllProviders.mockResolvedValue(providersResponse);
scansActionsMock.getScans.mockResolvedValue({
data: [
{
type: "scans",
id: "scan-1",
attributes: { trigger: "scheduled", state: "scheduled" },
relationships: {
provider: { data: { type: "providers", id: "provider-1" } },
},
},
],
});
schedulesActionsMock.getSchedules.mockResolvedValue({ error: "Not found" });
// When
const viewData = await loadProvidersAccountsViewData({
searchParams: {} satisfies SearchParamsProps,
isCloud: false,
});
// Then — scan-based path still flags the provider; no throw from the error.
expect(findProviderRow(viewData.rows, "provider-1")?.hasSchedule).toBe(
true,
);
expect(findProviderRow(viewData.rows, "provider-2")?.hasSchedule).toBe(
false,
);
});
});
@@ -4,10 +4,16 @@ import {
} from "@/actions/organizations/organizations";
import { getAllProviders, getProviders } from "@/actions/providers";
import { getScans } from "@/actions/scans";
import { getSchedules } from "@/actions/schedules";
import {
extractFiltersAndQuery,
extractSortAndKey,
} from "@/lib/helper-filters";
import {
buildProviderScheduleSummary,
buildSchedulesByProviderId,
isScheduleConfigured,
} from "@/lib/schedules";
import {
FilterEntity,
FilterOption,
@@ -27,7 +33,8 @@ import {
ProvidersTableRow,
ProvidersTableRowsInput,
} from "@/types/providers-table";
import { SCAN_TRIGGER, ScanProps } from "@/types/scans";
import { SCAN_TRIGGER, ScanProps, ScanScheduleSummary } from "@/types/scans";
import { ScheduleAttributes } from "@/types/schedules";
const PROVIDERS_STATUS_MAPPING = [
{
@@ -127,13 +134,32 @@ const buildScheduledProviderIds = (scans: ScanProps[]): Set<string> => {
return scheduled;
};
// A schedule is backed by the Provider row itself, so its `/schedules` entry
// exists before the first scheduled Scan is materialized — only enabled,
// configured ones carry a displayable cadence summary.
const buildProviderScheduleSummaryFor = (
attributes: ScheduleAttributes | undefined,
now: Date,
): ScanScheduleSummary | undefined =>
attributes && attributes.scan_enabled && isScheduleConfigured(attributes)
? buildProviderScheduleSummary(attributes, now)
: undefined;
const enrichProviders = (
providersResponse?: ProvidersApiResponse,
scheduledProviderIds?: Set<string>,
providersResponse: ProvidersApiResponse | undefined,
scanScheduledProviderIds: Set<string>,
schedulesByProviderId: Record<string, ScheduleAttributes>,
): ProvidersProviderRow[] => {
const providerGroupLookup = createProviderGroupLookup(providersResponse);
const now = new Date();
return (providersResponse?.data ?? []).map((provider) => ({
return (providersResponse?.data ?? []).map((provider) => {
const scheduleSummary = buildProviderScheduleSummaryFor(
schedulesByProviderId[provider.id],
now,
);
return {
...provider,
rowType: PROVIDERS_ROW_TYPE.PROVIDER,
groupNames:
@@ -141,8 +167,13 @@ const enrichProviders = (
(providerGroup: { id: string }) =>
providerGroupLookup.get(providerGroup.id) ?? "Unknown Group",
) ?? [],
hasSchedule: scheduledProviderIds?.has(provider.id) ?? false,
}));
// A fired scheduled scan OR a configured schedule that hasn't fired yet.
hasSchedule:
scanScheduledProviderIds.has(provider.id) ||
scheduleSummary !== undefined,
scheduleSummary,
};
});
};
const createOrganizationRow = ({
@@ -453,6 +484,7 @@ export async function loadProvidersAccountsViewData({
providersResponse,
allProvidersResponse,
scansResponse,
schedulesResponse,
organizationsResponse,
organizationUnitsResponse,
] = await Promise.all([
@@ -468,7 +500,7 @@ export async function loadProvidersAccountsViewData({
// Unfiltered fetch for ProviderTypeSelector — only needs distinct types;
// TODO: Replace with a dedicated lightweight endpoint when available.
resolveActionResult(getAllProviders()),
// Fetch active scheduled scans to determine daily schedule per provider
// Fetch active scheduled scans to flag providers whose schedule has fired.
resolveActionResult(
getScans({
pageSize: 500,
@@ -478,6 +510,9 @@ export async function loadProvidersAccountsViewData({
},
}),
),
// Fetch configured schedules to also flag providers whose schedule has not
// fired yet (best-effort: absent in OSS, where the helper yields no ids).
resolveActionResult(getSchedules()),
isCloud
? listOrganizationsSafe()
: Promise.resolve(emptyOrganizationsResponse),
@@ -486,13 +521,18 @@ export async function loadProvidersAccountsViewData({
: Promise.resolve(emptyOrganizationUnitsResponse),
]);
const scheduledProviderIds = buildScheduledProviderIds(
const scanScheduledProviderIds = buildScheduledProviderIds(
scansResponse?.data ?? [],
);
const schedulesByProviderId = buildSchedulesByProviderId(schedulesResponse);
const orgs = organizationsResponse?.data ?? [];
const ous = organizationUnitsResponse?.data ?? [];
const providers = enrichProviders(providersResponse, scheduledProviderIds);
const providers = enrichProviders(
providersResponse,
scanScheduledProviderIds,
schedulesByProviderId,
);
const rows = buildProvidersTableRows({
isCloud,
+166 -4
View File
@@ -3,9 +3,12 @@ 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 { PageReady } from "@/components/onboarding";
import {
appendPendingScheduleRowsToPage,
getProviderIdsFromScans,
getScanJobsTab,
getScanJobsTabFilters,
getScanJobsUserFilters,
@@ -15,14 +18,43 @@ 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 {
buildProviderScheduleSummary,
buildSchedulesByProviderId,
isScheduleConfigured,
} from "@/lib/schedules";
import {
ProviderProps,
SCAN_JOBS_TAB,
SCAN_TRIGGER,
ScanProps,
SearchParamsProps,
} from "@/types";
const ACTIVE_SCAN_COUNT_PAGE_SIZE = 1;
// Pending schedule rows are derived from provider schedules, but must honor the
// same provider filters as real scan rows. Keep these filter keys typed locally
// without narrowing the global SearchParamsProps shape used by Next pages.
const PENDING_ROW_PROVIDER_FILTER = {
PROVIDER_UID_IN: "provider_uid__in",
PROVIDER_UID: "provider_uid",
PROVIDER_TYPE_IN: "provider_type__in",
PROVIDER_TYPE: "provider_type",
} as const;
type PendingRowProviderFilter =
(typeof PENDING_ROW_PROVIDER_FILTER)[keyof typeof PENDING_ROW_PROVIDER_FILTER];
type PendingRowProviderFilterParam = `filter[${PendingRowProviderFilter}]`;
const PROVIDER_UID_FILTER_KEYS = [
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_UID_IN}]`,
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_UID}]`,
] as const satisfies ReadonlyArray<PendingRowProviderFilterParam>;
const PROVIDER_TYPE_FILTER_KEYS = [
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_TYPE_IN}]`,
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_TYPE}]`,
] as const satisfies ReadonlyArray<PendingRowProviderFilterParam>;
const getFilterSearchQuery = (
filters: Record<string, string | string[]>,
@@ -33,6 +65,47 @@ 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);
};
const getFirstSearchParam = (
searchParams: SearchParamsProps,
keys: ReadonlyArray<PendingRowProviderFilterParam>,
): string | string[] | undefined => {
for (const key of keys) {
const value = searchParams[key];
if (value !== undefined) return value;
}
return undefined;
};
/** Applies the table's provider filters to synthetic pending-schedule rows. */
const filterProvidersForPendingRows = (
providers: ProviderProps[],
searchParams: SearchParamsProps,
): ProviderProps[] => {
const uids = parseCsvParam(
getFirstSearchParam(searchParams, PROVIDER_UID_FILTER_KEYS),
);
const types = parseCsvParam(
getFirstSearchParam(searchParams, PROVIDER_TYPE_FILTER_KEYS),
);
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> => {
@@ -53,6 +126,40 @@ const getActiveScanCount = async (
return scansData && "meta" in scansData ? scansData.meta.pagination.count : 0;
};
/**
* A provider can already have a real scheduled scan on a different page.
* Current-page rows are not enough to decide whether a schedule needs a
* synthetic Pending row, so fetch all scheduled scan provider ids when the
* backend paginated result is larger than the current slice.
*/
const getCoveredScheduledProviderIds = async ({
currentScans,
realScanCount,
query,
filters,
}: {
currentScans: ScanProps[];
realScanCount: number;
query: string;
filters: Record<string, string | string[]>;
}): Promise<Set<string>> => {
if (realScanCount === 0 || currentScans.length === realScanCount) {
return getProviderIdsFromScans(currentScans);
}
const allScheduledScansData = await getScans({
query,
page: 1,
pageSize: realScanCount,
filters,
include: "provider",
});
return getProviderIdsFromScans(
(allScheduledScansData?.data ?? []) as ScanProps[],
);
};
export default async function Scans({
searchParams,
}: {
@@ -123,7 +230,10 @@ export default async function Scans({
/>
}
>
<SSRDataTableScans searchParams={resolvedSearchParams} />
<SSRDataTableScans
searchParams={resolvedSearchParams}
providers={providers}
/>
</Suspense>
</ScansPageShell>
)}
@@ -133,8 +243,10 @@ export default async function Scans({
const SSRDataTableScans = async ({
searchParams,
providers,
}: {
searchParams: SearchParamsProps;
providers: ProviderProps[];
}) => {
const tab = getScanJobsTab(searchParams.tab);
@@ -170,7 +282,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;
@@ -191,10 +303,60 @@ 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;
// Schedules are keyed by provider id so real scheduled scan rows can display
// cadence/next-run info, and schedule-only providers can become Pending rows.
const schedulesByProviderId = buildSchedulesByProviderId(schedulesResult);
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;
return {
...scan,
providerSchedule: buildProviderScheduleSummary(schedule, new Date()),
};
});
let tableData = scansWithSchedule;
let tableMeta = meta;
if (tab === SCAN_JOBS_TAB.SCHEDULED) {
// The backend paginates real scans only. Pending schedule rows are generated
// client-side, so reconcile both sources before passing data/meta to the table.
const coveredProviderIds = await getCoveredScheduledProviderIds({
currentScans: scansWithSchedule,
realScanCount: meta?.pagination?.count ?? scansWithSchedule.length,
query,
filters,
});
const scheduledTable = appendPendingScheduleRowsToPage({
scans: scansWithSchedule,
meta,
page,
pageSize,
providers: filterProvidersForPendingRows(providers, searchParams),
schedulesByProviderId,
coveredProviderIds,
now: new Date(),
});
tableData = scheduledTable.data;
tableMeta = scheduledTable.meta;
}
return (
<ScanJobsTable
data={expandedScansData}
meta={meta}
data={tableData}
meta={tableMeta}
tab={tab}
hasFilters={hasUserFilters}
/>
+16 -12
View File
@@ -1,25 +1,29 @@
"use client";
import Link from "next/link";
import { Button } from "@/components/shadcn";
import { StackedCell } from "@/components/shadcn";
import { formatLocalTimeWithZone } from "@/lib/date-utils";
import type { ScanScheduleSummary } from "@/types/scans";
interface LinkToScansProps {
hasSchedule: boolean;
providerUid?: string;
schedule?: ScanScheduleSummary;
}
export const LinkToScans = ({ hasSchedule, providerUid }: LinkToScansProps) => {
// Matches the scans table Schedule column: cadence on top, next-run local time
// underneath. Falls back to a plain label when the cadence is unknown.
export const LinkToScans = ({ hasSchedule, schedule }: LinkToScansProps) => {
if (schedule) {
return (
<StackedCell
primary={schedule.cadence ?? schedule.summary}
secondary={formatLocalTimeWithZone(schedule.nextScanAt ?? null)}
/>
);
}
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>
</Button>
</div>
);
};
@@ -1,8 +1,9 @@
import { act, render, waitFor } from "@testing-library/react";
import { act, render, screen, waitFor } from "@testing-library/react";
import type { ComponentProps } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useOrgSetupStore } from "@/store/organizations/store";
import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules";
import { OrgLaunchScan } from "./org-launch-scan";
@@ -76,4 +77,72 @@ describe("OrgLaunchScan", () => {
expect(toastPayload.action).toBeDefined();
expect(toastPayload.action.props.children.props.href).toBe("/scans");
});
it("uses a single manual scan when schedules are unavailable", async () => {
// Given
launchOrganizationScansMock.mockResolvedValue({ successCount: 1 });
const onFooterChange = vi.fn();
render(
<OrgLaunchScan
onClose={vi.fn()}
onBack={vi.fn()}
onFooterChange={onFooterChange}
capability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
/>,
);
// Then
expect(
screen.getByText(/scheduled scans are not available for trial accounts/i),
).toBeInTheDocument();
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
// When
await waitFor(() => {
expect(onFooterChange).toHaveBeenCalled();
});
const footerConfig = onFooterChange.mock.calls.at(-1)?.[0];
await act(async () => {
footerConfig.onAction?.();
});
// Then
await waitFor(() => {
expect(launchOrganizationScansMock).toHaveBeenCalledTimes(1);
});
expect(launchOrganizationScansMock).toHaveBeenCalledWith(
["provider-1"],
"single",
);
});
it("blocks manual scans when the trial scan limit is reached", async () => {
// Given
const onFooterChange = vi.fn();
render(
<OrgLaunchScan
onClose={vi.fn()}
onBack={vi.fn()}
onFooterChange={onFooterChange}
capability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
isScanLimitReached
/>,
);
// When
await waitFor(() => {
expect(onFooterChange).toHaveBeenCalled();
});
const footerConfig = onFooterChange.mock.calls.at(-1)?.[0];
await act(async () => {
footerConfig.onAction?.();
});
// Then
expect(screen.getByText(/reached your scan limit/i)).toBeInTheDocument();
expect(footerConfig.actionDisabled).toBe(true);
expect(launchOrganizationScansMock).not.toHaveBeenCalled();
});
});
@@ -21,12 +21,23 @@ import { Spinner } from "@/components/shadcn/spinner/spinner";
import { TreeStatusIcon } from "@/components/shadcn/tree-view/tree-status-icon";
import { ToastAction, useToast } from "@/components/ui";
import { useOrgSetupStore } from "@/store/organizations/store";
import {
SCAN_SCHEDULE_CAPABILITY,
type ScanScheduleCapability,
} from "@/types/schedules";
import { TREE_ITEM_STATUS } from "@/types/tree";
interface OrgLaunchScanProps {
onClose: () => void;
onBack: () => void;
onFooterChange: (config: WizardFooterConfig) => void;
/**
* Schedule capability override. Prowler Cloud passes MANUAL_ONLY for trial
* tenants so organization onboarding cannot create recurring schedules.
*/
capability?: ScanScheduleCapability;
/** Cloud-only manual scan quota signal. */
isScanLimitReached?: boolean;
}
const SCAN_SCHEDULE = {
@@ -40,6 +51,8 @@ export function OrgLaunchScan({
onClose,
onBack,
onFooterChange,
capability,
isScanLimitReached = false,
}: OrgLaunchScanProps) {
const router = useRouter();
const { toast } = useToast();
@@ -51,13 +64,21 @@ export function OrgLaunchScan({
SCAN_SCHEDULE.DAILY,
);
const launchActionRef = useRef<() => void>(() => {});
const isManualOnly = capability === SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY;
const effectiveScheduleOption = isManualOnly
? SCAN_SCHEDULE.SINGLE
: scheduleOption;
const handleLaunchScan = async () => {
if (isManualOnly && isScanLimitReached) {
return;
}
setIsLaunching(true);
const result = await launchOrganizationScans(
createdProviderIds,
scheduleOption,
effectiveScheduleOption,
);
const successCount = result.successCount;
@@ -69,7 +90,7 @@ export function OrgLaunchScan({
toast({
title: "Scan Launched",
description:
scheduleOption === SCAN_SCHEDULE.DAILY
effectiveScheduleOption === SCAN_SCHEDULE.DAILY
? `Daily scan scheduled for ${successCount} account${successCount !== 1 ? "s" : ""}.`
: `Single scan launched for ${successCount} account${successCount !== 1 ? "s" : ""}.`,
action: (
@@ -92,13 +113,23 @@ export function OrgLaunchScan({
onBack,
showAction: true,
actionLabel: "Launch scan",
actionDisabled: isLaunching || createdProviderIds.length === 0,
actionDisabled:
isLaunching ||
createdProviderIds.length === 0 ||
(isManualOnly && isScanLimitReached),
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
onAction: () => {
launchActionRef.current();
},
});
}, [createdProviderIds.length, isLaunching, onBack, onFooterChange]);
}, [
createdProviderIds.length,
isLaunching,
isManualOnly,
isScanLimitReached,
onBack,
onFooterChange,
]);
return (
<div className="flex min-h-0 flex-1 flex-col gap-8">
@@ -146,6 +177,20 @@ export function OrgLaunchScan({
</p>
)}
{isManualOnly ? (
<div className="flex flex-col gap-3">
<p className="text-text-neutral-secondary text-sm">
Scheduled scans are not available for trial accounts. These
accounts will run a one-time manual scan now.
</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>
) : (
<div className="flex flex-col gap-4">
<p className="text-text-neutral-secondary text-sm">
Select a Prowler scan schedule for these accounts.
@@ -170,6 +215,7 @@ export function OrgLaunchScan({
</SelectContent>
</Select>
</div>
)}
</div>
)}
</div>
@@ -29,6 +29,7 @@ import {
} from "@/lib/tours/use-driver-tour";
import type { FilterOption, MetaDataProps, ProviderProps } from "@/types";
import type { ProvidersTableRow } from "@/types/providers-table";
import type { ScanScheduleCapability } from "@/types/schedules";
const addProviderFlow = getFlowById("add-provider")!;
@@ -51,6 +52,9 @@ interface ProvidersAccountsViewProps {
metadata?: MetaDataProps;
providers: ProviderProps[];
rows: ProvidersTableRow[];
/** Cloud overlay seam for provider-creation scan launch. */
scanScheduleCapability?: ScanScheduleCapability;
isScanLimitReached?: boolean;
}
export function ProvidersAccountsView({
@@ -59,6 +63,8 @@ export function ProvidersAccountsView({
metadata,
providers,
rows,
scanScheduleCapability,
isScanLimitReached,
}: ProvidersAccountsViewProps) {
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -157,6 +163,8 @@ export function ProvidersAccountsView({
initialData={providerWizardInitialData}
orgInitialData={orgWizardInitialData}
refreshOnClose={false}
scanScheduleCapability={scanScheduleCapability}
isScanLimitReached={isScanLimitReached}
/>
</>
);
@@ -1,18 +1,13 @@
"use client";
import { ColumnDef, Row, RowSelectionState } from "@tanstack/react-table";
import {
Building2,
FolderTree,
ShieldAlert,
ShieldCheck,
ShieldOff,
} from "lucide-react";
import { Building2, FolderTree } from "lucide-react";
import type {
OrgWizardInitialData,
ProviderWizardInitialData,
} from "@/components/providers/wizard/types";
import { Badge } from "@/components/shadcn";
import { Checkbox } from "@/components/shadcn/checkbox/checkbox";
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import { DateWithTime, EntityInfo } from "@/components/ui/entities";
@@ -47,27 +42,24 @@ const OrganizationIcon = ({ groupKind }: { groupKind: string }) => {
const ProviderStatusCell = ({ connected }: { connected: boolean | null }) => {
if (connected === true) {
return (
<div className="text-system-success flex items-center gap-2 text-sm whitespace-nowrap">
<ShieldCheck className="size-4 shrink-0" />
<span>Connected</span>
</div>
<Badge variant="success" className="text-sm">
Connected
</Badge>
);
}
if (connected === false) {
return (
<div className="text-text-error-primary flex items-center gap-2 text-sm whitespace-nowrap">
<ShieldAlert className="size-4 shrink-0" />
<span>Connection failed</span>
</div>
<Badge variant="error" className="text-sm">
Connection failed
</Badge>
);
}
return (
<div className="text-text-neutral-secondary flex items-center gap-2 text-sm whitespace-nowrap">
<ShieldOff className="size-4 shrink-0" />
<span>Not connected</span>
</div>
<Badge variant="tag" className="text-text-neutral-secondary text-sm">
Not connected
</Badge>
);
};
@@ -259,7 +251,7 @@ export function getColumnProviders(
return (
<LinkToScans
hasSchedule={row.original.hasSchedule}
providerUid={row.original.attributes.uid}
schedule={row.original.scheduleSummary}
/>
);
},
@@ -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"}
@@ -15,6 +15,7 @@ import {
PROVIDER_WIZARD_MODE,
PROVIDER_WIZARD_STEP,
} from "@/types/provider-wizard";
import type { ScanScheduleCapability } from "@/types/schedules";
import { useProviderWizardController } from "./hooks/use-provider-wizard-controller";
import {
@@ -40,6 +41,10 @@ interface ProviderWizardModalProps {
initialData?: ProviderWizardInitialData;
orgInitialData?: OrgWizardInitialData;
refreshOnClose?: boolean;
/** Cloud overlay seam; defaults to the environment-resolved capability. */
scanScheduleCapability?: ScanScheduleCapability;
/** Cloud-only manual scan quota signal. */
isScanLimitReached?: boolean;
}
export function ProviderWizardModal({
@@ -48,6 +53,8 @@ export function ProviderWizardModal({
initialData,
orgInitialData,
refreshOnClose,
scanScheduleCapability,
isScanLimitReached,
}: ProviderWizardModalProps) {
const {
backToProviderFlow,
@@ -107,7 +114,13 @@ export function ProviderWizardModal({
</div>
</DialogHeader>
<div className="mt-6 flex min-h-0 flex-1 flex-col overflow-hidden lg:mt-8 lg:flex-row">
{/* Anchors the add-provider tour's final step to the wizard content and
footer, keeping the real form controls clickable under the overlay. */}
<div
data-tour-id="add-provider-wizard-body"
className="mt-6 flex min-h-0 flex-1 flex-col overflow-hidden lg:mt-8"
>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden lg:flex-row">
<div className="mb-4 box-border w-full shrink-0 lg:mb-0 lg:w-[328px]">
{isProviderFlow ? (
<WizardStepper
@@ -128,16 +141,12 @@ export function ProviderWizardModal({
/>
)}
</div>
<div aria-hidden className="hidden w-[100px] min-w-0 shrink lg:block" />
{/* Anchors the add-provider tour's final step to the whole right section —
the form inputs AND the footer — so the spotlight covers the Next button
instead of leaving it under the overlay. The popover still pins to the left
of this section, beside the inputs. */}
<div
data-tour-id="add-provider-wizard-body"
className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
>
aria-hidden
className="hidden w-[100px] min-w-0 shrink lg:block"
/>
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="relative min-h-0 flex-1 overflow-hidden">
<div
ref={containerRef}
@@ -167,12 +176,15 @@ export function ProviderWizardModal({
currentStep === PROVIDER_WIZARD_STEP.CREDENTIALS && (
<CredentialsStep
onNext={() => setCurrentStep(PROVIDER_WIZARD_STEP.TEST)}
onBack={() => setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT)}
onBack={() =>
setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT)
}
onFooterChange={setFooterConfig}
/>
)}
{isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.TEST && (
{isProviderFlow &&
currentStep === PROVIDER_WIZARD_STEP.TEST && (
<TestConnectionStep
onSuccess={handleTestSuccess}
onResetCredentials={() =>
@@ -188,12 +200,17 @@ export function ProviderWizardModal({
onBack={() => setCurrentStep(PROVIDER_WIZARD_STEP.TEST)}
onClose={handleClose}
onFooterChange={setFooterConfig}
capability={scanScheduleCapability}
isScanLimitReached={isScanLimitReached}
/>
)}
{!isProviderFlow && orgCurrentStep === ORG_WIZARD_STEP.SETUP && (
{!isProviderFlow &&
orgCurrentStep === ORG_WIZARD_STEP.SETUP && (
<OrgSetupForm
onBack={isOrgDirectEntry ? handleClose : backToProviderFlow}
onBack={
isOrgDirectEntry ? handleClose : backToProviderFlow
}
onClose={handleClose}
onNext={() => {
setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE);
@@ -230,13 +247,16 @@ export function ProviderWizardModal({
/>
)}
{!isProviderFlow && orgCurrentStep === ORG_WIZARD_STEP.LAUNCH && (
{!isProviderFlow &&
orgCurrentStep === ORG_WIZARD_STEP.LAUNCH && (
<OrgLaunchScan
onClose={handleClose}
onBack={() => {
setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE);
}}
onFooterChange={setFooterConfig}
capability={scanScheduleCapability}
isScanLimitReached={isScanLimitReached}
/>
)}
@@ -255,13 +275,13 @@ export function ProviderWizardModal({
</div>
)}
</div>
{/* Inside the highlighted right section so the tour spotlight covers the Next
button (no longer dimmed by the overlay). Aligned to the form width so Next
stays at the right edge. */}
</div>
</div>
{(resolvedFooterConfig.showBack ||
resolvedFooterConfig.showSecondaryAction ||
resolvedFooterConfig.showAction) && (
<div className="mt-8 w-full pt-6 lg:ml-auto lg:max-w-[620px] xl:max-w-[700px]">
<div className="mt-8 pt-6">
<div className="flex items-center justify-between">
<div>
{resolvedFooterConfig.showBack && (
@@ -335,7 +355,6 @@ export function ProviderWizardModal({
</div>
)}
</div>
</div>
</Modal>
);
}
@@ -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(),
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,28 +38,74 @@ vi.mock("@/components/ui", () => ({
}),
}));
describe("LaunchStep", () => {
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
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();
const seedConnectedProvider = () => {
useProviderWizardStore.setState({
providerId: "provider-1",
providerType: "gcp",
providerUid: "project-123",
mode: "add",
});
};
scheduleDailyMock.mockResolvedValue({ data: { id: "scan-1" } });
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();
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe("Prowler OSS (non-Cloud)", () => {
beforeEach(() => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } });
});
it("defaults to run now and locks schedule mode outside Cloud", async () => {
// Given
const onFooterChange = vi.fn();
seedConnectedProvider();
render(
<LaunchStep
onBack={vi.fn()}
onClose={vi.fn()}
onFooterChange={onFooterChange}
/>,
);
// Then
expect(screen.getByText("Account Connected!")).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "Run now" })).toBeChecked();
expect(
screen.getByRole("radio", { name: "On a schedule" }),
).toBeDisabled();
expect(
screen.queryByRole("combobox", { name: /repeats/i }),
).not.toBeInTheDocument();
await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe("Launch scan");
});
it("launches only an on-demand scan and never creates a legacy daily schedule", async () => {
// Given
const onClose = vi.fn();
const onFooterChange = vi.fn();
seedConnectedProvider();
render(
<LaunchStep
@@ -57,34 +114,257 @@ describe("LaunchStep", () => {
onFooterChange={onFooterChange}
/>,
);
await waitFor(() => {
expect(onFooterChange).toHaveBeenCalled();
});
await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
// When
const initialFooterConfig = onFooterChange.mock.calls.at(-1)?.[0];
await act(async () => {
initialFooterConfig.onAction?.();
lastFooterConfig(onFooterChange)?.onAction?.();
});
// Then
await waitFor(() => {
expect(scheduleDailyMock).toHaveBeenCalledTimes(1);
await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalledTimes(1));
const sentFormData = scanOnDemandMock.mock.calls[0]?.[0] as FormData;
expect(sentFormData.get("providerId")).toBe("provider-1");
expect(scheduleDailyMock).not.toHaveBeenCalled();
expect(updateScheduleMock).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalledTimes(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(
describe("Prowler Cloud subscribed", () => {
beforeEach(() => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
updateScheduleMock.mockResolvedValue({ data: { id: "provider-1" } });
});
it("defaults to schedule mode 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}
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
/>,
);
// Then advanced cadence selector is enabled
expect(
screen.getByRole("radio", { name: "On a schedule" }),
).toBeChecked();
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({
title: "Scan Launched",
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.SCHEDULED}`,
);
});
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}
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
/>,
);
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();
const toastPayload = toastMock.mock.calls[0]?.[0];
expect(toastPayload.action.props.children.props.href).toBe(
`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`,
);
});
it("launches only an on-demand scan when run now is selected", async () => {
// Given
const user = userEvent.setup();
const onClose = vi.fn();
const onFooterChange = vi.fn();
seedConnectedProvider();
scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } });
render(
<LaunchStep
onBack={vi.fn()}
onClose={onClose}
onFooterChange={onFooterChange}
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
/>,
);
await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
// When
await user.click(
screen.getByRole("radio", {
name: "Run now",
}),
);
await waitFor(() =>
expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe(
"Launch scan",
),
);
await act(async () => {
lastFooterConfig(onFooterChange)?.onAction?.();
});
// Then
await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalledTimes(1));
expect(updateScheduleMock).not.toHaveBeenCalled();
expect(scheduleDailyMock).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalledTimes(1);
});
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}
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
/>,
);
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("defaults to run now, locks schedule mode, 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();
expect(screen.getByRole("radio", { name: "Run now" })).toBeChecked();
expect(
screen.getByRole("radio", { name: "On a schedule" }),
).toBeDisabled();
expect(
screen.getByText(/scheduled scans are not available/i),
).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,42 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import { scanOnDemand, scheduleDaily } from "@/actions/scans";
import { scanOnDemand } from "@/actions/scans";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shadcn/select/select";
SAVE_SCHEDULE_STATUS,
saveScheduleWithInitialScan,
} from "@/components/scans/schedule/save-schedule";
import { ScanScheduleFields } from "@/components/scans/schedule/scan-schedule-fields";
import { Field, FieldLabel } from "@/components/shadcn";
import {
RadioGroup,
RadioGroupItem,
} from "@/components/shadcn/radio-group/radio-group";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { TreeStatusIcon } from "@/components/shadcn/tree-view/tree-status-icon";
import {
CloudFeatureBadge,
CloudFeatureBadgeLink,
} from "@/components/shared/cloud-feature-badge";
import { ToastAction, useToast } from "@/components/ui";
import { EntityInfo } from "@/components/ui/entities";
import {
getScanScheduleCapability,
getScheduleFormDefaults,
scheduleFormSchema,
} from "@/lib/schedules";
import { isCloud } from "@/lib/shared/env";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { SCAN_JOBS_TAB } from "@/types";
import {
SCAN_SCHEDULE_CAPABILITY,
type ScanScheduleCapability,
type ScheduleFormValues,
} from "@/types/schedules";
import { TREE_ITEM_STATUS } from "@/types/tree";
import {
@@ -23,51 +44,94 @@ import {
WizardFooterConfig,
} from "./footer-controls";
const SCAN_SCHEDULE = {
DAILY: "daily",
SINGLE: "single",
const LAUNCH_MODE = {
NOW: "now",
SCHEDULE: "schedule",
} as const;
type ScanScheduleOption = (typeof SCAN_SCHEDULE)[keyof typeof SCAN_SCHEDULE];
type LaunchMode = (typeof LAUNCH_MODE)[keyof typeof LAUNCH_MODE];
interface LaunchStepProps {
onBack: () => void;
onClose: () => void;
onFooterChange: (config: WizardFooterConfig) => void;
/**
* Schedule capability override. Absent in Prowler OSS (defaults to a
* Cloud-vs-non-Cloud decision). The prowler-cloud overlay computes a
* billing-aware capability and injects it here so trial/onboarding accounts
* are limited to manual scans.
*/
capability?: ScanScheduleCapability;
/**
* When true, the manual scan action is disabled (account scan quota reached).
* Cloud-only signal; never set in OSS.
*/
isScanLimitReached?: boolean;
}
export function LaunchStep({
onBack,
onClose,
onFooterChange,
capability: capabilityProp,
isScanLimitReached = false,
}: LaunchStepProps) {
const { toast } = useToast();
const { providerId } = useProviderWizardStore();
const { providerAlias, providerId, providerType, providerUid } =
useProviderWizardStore();
const capability = capabilityProp ?? getScanScheduleCapability(isCloud());
const isManualOnly = capability === SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY;
const isAdvanced = capability === SCAN_SCHEDULE_CAPABILITY.ADVANCED;
const [isLaunching, setIsLaunching] = useState(false);
const [scheduleOption, setScheduleOption] = useState<ScanScheduleOption>(
SCAN_SCHEDULE.DAILY,
const [mode, setMode] = useState<LaunchMode>(
isAdvanced ? LAUNCH_MODE.SCHEDULE : LAUNCH_MODE.NOW,
);
const launchActionRef = useRef<() => void>(() => {});
const form = useForm<ScheduleFormValues>({
resolver: zodResolver(scheduleFormSchema),
defaultValues: getScheduleFormDefaults(),
});
const handleLaunchScan = async () => {
if (!providerId) {
const isScheduleMode = isAdvanced && mode === LAUNCH_MODE.SCHEDULE;
const isLimitBlocked = mode === LAUNCH_MODE.NOW && isScanLimitReached;
const isActionBlocked = isLaunching || !providerId || isLimitBlocked;
const launchInitialScan = useWatch({
control: form.control,
name: "launchInitialScan",
});
const actionLabel = (() => {
if (!isScheduleMode) {
return isLaunching ? "Launching scan..." : "Launch scan";
}
if (isLaunching) {
return launchInitialScan ? "Saving and launching..." : "Saving...";
}
return launchInitialScan ? "Save and launch scan" : "Save";
})();
const launchOnDemandScan = async (): Promise<{ error?: unknown } | null> => {
if (!providerId) return null;
const formData = new FormData();
formData.set("providerId", providerId);
return scanOnDemand(formData);
};
const handleManualScan = async () => {
if (isScanLimitReached) {
return;
}
setIsLaunching(true);
const formData = new FormData();
formData.set("providerId", providerId);
const result =
scheduleOption === SCAN_SCHEDULE.DAILY
? await scheduleDaily(formData)
: await scanOnDemand(formData);
const scanResult = await launchOnDemandScan();
if (result?.error) {
if (scanResult?.error) {
setIsLaunching(false);
toast({
variant: "destructive",
title: "Unable to launch scan",
description: String(result.error),
description: String(scanResult.error),
});
return;
}
@@ -75,11 +139,8 @@ export function LaunchStep({
setIsLaunching(false);
onClose();
toast({
title: "Scan Launched",
description:
scheduleOption === SCAN_SCHEDULE.DAILY
? "Daily scan scheduled successfully."
: "Single scan launched successfully.",
title: "Scan launched",
description: "The scan was launched successfully.",
action: (
<ToastAction altText="Go to scans" asChild>
<Link href={`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`}>Go to scans</Link>
@@ -88,8 +149,69 @@ export function LaunchStep({
});
};
launchActionRef.current = () => {
void handleLaunchScan();
const handleSaveSchedule = form.handleSubmit(async (values) => {
if (!providerId) {
return;
}
setIsLaunching(true);
const result = await saveScheduleWithInitialScan({
providerId,
values,
useLegacyDaily: !isAdvanced,
});
setIsLaunching(false);
if (result.status === SAVE_SCHEDULE_STATUS.ERROR) {
toast({
variant: "destructive",
title: "Unable to save scan schedule",
description: result.message,
});
return;
}
onClose();
const launched = result.status === SAVE_SCHEDULE_STATUS.SAVED_AND_LAUNCHED;
const targetTab = launched ? SCAN_JOBS_TAB.ACTIVE : SCAN_JOBS_TAB.SCHEDULED;
const goToScans = (
<ToastAction altText="Go to scans" asChild>
<Link href={`/scans?tab=${targetTab}`}>Go to scans</Link>
</ToastAction>
);
if (result.status === SAVE_SCHEDULE_STATUS.SAVED_SCAN_FAILED) {
toast({
title: "Scan schedule saved",
description: `The schedule was saved, but the initial scan could not be launched: ${result.message}`,
action: goToScans,
});
return;
}
toast({
title: launched
? "Scan schedule saved and initial scan launched"
: "Scan schedule saved",
description: launched
? "The schedule was saved and the initial scan was launched."
: "The scan schedule was saved successfully.",
action: goToScans,
});
});
// Keep the latest action handler in a ref so the footer (synced via effect)
// always invokes the current closure without re-running on every render.
const actionRef = useRef<() => void>(() => {});
actionRef.current = () => {
if (!isScheduleMode) {
void handleManualScan();
return;
}
void handleSaveSchedule();
};
useEffect(() => {
@@ -99,21 +221,30 @@ export function LaunchStep({
backDisabled: isLaunching,
onBack,
showAction: true,
actionLabel: isLaunching ? "Launching scans..." : "Launch scan",
actionDisabled: isLaunching || !providerId,
actionLabel,
actionDisabled: isActionBlocked,
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
onAction: () => {
launchActionRef.current();
},
onAction: () => actionRef.current(),
});
}, [isLaunching, onBack, onFooterChange, providerId]);
}, [
isActionBlocked,
isLaunching,
actionLabel,
isScheduleMode,
launchInitialScan,
mode,
onBack,
onFooterChange,
]);
if (isLaunching) {
return (
<div className="flex min-h-[320px] items-center justify-center">
<div className="flex items-center gap-3 py-2">
<Spinner className="size-6" />
<p className="text-sm font-medium">Launching scans...</p>
<p className="text-sm font-medium">
{!isScheduleMode ? "Launching scan..." : "Saving scan schedule..."}
</p>
</div>
</div>
);
@@ -121,13 +252,21 @@ export function LaunchStep({
return (
<div className="flex min-h-0 flex-1 flex-col gap-6">
{(providerId || providerUid) && (
<EntityInfo
cloudProvider={providerType ?? undefined}
entityAlias={providerAlias ?? providerUid ?? providerId ?? undefined}
entityId={providerUid ?? providerId ?? undefined}
/>
)}
<div className="flex items-center gap-3">
<TreeStatusIcon status={TREE_ITEM_STATUS.SUCCESS} className="size-6" />
<h3 className="text-sm font-semibold">Connection validated!</h3>
<h3 className="text-sm font-semibold">Account Connected!</h3>
</div>
<p className="text-text-neutral-secondary text-sm">
Choose how you want to launch scans for this provider.
Your account is connected to Prowler and ready to Scan!
</p>
{!providerId && (
@@ -136,28 +275,57 @@ export function LaunchStep({
</p>
)}
<div className="flex flex-col gap-4">
<p className="text-text-neutral-secondary text-sm">Scan schedule</p>
<Select
value={scheduleOption}
onValueChange={(value) =>
setScheduleOption(value as ScanScheduleOption)
}
disabled={isLaunching || !providerId}
<Field>
<FieldLabel>Mode</FieldLabel>
<RadioGroup
value={mode}
onValueChange={(value) => setMode(value as LaunchMode)}
className="flex flex-row flex-wrap gap-6"
aria-label="Scan mode"
>
<SelectTrigger className="w-full max-w-[376px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={SCAN_SCHEDULE.DAILY}>
Scan Daily (every 24 hours)
</SelectItem>
<SelectItem value={SCAN_SCHEDULE.SINGLE}>
Run a single scan (no recurring schedule)
</SelectItem>
</SelectContent>
</Select>
</div>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value={LAUNCH_MODE.NOW} aria-label="Run now" />
Run now
</label>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem
value={LAUNCH_MODE.SCHEDULE}
aria-label="On a schedule"
disabled={!isAdvanced}
/>
On a schedule
{!isAdvanced &&
(isManualOnly ? (
<CloudFeatureBadge label="Requires subscription" size="sm" />
) : (
<CloudFeatureBadgeLink size="sm" />
))}
</label>
</RadioGroup>
</Field>
{!isAdvanced && (
<p className="text-text-neutral-secondary text-sm">
Scheduled scans are not available for this account. Run now to get
immediate findings.
</p>
)}
{isLimitBlocked && (
<p className="text-text-error-primary text-sm">
You have reached your scan limit, so additional scans are not
available right now.
</p>
)}
{isScheduleMode && (
<ScanScheduleFields
form={form}
disabled={isLaunching || !providerId}
showLaunchInitialScan
showNextScheduledCopy
/>
)}
</div>
);
}
+150 -3
View File
@@ -3,13 +3,35 @@ import userEvent from "@testing-library/user-event";
import type { ComponentProps } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { refreshMock, scanOnDemandMock, searchParamsValue, toastMock } =
vi.hoisted(() => ({
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();
});
});
});
+244 -13
View File
@@ -1,23 +1,48 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { CloudCog, Rocket } from "lucide-react";
import { CloudCog, Loader2, Rocket } from "lucide-react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useForm } from "react-hook-form";
import { useRef, useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import { z } from "zod";
import { scanOnDemand } from "@/actions/scans";
import { getSchedule } from "@/actions/schedules";
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
import { Field, FieldError, FieldLabel, Input } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import {
RadioGroup,
RadioGroupItem,
} from "@/components/shadcn/radio-group/radio-group";
import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge";
import { FormButtons } from "@/components/ui/form";
import { toast, ToastAction } from "@/components/ui/toast";
import {
getScanScheduleCapability,
getScheduleFormDefaults,
getScheduleFormValues,
scheduleFormSchema,
} from "@/lib/schedules";
import { isCloud } from "@/lib/shared/env";
import { SCAN_JOBS_TAB } from "@/types";
import type { ProviderProps } from "@/types/providers";
import {
SCAN_SCHEDULE_CAPABILITY,
type ScanScheduleCapability,
type ScheduleApiResponse,
type ScheduleFormValues,
} from "@/types/schedules";
import { scanAliasSchema } from "./scan-alias-validation";
import { getScanJobsTab } from "./scans.utils";
import {
SAVE_SCHEDULE_STATUS,
saveScheduleWithInitialScan,
} from "./schedule/save-schedule";
import { ScanScheduleFields } from "./schedule/scan-schedule-fields";
const launchScanSchema = z.object({
providerId: z.string().min(1, "Select a provider to launch a scan."),
@@ -26,33 +51,112 @@ const launchScanSchema = z.object({
type LaunchScanFormValues = z.infer<typeof launchScanSchema>;
const LAUNCH_MODE = {
NOW: "now",
SCHEDULE: "schedule",
} as const;
type LaunchMode = (typeof LAUNCH_MODE)[keyof typeof LAUNCH_MODE];
const SCHEDULE_LOAD_STATE = {
IDLE: "idle",
LOADING: "loading",
LOADED: "loaded",
ERROR: "error",
} as const;
type ScheduleLoadState =
(typeof SCHEDULE_LOAD_STATE)[keyof typeof SCHEDULE_LOAD_STATE];
interface LaunchScanModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
providers: ProviderProps[];
/** Cloud overlay seam; defaults to the environment-resolved capability. */
capability?: ScanScheduleCapability;
isScanLimitReached?: boolean;
}
interface LaunchScanFormProps {
providers: ProviderProps[];
onClose: () => void;
capability: ScanScheduleCapability;
isScanLimitReached: boolean;
}
function LaunchScanForm({ providers, onClose }: LaunchScanFormProps) {
function LaunchScanForm({
providers,
onClose,
capability,
isScanLimitReached,
}: LaunchScanFormProps) {
const router = useRouter();
const searchParams = useSearchParams();
const form = useForm<LaunchScanFormValues>({
resolver: zodResolver(launchScanSchema),
defaultValues: { providerId: "", scanAlias: "" },
});
const scheduleForm = useForm<ScheduleFormValues>({
resolver: zodResolver(scheduleFormSchema),
defaultValues: getScheduleFormDefaults(),
});
const [mode, setMode] = useState<LaunchMode>(LAUNCH_MODE.NOW);
const [scheduleLoad, setScheduleLoad] = useState<ScheduleLoadState>(
SCHEDULE_LOAD_STATE.IDLE,
);
// Guards against out-of-order responses when switching providers quickly.
const requestedProviderRef = useRef<string>("");
const providerId = form.watch("providerId");
const isAdvanced = capability === SCAN_SCHEDULE_CAPABILITY.ADVANCED;
const isManualOnly = capability === SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY;
const isScheduleMode = mode === LAUNCH_MODE.SCHEDULE;
// useWatch, not form.watch: form.watch re-renders are dropped by React Compiler memoization.
const providerId = useWatch({ control: form.control, name: "providerId" });
const activeTab = getScanJobsTab(searchParams.get("tab") ?? undefined);
const shouldShowActiveTabAction = activeTab !== SCAN_JOBS_TAB.ACTIVE;
const disconnectedProviderIds = providers
.filter((provider) => provider.attributes.connection.connected !== true)
.map((provider) => provider.id);
const onSubmit = form.handleSubmit(async ({ providerId, scanAlias }) => {
const loadSchedule = async (id: string) => {
requestedProviderRef.current = id;
if (!id) {
setScheduleLoad(SCHEDULE_LOAD_STATE.IDLE);
return;
}
setScheduleLoad(SCHEDULE_LOAD_STATE.LOADING);
const response = (await getSchedule(id)) as
| ScheduleApiResponse
| { error?: string };
if (requestedProviderRef.current !== id) return;
if (!response || ("error" in response && response.error)) {
setScheduleLoad(SCHEDULE_LOAD_STATE.ERROR);
return;
}
scheduleForm.reset(
getScheduleFormValues(
"data" in response ? response.data?.attributes : null,
),
);
setScheduleLoad(SCHEDULE_LOAD_STATE.LOADED);
};
const handleProviderChange = (id: string) => {
form.setValue("providerId", id, { shouldValidate: true });
if (isScheduleMode) void loadSchedule(id);
};
const handleModeChange = (nextMode: string) => {
setMode(nextMode as LaunchMode);
if (nextMode === LAUNCH_MODE.SCHEDULE) void loadSchedule(providerId);
};
const launchNow = form.handleSubmit(async ({ providerId, scanAlias }) => {
const formData = new FormData();
formData.set("providerId", providerId);
const trimmedAlias = scanAlias?.trim();
@@ -87,13 +191,66 @@ 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">
// min-w-0: let this dialog grid item shrink so a long provider UID truncates instead of widening the modal
<form onSubmit={onSubmit} className="flex min-w-0 flex-col gap-8">
<div className="flex items-center gap-2">
<CloudCog className="text-text-neutral-secondary size-4" />
<span className="text-text-neutral-secondary text-sm">
@@ -108,9 +265,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,6 +273,40 @@ function LaunchScanForm({ providers, onClose }: LaunchScanFormProps) {
{providerError && <FieldError>{providerError}</FieldError>}
</Field>
{!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
@@ -127,14 +316,50 @@ function LaunchScanForm({ providers, onClose }: LaunchScanFormProps) {
/>
{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
/>
)}
{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 +370,11 @@ export function LaunchScanModal({
open,
onOpenChange,
providers,
capability,
isScanLimitReached = false,
}: LaunchScanModalProps) {
const resolvedCapability = capability ?? getScanScheduleCapability(isCloud());
return (
<Modal
open={open}
@@ -157,6 +386,8 @@ export function LaunchScanModal({
<LaunchScanForm
providers={providers}
onClose={() => onOpenChange(false)}
capability={resolvedCapability}
isScanLimitReached={isScanLimitReached}
/>
</Modal>
);
+8
View File
@@ -21,6 +21,7 @@ import { buildViewFirstScanTour } from "@/lib/tours/view-first-scan.tour";
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";
const viewFirstScanFlow = getFlowById("view-first-scan")!;
@@ -34,6 +35,9 @@ interface ScansPageShellProps {
hasManageScansPermission: boolean;
activeScanCount?: number;
children: ReactNode;
/** Cloud overlay seam for the launch-scan modal. */
scanScheduleCapability?: ScanScheduleCapability;
isScanLimitReached?: boolean;
}
export function ScansPageShell({
@@ -41,6 +45,8 @@ export function ScansPageShell({
hasManageScansPermission,
activeScanCount = 0,
children,
scanScheduleCapability,
isScanLimitReached,
}: ScansPageShellProps) {
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -165,6 +171,8 @@ export function ScansPageShell({
open={launchOpen}
onOpenChange={handleLaunchOpenChange}
providers={providers}
capability={scanScheduleCapability}
isScanLimitReached={isScanLimitReached}
/>
</div>
);
+236 -2
View File
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import {
type MetaDataProps,
type ProviderProps,
SCAN_JOBS_TAB,
type ScanAttributes,
type ScanProps,
@@ -8,6 +10,8 @@ import {
} from "@/types";
import {
appendPendingScheduleRowsToPage,
buildPendingScheduleRows,
formatScanDuration,
getScanAlias,
getScanFindingsSummary,
@@ -109,9 +113,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 +165,233 @@ describe("scans.utils", () => {
expect(getScanFindingsSummary(makeScan("x").attributes)).toBeNull();
});
});
describe("buildPendingScheduleRows", () => {
const now = new Date("2026-06-10T10:30:00Z");
const makeProvider = (id: string): ProviderProps => ({
id,
type: "providers",
attributes: {
provider: "aws",
uid: `uid-${id}`,
alias: `alias-${id}`,
status: "completed",
resources: 0,
connection: {
connected: true,
last_checked_at: "2026-06-10T10:00:00Z",
},
scanner_args: {
only_logs: false,
excluded_checks: [],
aws_retries_max_attempts: 3,
},
inserted_at: "2026-06-10T10:00:00Z",
updated_at: "2026-06-10T10:00:00Z",
created_by: {
object: "users",
id: "user-1",
},
},
relationships: {
secret: { data: null },
provider_groups: { meta: { count: 0 }, data: [] },
},
});
const makeScheduledScan = (id: string, providerId: string): ScanProps => ({
...makeScan("Scheduled Scan", "scheduled"),
id,
relationships: {
provider: { data: { type: "providers", id: providerId } },
task: { data: { type: "tasks", id: `task-${id}` } },
},
});
const makeMeta = ({
page,
pages,
count,
}: {
page: number;
pages: number;
count: number;
}): MetaDataProps => ({
pagination: {
page,
pages,
count,
itemsPerPage: [1, 2, 10],
},
version: "v1",
});
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("uses global covered providers when appending pending rows", () => {
// Given - p1 has a real scheduled scan on a previous page, p2 only has a configured schedule.
const result = appendPendingScheduleRowsToPage({
scans: [],
meta: makeMeta({ page: 2, pages: 1, count: 1 }),
page: 2,
pageSize: 1,
providers: [makeProvider("p1"), makeProvider("p2")],
schedulesByProviderId: {
p1: weeklySchedule,
p2: weeklySchedule,
},
coveredProviderIds: new Set(["p1"]),
now,
});
// Then - p1 is not duplicated as a synthetic pending row.
expect(result.data.map((scan) => scan.id)).toEqual(["pending-schedule-p2"]);
expect(result.meta?.pagination.count).toBe(2);
expect(result.meta?.pagination.pages).toBe(2);
});
it("keeps the last real page within page size and carries pending rows to the next page", () => {
// Given - three real scheduled scans and two pending schedules at page size 2.
const providers = ["p1", "p2", "p3", "p4", "p5"].map(makeProvider);
const schedulesByProviderId = Object.fromEntries(
providers.map((provider) => [provider.id, weeklySchedule]),
);
const lastRealPage = appendPendingScheduleRowsToPage({
scans: [makeScheduledScan("scan-3", "p3")],
meta: makeMeta({ page: 2, pages: 2, count: 3 }),
page: 2,
pageSize: 2,
providers,
schedulesByProviderId,
coveredProviderIds: new Set(["p1", "p2", "p3"]),
now,
});
const firstPendingOnlyPage = appendPendingScheduleRowsToPage({
scans: [],
meta: makeMeta({ page: 3, pages: 2, count: 3 }),
page: 3,
pageSize: 2,
providers,
schedulesByProviderId,
coveredProviderIds: new Set(["p1", "p2", "p3"]),
now,
});
// Then - page 2 gets one pending row, page 3 gets the remaining pending row.
expect(lastRealPage.data.map((scan) => scan.id)).toEqual([
"scan-3",
"pending-schedule-p4",
]);
expect(lastRealPage.meta?.pagination.count).toBe(5);
expect(lastRealPage.meta?.pagination.pages).toBe(3);
expect(firstPendingOnlyPage.data.map((scan) => scan.id)).toEqual([
"pending-schedule-p5",
]);
});
it("shows pending rows on an otherwise empty scheduled tab with coherent metadata", () => {
// Given
const result = appendPendingScheduleRowsToPage({
scans: [],
meta: makeMeta({ page: 1, pages: 0, count: 0 }),
page: 1,
pageSize: 10,
providers: [makeProvider("p1")],
schedulesByProviderId: { p1: weeklySchedule },
coveredProviderIds: new Set(),
now,
});
// Then
expect(result.data.map((scan) => scan.id)).toEqual(["pending-schedule-p1"]);
expect(result.meta?.pagination.count).toBe(1);
expect(result.meta?.pagination.pages).toBe(1);
expect(result.meta?.pagination.page).toBe(1);
});
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);
});
});
+146 -1
View File
@@ -1,5 +1,13 @@
import {
describeScheduleCadence,
getNextScheduledRunInTimezone,
getScheduleCadenceParts,
isScheduleConfigured,
} from "@/lib/schedules";
import {
DEFAULT_SCAN_JOBS_TAB,
type MetaDataProps,
type ProviderProps,
SCAN_JOBS_TAB,
SCAN_STATE,
SCAN_TRIGGER,
@@ -9,6 +17,7 @@ import {
type ScanProps,
type ScanState,
type ScanTrigger,
type ScheduleAttributes,
type SearchParamsProps,
} from "@/types";
@@ -136,7 +145,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 +188,142 @@ 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;
}
interface AppendPendingScheduleRowsToPageParams
extends BuildPendingScheduleRowsParams {
scans: ScanProps[];
meta?: MetaDataProps;
page: number;
pageSize: number;
}
/**
* 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,
},
},
];
});
}
export function getProviderIdsFromScans(scans: ScanProps[]): Set<string> {
return new Set(
scans
.map((scan) => scan.relationships?.provider?.data?.id)
.filter((id): id is string => Boolean(id)),
);
}
export function appendPendingScheduleRowsToPage({
scans,
meta,
page,
pageSize,
providers,
schedulesByProviderId,
coveredProviderIds,
now,
}: AppendPendingScheduleRowsToPageParams): {
data: ScanProps[];
meta?: MetaDataProps;
} {
// The API paginates real scheduled scans, while pending rows come from
// configured schedules without a scan yet. Reconcile both sources here so the
// rendered rows and pagination metadata describe the same combined list.
const pendingRows = buildPendingScheduleRows({
providers,
schedulesByProviderId,
coveredProviderIds,
now,
});
const safePageSize = Math.max(1, pageSize);
const realCount = meta?.pagination.count ?? scans.length;
const pendingStart = Math.max(0, (page - 1) * safePageSize - realCount);
const pendingSlots = Math.max(0, safePageSize - scans.length);
const pagePendingRows = pendingRows.slice(
pendingStart,
pendingStart + pendingSlots,
);
const combinedCount = realCount + pendingRows.length;
const combinedPages =
combinedCount === 0 ? 0 : Math.ceil(combinedCount / safePageSize);
return {
data: [...scans, ...pagePendingRows],
meta: meta
? {
...meta,
pagination: {
...meta.pagination,
page,
count: combinedCount,
pages: combinedPages,
},
}
: undefined,
};
}
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,76 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useForm } from "react-hook-form";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { getScheduleFormDefaults } from "@/lib/schedules";
import type { ScheduleFormValues } from "@/types/schedules";
import { ScanScheduleFields } from "./scan-schedule-fields";
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", {
configurable: true,
value: vi.fn(() => false),
});
Object.defineProperty(HTMLElement.prototype, "setPointerCapture", {
configurable: true,
value: vi.fn(),
});
Object.defineProperty(HTMLElement.prototype, "releasePointerCapture", {
configurable: true,
value: vi.fn(),
});
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: vi.fn(),
});
});
function ScheduleFieldsHarness() {
const form = useForm<ScheduleFormValues>({
defaultValues: getScheduleFormDefaults(),
});
return (
<ScanScheduleFields
form={form}
showNextScheduledCopy
canUseAdvancedSchedule
/>
);
}
function getHelperCopy(text: RegExp) {
return screen.getByText((_, element) => {
return (
element?.tagName.toLowerCase() === "p" &&
text.test(element.textContent ?? "")
);
});
}
describe("ScanScheduleFields", () => {
it("updates the helper copy when the cadence changes to interval", async () => {
// Given
const user = userEvent.setup();
render(<ScheduleFieldsHarness />);
expect(getHelperCopy(/Daily/)).toBeInTheDocument();
// When
await user.click(screen.getByRole("combobox", { name: /repeats/i }));
await user.click(screen.getByRole("option", { name: /every 48 hours/i }));
// Then
expect(getHelperCopy(/Every 48 hours/)).toBeInTheDocument();
expect(
screen.queryByText((_, element) => {
return (
element?.tagName.toLowerCase() === "p" &&
/Daily/.test(element.textContent ?? "")
);
}),
).not.toBeInTheDocument();
});
});
@@ -0,0 +1,301 @@
"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>
);
}
function getScheduleSummary({
frequency,
intervalHours,
dayOfWeek,
dayOfMonth,
}: Pick<
ScheduleFormValues,
"frequency" | "intervalHours" | "dayOfWeek" | "dayOfMonth"
>) {
switch (frequency) {
case SCHEDULE_FREQUENCY.INTERVAL:
return `Every ${intervalHours} hours`;
case SCHEDULE_FREQUENCY.WEEKLY:
return `Weekly on ${SCHEDULE_WEEKDAY_LABELS[dayOfWeek] ?? SCHEDULE_WEEKDAY_LABELS[0]}`;
case SCHEDULE_FREQUENCY.MONTHLY:
return `Monthly on day ${dayOfMonth}`;
default:
return "Daily";
}
}
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, hour, dayOfWeek, dayOfMonth, intervalHours] = useWatch({
control,
name: ["frequency", "hour", "dayOfWeek", "dayOfMonth", "intervalHours"],
});
const timezone = getBrowserTimezone();
const scheduleSummary = getScheduleSummary({
frequency,
intervalHours,
dayOfWeek,
dayOfMonth,
});
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">
{scheduleSummary}. 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
}
/>
);
}
@@ -1,14 +1,25 @@
import type { CellContext, HeaderContext } from "@tanstack/react-table";
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import type { ScanProps } from "@/types";
vi.mock("@/components/shadcn", () => ({
Badge: ({ children }: { children: React.ReactNode }) => (
<span>{children}</span>
),
Badge: ({ children }: { children: ReactNode }) => <span>{children}</span>,
Progress: () => <div />,
StackedCell: ({
primary,
secondary,
}: {
primary: ReactNode;
secondary?: ReactNode;
}) => (
<div>
<span>{primary}</span>
{secondary ? <span>{secondary}</span> : null}
</div>
),
}));
vi.mock("@/components/ui/entities", () => ({
@@ -148,6 +159,8 @@ describe("getScanJobsColumns", () => {
"account",
"scanInfo",
"scanSchedule",
"nextScan",
"lastScan",
"actions",
]);
});
+54 -25
View File
@@ -2,9 +2,10 @@
import type { ColumnDef } from "@tanstack/react-table";
import { DateWithTime } from "@/components/ui/entities";
import { StackedCell } from "@/components/shadcn";
import { DataTableColumnHeader } from "@/components/ui/table";
import { StatusBadge } from "@/components/ui/table/status-badge";
import { formatLocalDate, formatLocalTimeWithZone } from "@/lib/date-utils";
import { SCAN_JOBS_TAB, type ScanJobsTab, type ScanProps } from "@/types";
import { formatScanDuration } from "../scans.utils";
@@ -48,15 +49,59 @@ const getScanScheduleColumn = (title: string): ColumnDef<ScanProps> => ({
cell: ({ row }) => <ScheduleCell scan={row.original} />,
});
const getScheduleSummary = (scan: ScanProps) =>
scan.pendingSchedule ?? scan.providerSchedule;
const renderDateCell = (value: string | null) => {
const date = formatLocalDate(value);
if (!date) return <span>-</span>;
return (
<StackedCell primary={date} secondary={formatLocalTimeWithZone(value)} />
);
};
const scheduledScanScheduleColumn: ColumnDef<ScanProps> = {
id: "scanSchedule",
accessorFn: (row) => row.attributes.scheduled_at,
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Schedule" />
),
cell: ({ row }) => (
<DateWithTime dateTime={row.original.attributes.scheduled_at} showTime />
// Cadence on top, local fire time underneath.
cell: ({ row }) => {
const schedule = getScheduleSummary(row.original);
if (!schedule) return <span>-</span>;
return (
<StackedCell
primary={schedule.cadence ?? schedule.summary}
secondary={formatLocalTimeWithZone(
row.original.attributes.scheduled_at,
)}
/>
);
},
enableSorting: false,
};
const nextScanColumn: ColumnDef<ScanProps> = {
id: "nextScan",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Next Scan" />
),
// Real rows carry their fire time in scheduled_at; pending rows are
// synthesized with the server-computed next_scan_at in the same field.
cell: ({ row }) => renderDateCell(row.original.attributes.scheduled_at),
enableSorting: false,
};
const lastScanColumn: ColumnDef<ScanProps> = {
id: "lastScan",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Last Scan" />
),
cell: ({ row }) =>
renderDateCell(getScheduleSummary(row.original)?.lastScanAt ?? null),
enableSorting: false,
};
@@ -104,13 +149,10 @@ const activeColumns = (): ColumnDef<ScanProps>[] => [
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Launched" />
),
cell: ({ row }) => (
<DateWithTime
dateTime={
cell: ({ row }) =>
renderDateCell(
row.original.attributes.started_at ||
row.original.attributes.inserted_at
}
/>
row.original.attributes.inserted_at,
),
enableSorting: false,
},
@@ -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(
() => ({
const {
downloadScanZipMock,
getScheduleMock,
getTaskMock,
pushMock,
toastMock,
} = vi.hoisted(() => ({
downloadScanZipMock: vi.fn(),
getScheduleMock: vi.fn(),
getTaskMock: vi.fn(),
pushMock: vi.fn(),
toastMock: vi.fn(),
}),
);
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({
@@ -29,6 +34,10 @@ vi.mock("@/actions/task", () => ({
getTask: getTaskMock,
}));
vi.mock("@/actions/schedules", () => ({
getSchedule: getScheduleMock,
}));
vi.mock("@/lib/helper", () => ({
downloadScanZip: downloadScanZipMock,
}));
@@ -53,6 +62,26 @@ vi.mock("@/components/scans/edit-alias-modal", () => ({
) : null,
}));
vi.mock("@/components/scans/schedule/edit-scan-schedule-modal", () => ({
EDIT_SCAN_SCHEDULE_STATE: {
LOADING: "loading",
LOADED: "loaded",
ERROR: "error",
},
EditScanScheduleModal: ({
open,
provider,
}: {
open: boolean;
provider?: { providerId: string };
}) =>
open ? (
<div role="dialog" aria-label="Edit Scan Schedule">
Editing schedule for {provider?.providerId}
</div>
) : null,
}));
const makeScan = (
overrides: Partial<ScanProps["attributes"]> = {},
): ScanProps => ({
@@ -80,6 +109,19 @@ const makeScan = (
});
describe("ScanJobsRowActions", () => {
beforeEach(() => {
getScheduleMock.mockResolvedValue({
data: {
type: "schedules",
id: "provider-1",
attributes: { scan_hour: null },
relationships: {
provider: { data: { type: "providers", id: "provider-1" } },
},
},
});
});
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
@@ -95,7 +137,9 @@ describe("ScanJobsRowActions", () => {
await user.click(
screen.getByRole("button", { name: /open actions menu/i }),
);
await user.click(screen.getByRole("menuitem", { name: /^edit$/i }));
await user.click(
screen.getByRole("menuitem", { name: /edit scan alias/i }),
);
// Then
expect(
@@ -103,8 +147,31 @@ describe("ScanJobsRowActions", () => {
).toHaveTextContent("Editing Production scan");
});
it("does not render the legacy Edit Scan Schedule option", async () => {
it("opens Edit Scan Schedule for Prowler Cloud subscribed scan rows", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
const user = userEvent.setup();
render(<ScanJobsRowActions scan={makeScan()} />);
// When
await user.click(
screen.getByRole("button", { name: /open actions menu/i }),
);
await user.click(
screen.getByRole("menuitem", { name: /edit scan schedule/i }),
);
// Then
expect(
screen.getByRole("dialog", { name: /edit scan schedule/i }),
).toHaveTextContent("Editing schedule for provider-1");
});
it("hides Edit Scan Schedule outside Prowler Cloud (OSS)", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
const user = userEvent.setup();
render(<ScanJobsRowActions scan={makeScan()} />);
@@ -120,24 +187,6 @@ describe("ScanJobsRowActions", () => {
).not.toBeInTheDocument();
});
it("does not render cancel scan while the scan cancellation API is missing", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
const user = userEvent.setup();
render(<ScanJobsRowActions scan={makeScan()} />);
// When
await user.click(
screen.getByRole("button", { name: /open actions menu/i }),
);
// Then
expect(
screen.queryByRole("menuitem", { name: /cancel scan/i }),
).not.toBeInTheDocument();
});
it("links completed scans to compliance from the actions menu", async () => {
// Given
const user = userEvent.setup();
@@ -1,6 +1,7 @@
"use client";
import {
CalendarClock,
Download,
Eye,
Pencil,
@@ -10,6 +11,7 @@ import {
import { useRouter } from "next/navigation";
import { useState } from "react";
import { getSchedule } from "@/actions/schedules";
import { getTask } from "@/actions/task";
import { getScanErrorDetails } from "@/actions/task/task.adapter";
import { EditAliasModal } from "@/components/scans/edit-alias-modal";
@@ -17,6 +19,12 @@ import {
ScanErrorDetailsModal,
type ScanErrorDetailsState,
} from "@/components/scans/scan-error-details-modal";
import {
EDIT_SCAN_SCHEDULE_STATE,
EditScanScheduleModal,
type EditScanScheduleState,
type ScanScheduleProvider,
} from "@/components/scans/schedule/edit-scan-schedule-modal";
import {
ActionDropdown,
ActionDropdownItem,
@@ -24,16 +32,36 @@ import {
import { useToast } from "@/components/ui";
import { toLocalDateString } from "@/lib/date-utils";
import { downloadScanZip } from "@/lib/helper";
import type { ScanProps } from "@/types";
import { getScanScheduleCapability } from "@/lib/schedules";
import { isCloud } from "@/lib/shared/env";
import type { ProviderType, ScanProps, ScheduleApiResponse } from "@/types";
import {
SCAN_SCHEDULE_CAPABILITY,
type ScanScheduleCapability,
} from "@/types/schedules";
interface ScanJobsRowActionsProps {
scan: ScanProps;
/**
* Schedule capability override. Only for Prowler Cloud.
*/
capability?: ScanScheduleCapability;
}
export function ScanJobsRowActions({ scan }: ScanJobsRowActionsProps) {
export function ScanJobsRowActions({
scan,
capability,
}: ScanJobsRowActionsProps) {
const router = useRouter();
const canEditSchedule =
(capability ?? getScanScheduleCapability(isCloud())) ===
SCAN_SCHEDULE_CAPABILITY.ADVANCED;
const { toast } = useToast();
const [editOpen, setEditOpen] = useState(false);
const [scheduleOpen, setScheduleOpen] = useState(false);
const [scheduleState, setScheduleState] = useState<EditScanScheduleState>({
kind: EDIT_SCAN_SCHEDULE_STATE.LOADING,
});
const [errorOpen, setErrorOpen] = useState(false);
const [errorState, setErrorState] = useState<ScanErrorDetailsState>({
kind: "idle",
@@ -43,6 +71,15 @@ export function ScanJobsRowActions({ scan }: ScanJobsRowActionsProps) {
const isFailed = scanState === "failed";
const taskId = scan.relationships.task.data?.id;
const scanDate = toLocalDateString(scan.attributes.completed_at);
const providerId = scan.relationships.provider.data?.id;
const scheduleProvider: ScanScheduleProvider | undefined = providerId
? {
providerId,
providerType: (scan.providerInfo?.provider ?? "aws") as ProviderType,
providerUid: scan.providerInfo?.uid ?? providerId,
providerAlias: scan.providerInfo?.alias ?? null,
}
: undefined;
const openFindings = () => {
if (!isCompleted || !scanDate) return;
@@ -96,9 +133,58 @@ export function ScanJobsRowActions({ scan }: ScanJobsRowActionsProps) {
setErrorState({ kind: "loaded", details });
};
const openScheduleEditor = async () => {
if (!providerId) {
setScheduleState({
kind: EDIT_SCAN_SCHEDULE_STATE.ERROR,
message: "Provider ID is not available for this scan.",
});
setScheduleOpen(true);
return;
}
setScheduleState({ kind: EDIT_SCAN_SCHEDULE_STATE.LOADING });
setScheduleOpen(true);
const response = (await getSchedule(providerId)) as
| ScheduleApiResponse
| { error?: string };
if (!response || ("error" in response && response.error)) {
setScheduleState({
kind: EDIT_SCAN_SCHEDULE_STATE.ERROR,
message:
response && "error" in response && response.error
? response.error
: "Failed to load scan schedule.",
});
return;
}
setScheduleState({
kind: EDIT_SCAN_SCHEDULE_STATE.LOADED,
schedule: "data" in response ? response.data : null,
});
};
return (
<div className="flex items-center justify-end">
<ActionDropdown>
{canEditSchedule && (
<ActionDropdownItem
icon={<CalendarClock />}
label="Edit Scan Schedule"
onSelect={() => void openScheduleEditor()}
/>
)}
{/* Synthetic pending rows have no Scan to alias. */}
{!scan.pendingSchedule && (
<ActionDropdownItem
icon={<Pencil />}
label="Edit Scan Alias"
onSelect={() => setEditOpen(true)}
/>
)}
{isCompleted && (
<>
<ActionDropdownItem
@@ -126,12 +212,6 @@ export function ScanJobsRowActions({ scan }: ScanJobsRowActionsProps) {
onSelect={() => void openErrorDetails()}
/>
)}
{/* TODO: Expand Edit to also cover schedule once the backend exposes a schedule update endpoint. */}
<ActionDropdownItem
icon={<Pencil />}
label="Edit"
onSelect={() => setEditOpen(true)}
/>
{/* TODO: Restore Cancel Scan once the backend exposes a public scan cancellation endpoint. */}
</ActionDropdown>
@@ -147,6 +227,13 @@ export function ScanJobsRowActions({ scan }: ScanJobsRowActionsProps) {
onOpenChange={setErrorOpen}
state={errorState}
/>
<EditScanScheduleModal
open={scheduleOpen}
onOpenChange={setScheduleOpen}
provider={scheduleProvider}
state={scheduleState}
/>
</div>
);
}
+1
View File
@@ -20,6 +20,7 @@ export * from "./select/multiselect";
export * from "./select/select";
export * from "./separator/separator";
export * from "./skeleton/skeleton";
export * from "./stacked-cell/stacked-cell";
export * from "./tabs/generic-tabs";
export * from "./tabs/tabs";
export * from "./textarea/textarea";
@@ -214,7 +214,7 @@ describe("MultiSelect", () => {
).not.toBeInTheDocument();
});
it("uses a normalized dropdown width instead of growing with the longest item", async () => {
it("sizes the dropdown to its content with a capped width", async () => {
const user = userEvent.setup();
render(
@@ -233,10 +233,8 @@ describe("MultiSelect", () => {
await user.click(screen.getByRole("combobox"));
expect(screen.getByRole("dialog")).toHaveClass(
"w-[min(var(--radix-popover-trigger-width),calc(100vw-2rem))]",
);
expect(screen.getByRole("dialog")).toHaveClass("max-w-[24rem]");
expect(screen.getByRole("dialog")).toHaveClass("sm:w-max");
expect(screen.getByRole("dialog")).toHaveClass("sm:max-w-[22rem]");
});
it("keeps long option lists scrollable inside the dropdown", async () => {
+3 -3
View File
@@ -313,8 +313,8 @@ export function MultiSelectContent({
const widthClasses =
width === "wide"
? "w-[min(max(var(--radix-popover-trigger-width),24rem),calc(100vw-2rem))] max-w-[32rem]"
: "w-[min(var(--radix-popover-trigger-width),calc(100vw-2rem))] max-w-[24rem]";
? "w-[calc(100vw-2rem)] sm:w-max sm:min-w-[min(max(var(--radix-popover-trigger-width),24rem),32rem)] sm:max-w-[32rem]"
: "w-[calc(100vw-2rem)] sm:w-max sm:min-w-[min(var(--radix-popover-trigger-width),22rem)] sm:max-w-[22rem]";
function handleSearchValueChange(searchValue: string) {
if (!canSearch || !searchValue.trim()) return;
@@ -426,7 +426,7 @@ export function MultiSelectItem({
onSelect?.(value);
}}
>
<span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden whitespace-nowrap">
{children}
</span>
<CheckIcon
@@ -0,0 +1,20 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { StackedCell } from "./stacked-cell";
describe("StackedCell", () => {
it("renders primary and secondary lines", () => {
render(<StackedCell primary="Jun 15, 2026" secondary="12:00AM MAD" />);
expect(screen.getByText("Jun 15, 2026")).toBeInTheDocument();
expect(screen.getByText("12:00AM MAD")).toBeInTheDocument();
});
it("omits the secondary line when not provided", () => {
const { container } = render(<StackedCell primary="Manual" />);
expect(screen.getByText("Manual")).toBeInTheDocument();
expect(container.querySelectorAll("span")).toHaveLength(1);
});
});
@@ -0,0 +1,36 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
interface StackedCellProps {
primary: ReactNode;
secondary?: ReactNode;
className?: string;
}
/**
* Presentational shell for two-line table cells (the DateWithTime look):
* primary line on top, muted secondary line underneath. Content/formatting
* belongs to the caller.
*/
export function StackedCell({
primary,
secondary,
className,
}: StackedCellProps) {
return (
<div
data-slot="stacked-cell"
className={cn("flex flex-col gap-1", className)}
>
<span className="text-text-neutral-primary text-sm whitespace-nowrap">
{primary}
</span>
{secondary ? (
<span className="text-text-neutral-tertiary text-xs font-medium">
{secondary}
</span>
) : null}
</div>
);
}
+7 -12
View File
@@ -5,6 +5,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { formatLocalTimeWithZone } from "@/lib/date-utils";
import { cn } from "@/lib/utils";
interface DateWithTimeProps {
@@ -29,17 +30,11 @@ 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}`
const fullText =
showTime && timeWithZone
? `${formattedDate} ${timeWithZone}`
: formattedDate;
const content = (
@@ -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>
@@ -44,14 +44,14 @@ export function DataTableExpandToggle<TData>({
const isExpanded = isExpandedProp ?? row.getIsExpanded();
if (!row.getCanExpand()) {
return <div className="w-4" />;
return <div className="w-4 shrink-0" />;
}
return (
<button
onClick={row.getToggleExpandedHandler()}
className={cn(
"rounded transition-colors",
"shrink-0 rounded transition-colors",
"hover:bg-prowler-white/10",
"focus-visible:ring-border-input-primary-press focus-visible:ring-2 focus-visible:outline-none",
)}
@@ -73,12 +73,13 @@ export function DataTableExpandableCell<TData>({
{canExpand ? (
<DataTableExpandToggle row={row} isExpanded={isExpanded} />
) : !isChildRow ? (
<div className="w-4" />
// shrink-0: keep this fixed-width spacer from collapsing under wide content, which would drag the checkbox left
<div className="w-4 shrink-0" />
) : null}
</>
)}
{checkboxSlot && (
<div className="mr-2 flex items-center">{checkboxSlot}</div>
<div className="mr-2 flex shrink-0 items-center">{checkboxSlot}</div>
)}
{children}
</div>
@@ -40,6 +40,19 @@ const columns: ColumnDef<TestRow>[] = [
},
];
const columnsWithActions: ColumnDef<TestRow>[] = [
{
accessorKey: "name",
header: "Name",
},
{
id: "actions",
header: "",
cell: () => <button type="button">Actions</button>,
enableSorting: false,
},
];
const metadata: MetaDataProps = {
pagination: {
page: 1,
@@ -99,4 +112,60 @@ describe("DataTable", () => {
expect(rightContent).toHaveClass("md:w-auto");
});
});
describe("when an actions column is present", () => {
it("should keep the actions header and cells sticky on the right edge", () => {
// Given
render(
<DataTable
columns={columnsWithActions}
data={[{ name: "Finding A" }]}
/>,
);
// When
const columnHeaders = screen.getAllByRole("columnheader");
const nameHeader = columnHeaders[0];
const actionsHeader = columnHeaders.at(-1);
expect(actionsHeader).toBeDefined();
const actionsHeaderElement = actionsHeader as HTMLElement;
const nameCell = screen.getByText("Finding A").closest("td");
const actionsCell = screen
.getByRole("button", {
name: "Actions",
})
.closest("td");
// Then
expect(nameHeader).not.toHaveClass("sticky");
expect(nameCell).not.toHaveClass("sticky");
expect(actionsHeaderElement).not.toHaveClass("sticky");
expect(actionsHeaderElement).not.toHaveClass("right-0");
expect(actionsHeaderElement).not.toHaveClass("z-20");
expect(actionsHeaderElement).toHaveClass("bg-bg-neutral-tertiary");
expect(actionsHeaderElement).toHaveClass("border-y");
expect(actionsHeaderElement).toHaveClass("last:border-r");
expect(actionsHeaderElement).toHaveClass("last:rounded-r-full");
expect(actionsHeaderElement).not.toHaveClass("bg-transparent");
expect(actionsHeaderElement).not.toHaveClass("border-y-0");
expect(actionsHeaderElement).not.toHaveClass("border-l-0");
expect(actionsHeaderElement).not.toHaveClass("border-r-0");
expect(actionsHeaderElement).not.toHaveClass("last:border-r-0");
expect(actionsHeaderElement).not.toHaveClass("backdrop-blur-none");
expect(actionsHeaderElement).not.toHaveClass("last:rounded-r-none");
expect(actionsHeaderElement).not.toHaveClass("before:bg-gradient-to-r");
expect(actionsHeaderElement).not.toHaveClass("before:content-['']");
expect(actionsHeaderElement).not.toHaveClass("after:content-['']");
expect(actionsHeaderElement).not.toHaveClass("after:rounded-r-full");
expect(actionsHeaderElement.querySelector("div")).not.toBeInTheDocument();
expect(actionsCell).toHaveClass("sticky");
expect(actionsCell).toHaveClass("right-0");
expect(actionsCell).toHaveClass("z-20");
expect(actionsCell).toHaveClass("bg-bg-neutral-secondary");
expect(actionsCell).not.toHaveClass("border-l");
expect(actionsCell).toHaveClass("before:bg-gradient-to-r");
expect(actionsCell).toHaveClass("before:from-transparent");
expect(actionsCell).toHaveClass("before:to-bg-neutral-secondary");
});
});
});
+26 -3
View File
@@ -44,6 +44,18 @@ type DataTableRowAttributes = {
* to allow them to flex naturally within the table layout.
*/
const DEFAULT_COLUMN_SIZE = 150;
const ACTIONS_COLUMN_ID = "actions";
const STICKY_ACTION_COLUMN_CLASS = "sticky right-0 z-20 min-w-12";
const STICKY_ACTION_CELL_CLASS = `${STICKY_ACTION_COLUMN_CLASS} overflow-visible bg-bg-neutral-secondary before:pointer-events-none before:absolute before:inset-y-0 before:-left-8 before:w-8 before:bg-gradient-to-r before:from-transparent before:to-bg-neutral-secondary before:content-[''] group-hover:bg-bg-neutral-tertiary group-hover:before:to-bg-neutral-tertiary group-data-[state=selected]:bg-bg-neutral-tertiary group-data-[state=selected]:before:to-bg-neutral-tertiary`;
const getStickyActionColumnClassName = (
columnId: string,
variant: "header" | "cell",
) => {
if (columnId !== ACTIONS_COLUMN_ID) return undefined;
return variant === "header" ? undefined : STICKY_ACTION_CELL_CLASS;
};
interface DataTableProviderProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
@@ -286,16 +298,21 @@ export function DataTable<TData, TValue>({
<TableRow key={`${headerGroup.id}-${selectionKey}-${expansionKey}`}>
{headerGroup.headers.map((header) => {
const size = header.getSize();
const isActionsHeader = header.column.id === ACTIONS_COLUMN_ID;
return (
<TableHead
key={header.id}
className={getStickyActionColumnClassName(
header.column.id,
"header",
)}
style={
getSubRows && size !== DEFAULT_COLUMN_SIZE
? { width: `${size}px` }
: undefined
}
>
{header.isPlaceholder
{header.isPlaceholder || isActionsHeader
? null
: flexRender(
header.column.columnDef.header,
@@ -323,13 +340,19 @@ export function DataTable<TData, TValue>({
<TableRow
{...getRowAttributes?.(row)}
data-state={row.getIsSelected() && "selected"}
className={cn(onRowClick && "cursor-pointer")}
className={cn("group", onRowClick && "cursor-pointer")}
onClick={(event) =>
handleRowClick(row, event.target as HTMLElement)
}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
<TableCell
key={cell.id}
className={getStickyActionColumnClassName(
cell.column.id,
"cell",
)}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
+35
View File
@@ -22,6 +22,41 @@ export function toLocalDateString(
}
}
/** Local date in the app's table format (e.g. "Jun 15, 2026"), as shown by DateWithTime. */
export function formatLocalDate(
value: string | null | undefined,
): string | undefined {
if (!value) return undefined;
try {
const date = parseISO(value);
if (isNaN(date.getTime())) return undefined;
return format(date, "MMM dd, yyyy");
} catch {
return undefined;
}
}
/** Local time with the browser's short zone label (e.g. "12:00AM MAD"), as shown by DateWithTime. */
export function formatLocalTimeWithZone(
value: string | null | undefined,
): string | undefined {
if (!value) return undefined;
try {
const date = parseISO(value);
if (isNaN(date.getTime())) return undefined;
const zone =
Intl.DateTimeFormat()
.resolvedOptions()
.timeZone.split("/")
.pop()
?.substring(0, 3)
.toUpperCase() || "";
return `${format(date, "h:mma")} ${zone}`.trim();
} catch {
return undefined;
}
}
/**
* Formats a duration in seconds to a human-readable string like "2h 5m 30s".
*/
+18 -1
View File
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { downloadScanZip } from "./helper";
import { downloadScanZip, getErrorMessage } from "./helper";
vi.mock("@/actions/scans", () => ({
getComplianceCsv: vi.fn(),
@@ -118,3 +118,20 @@ describe("downloadScanZip", () => {
});
});
});
describe("getErrorMessage", () => {
it("does not expose HTML gateway pages", () => {
// Given
const error = new Error(
"<html><head><title>502 Bad Gateway</title></head><body><h1>502 Bad Gateway</h1></body></html>",
);
// When
const message = getErrorMessage(error);
// Then
expect(message).toBe(
"Server is temporarily unavailable. Please try again in a few minutes.",
);
});
});
+22 -3
View File
@@ -109,6 +109,25 @@ export const getAuthUrl = (provider: AuthSocialProvider) => {
const REPORT_PREPARATION_ERROR =
"Unable to prepare the scan report. Please try again in a few minutes.";
export const GENERIC_SERVER_ERROR_MESSAGE =
"Server is temporarily unavailable. Please try again in a few minutes.";
const HTML_ERROR_PATTERN =
/(?:<!doctype\s+html|<html\b|<\/?(?:head|title|body|center|h1|h2|pre)\b)/i;
export const sanitizeErrorMessage = (
message: string,
fallback = GENERIC_SERVER_ERROR_MESSAGE,
): string => {
const trimmedMessage = message.trim();
if (!trimmedMessage) {
return fallback;
}
return HTML_ERROR_PATTERN.test(trimmedMessage) ? fallback : trimmedMessage;
};
const getPreflightErrorMessage = async (response: Response) => {
const contentType = response.headers.get("content-type")?.toLowerCase() || "";
@@ -404,11 +423,11 @@ export function decryptKey(passkey: string) {
export const getErrorMessage = (error: unknown): string => {
if (error instanceof Error) {
return error.message;
return sanitizeErrorMessage(error.message);
} else if (error && typeof error === "object" && "message" in error) {
return String(error.message);
return sanitizeErrorMessage(String(error.message));
} else if (typeof error === "string") {
return error;
return sanitizeErrorMessage(error);
} else {
return "Oops! Something went wrong.";
}
+490
View File
@@ -0,0 +1,490 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
buildProviderScheduleSummary,
buildSchedulesByProviderId,
buildScheduleUpdatePayload,
formatScheduleHour,
getBrowserTimezone,
getNextScheduledRun,
getScanScheduleCapability,
getScheduleFormValues,
isScheduleConfigured,
} from "@/lib/schedules";
import {
SCAN_SCHEDULE_CAPABILITY,
SCHEDULE_FREQUENCY,
type ScheduleAttributes,
type ScheduleProps,
} 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,
);
});
});
describe("buildSchedulesByProviderId", () => {
const buildSchedule = (
id: string,
overrides: Partial<ScheduleAttributes> = {},
): ScheduleProps => ({
type: "schedules",
id,
attributes: {
scan_enabled: true,
scan_frequency: SCHEDULE_FREQUENCY.DAILY,
scan_hour: 9,
scan_timezone: "Europe/Madrid",
scan_interval_hours: null,
scan_day_of_week: null,
scan_day_of_month: null,
...overrides,
},
relationships: {
provider: { data: { type: "providers", id } },
},
});
it("indexes schedule attributes by provider id (the schedule's own id)", () => {
const result = {
data: [
buildSchedule("provider-1", { scan_hour: 6 }),
buildSchedule("provider-2", { scan_hour: null }),
],
};
expect(buildSchedulesByProviderId(result)).toEqual({
"provider-1": result.data[0].attributes,
"provider-2": result.data[1].attributes,
});
});
it("returns an empty map when the request errored (e.g. OSS without /schedules)", () => {
expect(buildSchedulesByProviderId({ error: "Not found" })).toEqual({});
});
it("returns an empty map for a null/undefined result", () => {
expect(buildSchedulesByProviderId(null)).toEqual({});
expect(buildSchedulesByProviderId(undefined)).toEqual({});
});
});
describe("buildProviderScheduleSummary", () => {
const buildAttributes = (
overrides: Partial<ScheduleAttributes> = {},
): ScheduleAttributes => ({
scan_enabled: true,
scan_frequency: SCHEDULE_FREQUENCY.DAILY,
scan_hour: 9,
scan_timezone: "Europe/Madrid",
scan_interval_hours: null,
scan_day_of_week: null,
scan_day_of_month: null,
...overrides,
});
const now = new Date(2026, 5, 10, 10, 30, 0, 0);
it.each([
[{ scan_frequency: SCHEDULE_FREQUENCY.DAILY }, "Daily"],
[
{ scan_frequency: SCHEDULE_FREQUENCY.WEEKLY, scan_day_of_week: 1 },
"Weekly on Monday",
],
[
{ scan_frequency: SCHEDULE_FREQUENCY.MONTHLY, scan_day_of_month: 15 },
"Monthly on day 15",
],
[
{ scan_frequency: SCHEDULE_FREQUENCY.INTERVAL, scan_interval_hours: 72 },
"Every 72 hours",
],
])("exposes the %o cadence as %s", (overrides, cadence) => {
expect(
buildProviderScheduleSummary(buildAttributes(overrides), now).cadence,
).toBe(cadence);
});
it("passes through server-computed next/last run timestamps", () => {
const summary = buildProviderScheduleSummary(
buildAttributes({
next_scan_at: "2026-06-15T07:00:00Z",
last_scan_at: "2026-06-08T07:00:00Z",
}),
now,
);
expect(summary.nextScanAt).toBe("2026-06-15T07:00:00Z");
expect(summary.lastScanAt).toBe("2026-06-08T07:00:00Z");
});
});
+290
View File
@@ -0,0 +1,290 @@
import { z } from "zod";
import type { ScanScheduleSummary } from "@/types/scans";
import {
SCAN_SCHEDULE_CAPABILITY,
type ScanScheduleCapability,
SCHEDULE_FREQUENCY,
SCHEDULE_WEEKDAY_LABELS,
type ScheduleAttributes,
type ScheduleFormValues,
type ScheduleProps,
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,
};
}
interface SchedulesActionResult {
data?: ScheduleProps[] | null;
error?: unknown;
}
/**
* Indexes a `getSchedules()` result by provider id — the schedule resource's own
* `id` IS the provider id. Returns an empty map on error or missing data, so
* callers can treat schedules as best-effort (e.g. OSS, where the `/schedules`
* list endpoint may not exist). Shared by the scans and providers views.
*/
export function buildSchedulesByProviderId(
result: SchedulesActionResult | null | undefined,
): Record<string, ScheduleAttributes> {
const byProviderId: Record<string, ScheduleAttributes> = {};
if (!result || result.error) return byProviderId;
for (const schedule of result.data ?? []) {
byProviderId[schedule.id] = schedule.attributes;
}
return byProviderId;
}
/**
* 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()));
}
/**
* Builds the display summary (cadence + next/last run) for a provider's
* schedule, the way the scans and providers tables render it. Shared so both
* views show identical cadence labels.
*
* `next_scan_at` semantics: an absent field (older API) with an enabled
* schedule falls back to a client estimate; an explicit null (paused) means no
* next run.
*/
export function buildProviderScheduleSummary(
attributes: ScheduleAttributes,
now: Date,
): ScanScheduleSummary {
const nextScanAt =
attributes.next_scan_at === undefined && attributes.scan_enabled
? (getNextScheduledRunInTimezone(attributes, now)?.toISOString() ?? null)
: (attributes.next_scan_at ?? null);
return {
summary: describeScheduleCadence(attributes),
cadence: getScheduleCadenceParts(attributes).cadence,
nextScanAt,
lastScanAt: attributes.last_scan_at ?? null,
};
}
+54
View File
@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { captureExceptionMock, captureMessageMock, revalidatePathMock } =
vi.hoisted(() => ({
captureExceptionMock: vi.fn(),
captureMessageMock: vi.fn(),
revalidatePathMock: vi.fn(),
}));
vi.mock("@sentry/nextjs", () => ({
captureException: captureExceptionMock,
captureMessage: captureMessageMock,
}));
vi.mock("next/cache", () => ({
revalidatePath: revalidatePathMock,
}));
vi.mock("./helper", () => ({
GENERIC_SERVER_ERROR_MESSAGE:
"Server is temporarily unavailable. Please try again in a few minutes.",
getErrorMessage: (error: unknown) =>
error instanceof Error ? error.message : String(error),
parseStringify: (value: unknown) => value,
sanitizeErrorMessage: (message: string, fallback: string) =>
/<html\b|<\/?body\b|<\/?h1\b/i.test(message) ? fallback : message.trim(),
}));
import { handleApiResponse } from "./server-actions-helper";
describe("server action error handling", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
});
it("throws a generic server error instead of raw HTML for 5xx responses", async () => {
// Given
const response = new Response(
"<html><head><title>502 Bad Gateway</title></head><body><center><h1>502 Bad Gateway</h1></center></body></html>",
{
status: 502,
statusText: "Bad Gateway",
headers: { "content-type": "text/html" },
},
);
// When / Then
const result = handleApiResponse(response);
await expect(result).rejects.toThrow(
"Server is temporarily unavailable. Please try again in a few minutes.",
);
});
});
+21 -8
View File
@@ -3,7 +3,12 @@ import { revalidatePath } from "next/cache";
import { SentryErrorSource, SentryErrorType } from "@/sentry";
import { getErrorMessage, parseStringify } from "./helper";
import {
GENERIC_SERVER_ERROR_MESSAGE,
getErrorMessage,
parseStringify,
sanitizeErrorMessage,
} from "./helper";
/**
* Helper function to handle API responses consistently
@@ -17,6 +22,7 @@ export const handleApiResponse = async (
if (!response.ok) {
// Read error body safely; prefer JSON, fallback to plain text
const rawErrorText = await response.text().catch(() => "");
const contentType = response.headers.get("content-type")?.toLowerCase();
let errorData: any = null;
try {
errorData = rawErrorText ? JSON.parse(rawErrorText) : null;
@@ -27,13 +33,20 @@ export const handleApiResponse = async (
const errorsArray = Array.isArray(errorData?.errors)
? (errorData.errors as any[])
: undefined;
const errorDetail =
errorsArray?.[0]?.detail ||
errorData?.error ||
errorData?.message ||
(rawErrorText && rawErrorText.trim()) ||
response.statusText ||
"Oops! Something went wrong.";
const parsedErrorMessage =
errorsArray?.[0]?.detail || errorData?.error || errorData?.message;
const fallbackErrorMessage =
response.status >= 500 || contentType?.includes("text/html")
? GENERIC_SERVER_ERROR_MESSAGE
: response.statusText || "Oops! Something went wrong.";
const rawErrorMessage =
parsedErrorMessage ||
(response.status < 500 && rawErrorText.trim()) ||
fallbackErrorMessage;
const errorDetail = sanitizeErrorMessage(
String(rawErrorMessage),
fallbackErrorMessage,
);
// Capture error context for Sentry
const errorContext = {
+10 -10
View File
@@ -5,7 +5,6 @@ import {
AWS_CREDENTIAL_OPTIONS,
ProvidersPage,
} from "./providers/providers-page";
import { ScansPage } from "./scans/scans-page";
export const ERROR_MESSAGES = {
INVALID_CREDENTIALS: "Invalid email or password",
@@ -123,13 +122,10 @@ export async function addAWSProvider(
await providersPage.fillStaticCredentials(staticCredentials);
await providersPage.clickNext();
// Launch scan
await providersPage.verifyLaunchScanPageLoaded();
await providersPage.clickNext();
// Wait for redirect to provider page
const scansPage = new ScansPage(page);
await scansPage.verifyPageLoaded();
// Scans specs launch their own scan. The setup helper should only leave a
// connected provider behind so the suite does not spend CI capacity on a
// duplicate preparatory scan.
await providersPage.completeProviderConnectionWithoutLaunchingScan(accountId);
}
/**
@@ -223,9 +219,13 @@ export async function deleteProviderIfExists(
await expect(deleteMenuItem).toBeVisible({ timeout: 5000 });
await deleteMenuItem.click();
// Wait for confirmation modal to appear
// Wait for confirmation modal to appear. Exclude the Next.js dev error
// overlay, which is also role="dialog" and would otherwise be matched first,
// making the assertion wait on the wrong (hidden) element.
const modal = page.page
.locator('[role="dialog"], .modal, [data-testid*="modal"]')
.locator(
'[role="dialog"]:not([data-nextjs-dialog="true"]), .modal, [data-testid*="modal"]',
)
.first();
await expect(modal).toBeVisible({ timeout: 10000 });
+108 -8
View File
@@ -880,7 +880,9 @@ export class ProvidersPage extends BasePage {
if (actionName === "Check connection") {
await this.handleCheckConnectionCompletion();
}
if (actionName === "Launch scan") {
// "Save" is the wizard's final action (saves the scan schedule) and
// "Launch scan" its legacy/manual counterpart; both close the modal.
if (actionName === "Launch scan" || actionName === "Save") {
await this.handleLaunchScanCompletion();
}
return;
@@ -893,6 +895,12 @@ export class ProvidersPage extends BasePage {
}
private async handleCheckConnectionCompletion(): Promise<void> {
// A successful connection advances to the schedule step ("Save"); older
// flows surfaced a "Launch scan" action instead.
const saveScheduleButton = this.page.getByRole("button", {
name: "Save",
exact: true,
});
const launchScanButton = this.page.getByRole("button", {
name: "Launch scan",
exact: true,
@@ -903,6 +911,7 @@ export class ProvidersPage extends BasePage {
try {
await Promise.race([
saveScheduleButton.waitFor({ state: "visible", timeout: 30000 }),
launchScanButton.waitFor({ state: "visible", timeout: 30000 }),
this.wizardModal.waitFor({ state: "hidden", timeout: 30000 }),
connectionError.waitFor({ state: "visible", timeout: 30000 }),
@@ -918,18 +927,103 @@ export class ProvidersPage extends BasePage {
);
}
if (await launchScanButton.isVisible().catch(() => false)) {
if (await saveScheduleButton.isVisible().catch(() => false)) {
await saveScheduleButton.click();
await this.handleLaunchScanCompletion();
} else if (await launchScanButton.isVisible().catch(() => false)) {
await launchScanButton.click();
await this.handleLaunchScanCompletion();
}
}
private async waitForProviderLaunchChoice(): Promise<void> {
const launchAction = this.page
.getByRole("button", { name: "Save", exact: true })
.or(this.page.getByRole("button", { name: "Launch scan", exact: true }));
const connectionError = this.page.locator(
"div.border-border-error p.text-text-error-primary",
);
try {
await Promise.race([
launchAction.waitFor({ state: "visible", timeout: 30000 }),
connectionError.waitFor({ state: "visible", timeout: 30000 }),
]);
} catch {
// Continue and inspect visible state below.
}
if (await connectionError.isVisible().catch(() => false)) {
const errorText = await connectionError.textContent();
throw new Error(
`Test connection failed with error: ${errorText?.trim() || "Unknown error"}`,
);
}
await expect(launchAction).toBeVisible();
}
async completeProviderConnectionWithoutLaunchingScan(
providerUID: string,
): Promise<void> {
await this.verifyWizardModalOpen();
const checkConnectionButton = this.page.getByRole("button", {
name: "Check connection",
exact: true,
});
const launchAction = this.page
.getByRole("button", { name: "Save", exact: true })
.or(this.page.getByRole("button", { name: "Launch scan", exact: true }));
const connectionError = this.page.locator(
"div.border-border-error p.text-text-error-primary",
);
// The test-connection step renders its footer action only after an async
// load (canSubmit gate). Wait for the footer to settle on an actionable
// state (or surface a connection error) instead of reading visibility on
// the first frame, which races the render and falls through.
await expect(
checkConnectionButton.or(launchAction).or(connectionError),
).toBeVisible({ timeout: 30000 });
if (await connectionError.isVisible().catch(() => false)) {
const errorText = await connectionError.textContent();
throw new Error(
`Test connection failed with error: ${errorText?.trim() || "Unknown error"}`,
);
}
// Provider-add E2E validates credentials and provider persistence only.
// Launching one scan per provider made CI noisy and overloaded the backend;
// scan execution itself is covered by scans.spec.ts.
if (await checkConnectionButton.isVisible().catch(() => false)) {
await checkConnectionButton.click();
await this.waitForProviderLaunchChoice();
} else {
await expect(launchAction).toBeVisible();
}
await this.wizardModal
.getByRole("button", { name: "Close", exact: true })
.click();
await expect(this.wizardModal).not.toBeVisible({ timeout: 30000 });
await this.page.waitForURL(/\/providers/, { timeout: 30000 });
await this.verifyLoadProviderPageAfterNewProvider();
const providerExists =
await this.verifySingleRowForProviderUID(providerUID);
if (!providerExists) {
throw new Error(`Provider with UID ${providerUID} was not found.`);
}
}
private async handleLaunchScanCompletion(): Promise<void> {
const connectionError = this.page.locator(
"div.border-border-error p.text-text-error-primary",
);
const launchErrorToast = this.page.getByRole("alert").filter({
hasText: /Unable to launch scan/i,
hasText: /Unable to (launch scan|save scan schedule)/i,
});
try {
@@ -1525,9 +1619,11 @@ export class ProvidersPage extends BasePage {
await this.verifyPageHasProwlerTitle();
await this.verifyWizardModalOpen();
// Some providers show "Check connection" before "Launch scan".
// The final step saves the scan schedule ("Save"); manual-only accounts
// show "Launch scan" and some providers show "Check connection" first.
const launchAction = this.page
.getByRole("button", { name: "Launch scan", exact: true })
.getByRole("button", { name: "Save", exact: true })
.or(this.page.getByRole("button", { name: "Launch scan", exact: true }))
.or(
this.page.getByRole("button", {
name: "Check connection",
@@ -1555,10 +1651,14 @@ export class ProvidersPage extends BasePage {
hasText: providerUID,
});
// Verify the number of matching rows is 1
const count = await matchingRows.count();
if (count !== 1) return false;
// Use an auto-retrying assertion (not an instant count()) so the check
// waits for the table refetch to render the newly added provider row.
try {
await expect(matchingRows).toHaveCount(1, { timeout: 15000 });
return true;
} catch {
return false;
}
}
async selectAuthenticationMethod(method: AWSCredentialType): Promise<void> {
+164 -164
View File
@@ -32,26 +32,26 @@
4. Fill provider details (account ID and alias)
5. Select "credentials" authentication type
6. Fill static credentials (access key and secret key)
7. Launch initial scan
8. Verify redirect to Scans page
9. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan")
7. Confirm provider connection without launching a scan
8. Verify return to Providers page
9. Verify provider exists in Providers table
### Expected Result
- AWS provider successfully added with static credentials
- Initial scan launched successfully
- User redirected to Scans page
- Scheduled scan appears in Scans table with correct provider and scan name
- Provider connection validated without launching a scan
- User returned to Providers page
- Provider appears in Providers table with the expected UID
### Key verification points
- Provider page loads correctly
- Connect account page displays AWS option
- Credentials form accepts static credentials
- Launch scan page appears
- Successful redirect to Scans page after scan launch
- Provider exists in Scans table (verified by account ID)
- Scan name field contains "scheduled scan"
- Launch step appears
- Successful return to Providers page after closing the launch step
- Provider exists in Providers table (verified by account ID)
- Provider UID matches the expected value
### Notes
@@ -88,26 +88,26 @@
4. Fill provider details (account ID and alias)
5. Select "role" authentication type
6. Fill role credentials (access key, secret key, and role ARN)
7. Launch initial scan
8. Verify redirect to Scans page
9. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan")
7. Confirm provider connection without launching a scan
8. Verify return to Providers page
9. Verify provider exists in Providers table
### Expected Result
- AWS provider successfully added with role credentials
- Initial scan launched successfully
- User redirected to Scans page
- Scheduled scan appears in Scans table with correct provider and scan name
- Provider connection validated without launching a scan
- User returned to Providers page
- Provider appears in Providers table with the expected UID
### Key verification points
- Provider page loads correctly
- Connect account page displays AWS option
- Role credentials form accepts all required fields
- Launch scan page appears
- Successful redirect to Scans page after scan launch
- Provider exists in Scans table (verified by account ID)
- Scan name field contains "scheduled scan"
- Launch step appears
- Successful return to Providers page after closing the launch step
- Provider exists in Providers table (verified by account ID)
- Provider UID matches the expected value
### Notes
@@ -144,26 +144,26 @@
3. Select Azure provider type
4. Fill provider details (subscription ID and alias)
5. Fill Azure credentials (client ID, client secret, tenant ID)
6. Launch initial scan
7. Verify redirect to Scans page
8. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan")
6. Confirm provider connection without launching a scan
7. Verify return to Providers page
8. Verify provider exists in Providers table
### Expected Result
- Azure provider successfully added with static credentials
- Initial scan launched successfully
- User redirected to Scans page
- Scheduled scan appears in Scans table with correct provider and scan name
- Provider connection validated without launching a scan
- User returned to Providers page
- Provider appears in Providers table with the expected UID
### Key verification points
- Provider page loads correctly
- Connect account page displays Azure option
- Azure credentials form accepts all required fields
- Launch scan page appears
- Successful redirect to Scans page after scan launch
- Provider exists in Scans table (verified by subscription ID)
- Scan name field contains "scheduled scan"
- Launch step appears
- Successful return to Providers page after closing the launch step
- Provider exists in Providers table (verified by subscription ID)
- Provider UID matches the expected value
### Notes
@@ -201,26 +201,26 @@
4. Fill provider details (domain ID and alias)
5. Select static credentials type
6. Fill M365 credentials (client ID, client secret, tenant ID)
7. Launch initial scan
8. Verify redirect to Scans page
9. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan")
7. Confirm provider connection without launching a scan
8. Verify return to Providers page
9. Verify provider exists in Providers table
### Expected Result
- M365 provider successfully added with static credentials
- Initial scan launched successfully
- User redirected to Scans page
- Scheduled scan appears in Scans table with correct provider and scan name
- Provider connection validated without launching a scan
- User returned to Providers page
- Provider appears in Providers table with the expected UID
### Key verification points
- Provider page loads correctly
- Connect account page displays M365 option
- M365 credentials form accepts all required fields
- Launch scan page appears
- Successful redirect to Scans page after scan launch
- Provider exists in Scans table (verified by domain ID)
- Scan name field contains "scheduled scan"
- Launch step appears
- Successful return to Providers page after closing the launch step
- Provider exists in Providers table (verified by domain ID)
- Provider UID matches the expected value
### Notes
@@ -258,26 +258,26 @@
4. Fill provider details (domain ID and alias)
5. Select certificate credentials type
6. Fill M365 certificate credentials (client ID, tenant ID, certificate content)
7. Launch initial scan
8. Verify redirect to Scans page
9. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan")
7. Confirm provider connection without launching a scan
8. Verify return to Providers page
9. Verify provider exists in Providers table
### Expected Result
- M365 provider successfully added with certificate credentials
- Initial scan launched successfully
- User redirected to Scans page
- Scheduled scan appears in Scans table with correct provider and scan name
- Provider connection validated without launching a scan
- User returned to Providers page
- Provider appears in Providers table with the expected UID
### Key verification points
- Provider page loads correctly
- Connect account page displays M365 option
- Certificate credentials form accepts all required fields
- Launch scan page appears
- Successful redirect to Scans page after scan launch
- Provider exists in Scans table (verified by domain ID)
- Scan name field contains "scheduled scan"
- Launch step appears
- Successful return to Providers page after closing the launch step
- Provider exists in Providers table (verified by domain ID)
- Provider UID matches the expected value
### Notes
@@ -316,16 +316,16 @@
4. Fill provider details (context and alias)
5. Verify credentials page is loaded
6. Fill Kubernetes credentials (kubeconfig content)
7. Launch initial scan
8. Verify redirect to Scans page
9. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan")
7. Confirm provider connection without launching a scan
8. Verify return to Providers page
9. Verify provider exists in Providers table
### Expected Result
- Kubernetes provider successfully added with kubeconfig content
- Initial scan launched successfully
- User redirected to Scans page
- Scheduled scan appears in Scans table with correct provider and scan name
- Provider connection validated without launching a scan
- User returned to Providers page
- Provider appears in Providers table with the expected UID
### Key verification points
@@ -334,10 +334,10 @@
- Provider details form accepts context and alias
- Credentials page loads with kubeconfig content field
- Kubeconfig content is properly filled in the correct field
- Launch scan page appears
- Successful redirect to Scans page after scan launch
- Provider exists in Scans table (verified by context)
- Scan name field contains "scheduled scan"
- Launch step appears
- Successful return to Providers page after closing the launch step
- Provider exists in Providers table (verified by context)
- Provider UID matches the expected value
### Notes
@@ -377,16 +377,16 @@
4. Fill provider details (project ID and alias)
5. Select service account credentials type
6. Fill GCP service account key credentials
7. Launch initial scan
8. Verify redirect to Scans page
9. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan")
7. Confirm provider connection without launching a scan
8. Verify return to Providers page
9. Verify provider exists in Providers table
### Expected Result
- GCP provider successfully added with service account key
- Initial scan launched successfully
- User redirected to Scans page
- Scheduled scan appears in Scans table with correct provider and scan name
- Provider connection validated without launching a scan
- User returned to Providers page
- Provider appears in Providers table with the expected UID
### Key verification points
@@ -395,10 +395,10 @@
- Provider details form accepts project ID and alias
- Service account credentials page loads with service account key field
- Service account key is properly filled in the correct field
- Launch scan page appears
- Successful redirect to Scans page after scan launch
- Provider exists in Scans table (verified by project ID)
- Scan name field contains "scheduled scan"
- Launch step appears
- Successful return to Providers page after closing the launch step
- Provider exists in Providers table (verified by project ID)
- Provider UID matches the expected value
### Notes
@@ -439,16 +439,16 @@
4. Fill provider details (username and alias)
5. Select personal access token credentials type
6. Fill GitHub personal access token credentials
7. Launch initial scan
8. Verify redirect to Scans page
9. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan")
7. Confirm provider connection without launching a scan
8. Verify return to Providers page
9. Verify provider exists in Providers table
### Expected Result
- GitHub provider successfully added with personal access token
- Initial scan launched successfully
- User redirected to Scans page
- Scheduled scan appears in Scans table with correct provider and scan name
- Provider connection validated without launching a scan
- User returned to Providers page
- Provider appears in Providers table with the expected UID
### Key verification points
@@ -457,10 +457,10 @@
- Provider details form accepts username and alias
- Personal access token credentials page loads with token field
- Personal access token is properly filled in the correct field
- Launch scan page appears
- Successful redirect to Scans page after scan launch
- Provider exists in Scans table (verified by username)
- Scan name field contains "scheduled scan"
- Launch step appears
- Successful return to Providers page after closing the launch step
- Provider exists in Providers table (verified by username)
- Provider UID matches the expected value
### Notes
@@ -499,16 +499,16 @@
4. Fill provider details (username and alias)
5. Select GitHub App credentials type
6. Fill GitHub App credentials (App ID and private key)
7. Launch initial scan
8. Verify redirect to Scans page
9. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan")
7. Confirm provider connection without launching a scan
8. Verify return to Providers page
9. Verify provider exists in Providers table
### Expected Result
- GitHub provider successfully added with GitHub App credentials
- Initial scan launched successfully
- User redirected to Scans page
- Scheduled scan appears in Scans table with correct provider and scan name
- Provider connection validated without launching a scan
- User returned to Providers page
- Provider appears in Providers table with the expected UID
### Key verification points
@@ -517,10 +517,10 @@
- Provider details form accepts username and alias
- GitHub App credentials page loads with App ID and private key fields
- GitHub App credentials are properly filled in the correct fields
- Launch scan page appears
- Successful redirect to Scans page after scan launch
- Provider exists in Scans table (verified by username)
- Scan name field contains "scheduled scan"
- Launch step appears
- Successful return to Providers page after closing the launch step
- Provider exists in Providers table (verified by username)
- Provider UID matches the expected value
### Notes
@@ -560,16 +560,16 @@
4. Fill provider details (organization name and alias)
5. Select personal access token credentials type
6. Fill GitHub organization personal access token credentials
7. Launch initial scan
8. Verify redirect to Scans page
9. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan")
7. Confirm provider connection without launching a scan
8. Verify return to Providers page
9. Verify provider exists in Providers table
### Expected Result
- GitHub provider successfully added with organization personal access token
- Initial scan launched successfully
- User redirected to Scans page
- Scheduled scan appears in Scans table with correct provider and scan name
- Provider connection validated without launching a scan
- User returned to Providers page
- Provider appears in Providers table with the expected UID
### Key verification points
@@ -578,10 +578,10 @@
- Provider details form accepts organization name and alias
- Personal access token credentials page loads with token field
- Organization personal access token is properly filled in the correct field
- Launch scan page appears
- Successful redirect to Scans page after scan launch
- Provider exists in Scans table (verified by organization name)
- Scan name field contains "scheduled scan"
- Launch step appears
- Successful return to Providers page after closing the launch step
- Provider exists in Providers table (verified by organization name)
- Provider UID matches the expected value
### Notes
@@ -621,16 +621,16 @@
5. Select "role" authentication type
6. Switch authentication method to "Use AWS SDK default credentials"
7. Fill role ARN using AWS SDK credential inputs
8. Launch initial scan
9. Verify redirect to Scans page
10. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan")
8. Confirm provider connection without launching a scan
9. Verify return to Providers page
10. Verify provider exists in Providers table
### Expected Result
- AWS provider successfully added using AWS SDK default credentials to assume the role
- Initial scan launched successfully
- User redirected to Scans page
- Scheduled scan appears in Scans table with correct provider and scan name
- Provider connection validated without launching a scan
- User returned to Providers page
- Provider appears in Providers table with the expected UID
### Key verification points
@@ -638,10 +638,10 @@
- Connect account page displays AWS option
- Credentials form exposes AWS SDK default authentication method
- Role ARN field accepts provided value when SDK method is selected
- Launch scan page appears
- Successful redirect to Scans page after scan launch
- Provider exists in Scans table (verified by account ID)
- Scan name field contains "scheduled scan"
- Launch step appears
- Successful return to Providers page after closing the launch step
- Provider exists in Providers table (verified by account ID)
- Provider UID matches the expected value
### Notes
@@ -679,16 +679,16 @@
4. Fill provider details (tenancy ID and alias)
5. Verify OCI credentials page is loaded
6. Fill OCI credentials (user ID, fingerprint, key content, region)
7. Launch initial scan
8. Verify redirect to Scans page
9. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan")
7. Confirm provider connection without launching a scan
8. Verify return to Providers page
9. Verify provider exists in Providers table
### Expected Result
- OCI provider successfully added with API Key credentials
- Initial scan launched successfully
- User redirected to Scans page
- Scheduled scan appears in Scans table with correct provider and scan name
- Provider connection validated without launching a scan
- User returned to Providers page
- Provider appears in Providers table with the expected UID
### Key verification points
@@ -697,10 +697,10 @@
- Provider details form accepts tenancy ID and alias
- OCI credentials page loads
- Credentials form accepts all required fields (user ID, fingerprint, key content, region)
- Launch scan page appears
- Successful redirect to Scans page after scan launch
- Provider exists in Scans table (verified by tenancy ID)
- Scan name field contains "scheduled scan"
- Launch step appears
- Successful return to Providers page after closing the launch step
- Provider exists in Providers table (verified by tenancy ID)
- Provider UID matches the expected value
### Notes
@@ -798,16 +798,16 @@
6. Select static credentials type
7. Verify static credentials page is loaded
8. Fill AlibabaCloud credentials (access key ID and access key secret)
9. Launch initial scan
10. Verify redirect to Scans page
11. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan")
9. Confirm provider connection without launching a scan
10. Verify return to Providers page
11. Verify provider exists in Providers table
### Expected Result
- AlibabaCloud provider successfully added with static credentials
- Initial scan launched successfully
- User redirected to Scans page
- Scheduled scan appears in Scans table with correct provider and scan name
- Provider connection validated without launching a scan
- User returned to Providers page
- Provider appears in Providers table with the expected UID
### Key verification points
@@ -817,10 +817,10 @@
- Credentials page loads with credential type selection
- Static credentials page loads with access key ID and access key secret fields
- Static credentials are properly filled in the correct fields
- Launch scan page appears
- Successful redirect to Scans page after scan launch
- Provider exists in Scans table (verified by account ID)
- Scan name field contains "scheduled scan"
- Launch step appears
- Successful return to Providers page after closing the launch step
- Provider exists in Providers table (verified by account ID)
- Provider UID matches the expected value
### Notes
@@ -860,16 +860,16 @@
6. Select RAM Role credentials type
7. Verify RAM Role credentials page is loaded
8. Fill AlibabaCloud RAM Role credentials (access key ID, access key secret, and role ARN)
9. Launch initial scan
10. Verify redirect to Scans page
11. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan")
9. Confirm provider connection without launching a scan
10. Verify return to Providers page
11. Verify provider exists in Providers table
### Expected Result
- AlibabaCloud provider successfully added with RAM Role credentials
- Initial scan launched successfully
- User redirected to Scans page
- Scheduled scan appears in Scans table with correct provider and scan name
- Provider connection validated without launching a scan
- User returned to Providers page
- Provider appears in Providers table with the expected UID
### Key verification points
@@ -879,10 +879,10 @@
- Credentials page loads with credential type selection
- RAM Role credentials page loads with access key ID, access key secret, and role ARN fields
- RAM Role credentials are properly filled in the correct fields
- Launch scan page appears
- Successful redirect to Scans page after scan launch
- Provider exists in Scans table (verified by account ID)
- Scan name field contains "scheduled scan"
- Launch step appears
- Successful return to Providers page after closing the launch step
- Provider exists in Providers table (verified by account ID)
- Provider UID matches the expected value
### Notes
@@ -924,15 +924,15 @@
6. Continue to authentication details and provide role ARN
7. Confirm StackSet deployment checkbox and authenticate
8. Confirm organization account selection step and continue
9. Verify organization launch step, choose single scan schedule, and launch
10. Verify redirect to Scans page
9. Verify organization launch step, choose daily scan schedule, and save
10. Verify return to Providers page
### Expected Result
- AWS Organizations flow completes successfully
- Accounts are connected and launch step is displayed
- Scan scheduling selection is applied
- User is redirected to Scans page after launch
- User returns to Providers page after saving
### Key verification points
@@ -941,7 +941,7 @@
- Authentication details step loads
- Account selection step loads
- Accounts connected launch step appears
- Successful redirect to Scans page after launching
- Successful return to Providers page after saving
### Notes
@@ -978,16 +978,16 @@
4. Fill provider details (customer ID and alias)
5. Verify Google Workspace credentials page is loaded
6. Fill Google Workspace credentials (customer ID, service account JSON, delegated user email)
7. Launch initial scan
8. Verify redirect to Scans page
9. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan")
7. Confirm provider connection without launching a scan
8. Verify return to Providers page
9. Verify provider exists in Providers table
### Expected Result
- Google Workspace provider successfully added with Service Account credentials
- Initial scan launched successfully
- User redirected to Scans page
- Scheduled scan appears in Scans table with correct provider and scan name
- Provider connection validated without launching a scan
- User returned to Providers page
- Provider appears in Providers table with the expected UID
### Key verification points
@@ -998,10 +998,10 @@
- Customer ID help text is visible with instructions on finding the Customer ID
- Service account JSON field accepts multi-line formatted JSON
- Delegated user email field validates email format
- Launch scan page appears
- Successful redirect to Scans page after scan launch
- Provider exists in Scans table (verified by customer ID)
- Scan name field contains "scheduled scan"
- Launch step appears
- Successful return to Providers page after closing the launch step
- Provider exists in Providers table (verified by customer ID)
- Provider UID matches the expected value
### Notes
@@ -1042,16 +1042,16 @@
4. Fill provider details (team ID and alias)
5. Verify Vercel credentials page is loaded
6. Fill Vercel credentials (API token)
7. Launch initial scan
8. Verify redirect to Scans page
9. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan")
7. Confirm provider connection without launching a scan
8. Verify return to Providers page
9. Verify provider exists in Providers table
### Expected Result
- Vercel provider successfully added with API Token credentials
- Initial scan launched successfully
- User redirected to Scans page
- Scheduled scan appears in Scans table with correct provider and scan name
- Provider connection validated without launching a scan
- User returned to Providers page
- Provider appears in Providers table with the expected UID
### Key verification points
@@ -1060,10 +1060,10 @@
- Provider details form accepts team ID and alias
- Credentials page loads with the API Token field
- API Token is properly filled in the correct (masked) field
- Launch scan page appears
- Successful redirect to Scans page after scan launch
- Provider exists in Scans table (verified by team ID)
- Scan name field contains "scheduled scan"
- Launch step appears
- Successful return to Providers page after closing the launch step
- Provider exists in Providers table (verified by team ID)
- Provider UID matches the expected value
### Notes
+79 -186
View File
@@ -37,7 +37,6 @@ import {
VercelProviderCredential,
VERCEL_CREDENTIAL_OPTIONS,
} from "./providers-page";
import { ScansPage } from "../scans/scans-page";
import fs from "fs";
import { deleteProviderIfExists } from "../helpers";
@@ -45,7 +44,6 @@ test.describe("Add Provider", () => {
test.describe.serial("Add AWS Provider", () => {
// Providers page object
let providersPage: ProvidersPage;
let scansPage: ScansPage;
// Test data from environment variables
const accountId = process.env.E2E_AWS_PROVIDER_ACCOUNT_ID ?? "";
const accessKey = process.env.E2E_AWS_PROVIDER_ACCESS_KEY ?? "";
@@ -123,16 +121,10 @@ test.describe("Add Provider", () => {
await providersPage.fillStaticCredentials(staticCredentials);
await providersPage.clickNext();
// Launch scan
await providersPage.verifyLaunchScanPageLoaded();
await providersPage.clickNext();
// Wait for redirect to provider page
scansPage = new ScansPage(page);
await scansPage.verifyPageLoaded();
// Verify scan status is "Scheduled scan"
await scansPage.verifyScheduledScanStatus(accountId);
// Confirm the provider connection without launching a scan
await providersPage.completeProviderConnectionWithoutLaunchingScan(
accountId,
);
},
);
@@ -196,16 +188,10 @@ test.describe("Add Provider", () => {
await providersPage.fillRoleCredentials(roleCredentials);
await providersPage.clickNext();
// Launch scan
await providersPage.verifyLaunchScanPageLoaded();
await providersPage.clickNext();
// Wait for redirect to provider page
scansPage = new ScansPage(page);
await scansPage.verifyPageLoaded();
// Verify scan status is "Scheduled scan"
await scansPage.verifyScheduledScanStatus(accountId);
// Confirm the provider connection without launching a scan
await providersPage.completeProviderConnectionWithoutLaunchingScan(
accountId,
);
},
);
@@ -271,16 +257,10 @@ test.describe("Add Provider", () => {
await providersPage.fillRoleCredentials(roleCredentials);
await providersPage.clickNext();
// Launch scan
await providersPage.verifyLaunchScanPageLoaded();
await providersPage.clickNext();
// Wait for redirect to provider page
scansPage = new ScansPage(page);
await scansPage.verifyPageLoaded();
// Verify scan status is "Scheduled scan"
await scansPage.verifyScheduledScanStatus(accountId);
// Confirm the provider connection without launching a scan
await providersPage.completeProviderConnectionWithoutLaunchingScan(
accountId,
);
},
);
@@ -297,6 +277,14 @@ test.describe("Add Provider", () => {
],
},
async ({ page }) => {
// AWS Organizations (multi-account onboarding) is a Cloud-only feature,
// so this test must never run in the OSS CI. Gate explicitly on the
// Cloud env flag instead of relying on the org env vars being absent.
test.skip(
process.env.NEXT_PUBLIC_IS_CLOUD_ENV !== "true",
"AWS Organizations multi-account onboarding is a Cloud-only feature",
);
if (!organizationId || !organizationRoleArn) {
test.skip(
true,
@@ -343,11 +331,10 @@ test.describe("Add Provider", () => {
await providersPage.clickNext();
await providersPage.verifyOrganizationsLaunchStepLoaded();
await providersPage.chooseOrganizationsScanSchedule("single");
await providersPage.chooseOrganizationsScanSchedule("daily");
await providersPage.clickNext();
scansPage = new ScansPage(page);
await scansPage.verifyPageLoaded();
await providersPage.verifyLoadProviderPageAfterNewProvider();
},
);
});
@@ -355,7 +342,6 @@ test.describe("Add Provider", () => {
test.describe.serial("Add AZURE Provider", () => {
// Providers page object
let providersPage: ProvidersPage;
let scansPage: ScansPage;
// Test data from environment variables
const subscriptionId = process.env.E2E_AZURE_SUBSCRIPTION_ID ?? "";
@@ -422,16 +408,10 @@ test.describe("Add Provider", () => {
await providersPage.fillAZURECredentials(azureCredentials);
await providersPage.clickNext();
// Launch scan
await providersPage.verifyLaunchScanPageLoaded();
await providersPage.clickNext();
// Wait for redirect to scan page
scansPage = new ScansPage(page);
await scansPage.verifyPageLoaded();
// Verify scan status is "Scheduled scan"
await scansPage.verifyScheduledScanStatus(subscriptionId);
// Confirm the provider connection without launching a scan
await providersPage.completeProviderConnectionWithoutLaunchingScan(
subscriptionId,
);
},
);
});
@@ -439,7 +419,6 @@ test.describe("Add Provider", () => {
test.describe.serial("Add M365 Provider", () => {
// Providers page object
let providersPage: ProvidersPage;
let scansPage: ScansPage;
// Test data from environment variables
const domainId = process.env.E2E_M365_DOMAIN_ID ?? "";
@@ -519,16 +498,10 @@ test.describe("Add Provider", () => {
await providersPage.fillM365Credentials(m365Credentials);
await providersPage.clickNext();
// Launch scan
await providersPage.verifyLaunchScanPageLoaded();
await providersPage.clickNext();
// Wait for redirect to scan page
scansPage = new ScansPage(page);
await scansPage.verifyPageLoaded();
// Verify scan status is "Scheduled scan"
await scansPage.verifyScheduledScanStatus(domainId);
// Confirm the provider connection without launching a scan
await providersPage.completeProviderConnectionWithoutLaunchingScan(
domainId,
);
},
);
@@ -596,16 +569,10 @@ test.describe("Add Provider", () => {
await providersPage.fillM365CertificateCredentials(m365Credentials);
await providersPage.clickNext();
// Launch scan
await providersPage.verifyLaunchScanPageLoaded();
await providersPage.clickNext();
// Wait for redirect to scan page
scansPage = new ScansPage(page);
await scansPage.verifyPageLoaded();
// Verify scan status is "Scheduled scan"
await scansPage.verifyScheduledScanStatus(domainId);
// Confirm the provider connection without launching a scan
await providersPage.completeProviderConnectionWithoutLaunchingScan(
domainId,
);
},
);
});
@@ -613,7 +580,6 @@ test.describe("Add Provider", () => {
test.describe.serial("Add Kubernetes Provider", () => {
// Providers page object
let providersPage: ProvidersPage;
let scansPage: ScansPage;
// Test data from environment variables
const context = process.env.E2E_KUBERNETES_CONTEXT ?? "";
@@ -696,16 +662,10 @@ test.describe("Add Provider", () => {
await providersPage.fillKubernetesCredentials(kubernetesCredentials);
await providersPage.clickNext();
// Launch scan
await providersPage.verifyLaunchScanPageLoaded();
await providersPage.clickNext();
// Wait for redirect to provider page
scansPage = new ScansPage(page);
await scansPage.verifyPageLoaded();
// Verify scan status is "Scheduled scan"
await scansPage.verifyScheduledScanStatus(context);
// Confirm the provider connection without launching a scan
await providersPage.completeProviderConnectionWithoutLaunchingScan(
context,
);
},
);
});
@@ -713,7 +673,6 @@ test.describe("Add Provider", () => {
test.describe.serial("Add GCP Provider", () => {
// Providers page object
let providersPage: ProvidersPage;
let scansPage: ScansPage;
// Test data from environment variables
const projectId = process.env.E2E_GCP_PROJECT_ID ?? "";
@@ -802,16 +761,10 @@ test.describe("Add Provider", () => {
await providersPage.fillGCPServiceAccountKeyCredentials(gcpCredentials);
await providersPage.clickNext();
// Launch scan
await providersPage.verifyLaunchScanPageLoaded();
await providersPage.clickNext();
// Wait for redirect to scan page
scansPage = new ScansPage(page);
await scansPage.verifyPageLoaded();
// Verify scan status is "Scheduled scan"
await scansPage.verifyScheduledScanStatus(projectId);
// Confirm the provider connection without launching a scan
await providersPage.completeProviderConnectionWithoutLaunchingScan(
projectId,
);
},
);
});
@@ -819,7 +772,6 @@ test.describe("Add Provider", () => {
test.describe.serial("Add GitHub Provider", () => {
// Providers page object
let providersPage: ProvidersPage;
let scansPage: ScansPage;
test.describe("Add GitHub provider with username", () => {
const username = process.env.E2E_GITHUB_USERNAME ?? "";
@@ -899,16 +851,10 @@ test.describe("Add Provider", () => {
);
await providersPage.clickNext();
// Launch scan
await providersPage.verifyLaunchScanPageLoaded();
await providersPage.clickNext();
// Wait for redirect to scan page
scansPage = new ScansPage(page);
await scansPage.verifyPageLoaded();
// Verify scan status is "Scheduled scan"
await scansPage.verifyScheduledScanStatus(username);
// Confirm the provider connection without launching a scan
await providersPage.completeProviderConnectionWithoutLaunchingScan(
username,
);
},
);
test(
@@ -981,16 +927,10 @@ test.describe("Add Provider", () => {
await providersPage.fillGitHubAppCredentials(githubCredentials);
await providersPage.clickNext();
// Launch scan
await providersPage.verifyLaunchScanPageLoaded();
await providersPage.clickNext();
// Wait for redirect to scan page
scansPage = new ScansPage(page);
await scansPage.verifyPageLoaded();
// Verify scan status is "Scheduled scan"
await scansPage.verifyScheduledScanStatus(username);
// Confirm the provider connection without launching a scan
await providersPage.completeProviderConnectionWithoutLaunchingScan(
username,
);
},
);
});
@@ -1071,16 +1011,10 @@ test.describe("Add Provider", () => {
);
await providersPage.clickNext();
// Launch scan
await providersPage.verifyLaunchScanPageLoaded();
await providersPage.clickNext();
// Wait for redirect to scan page
scansPage = new ScansPage(page);
await scansPage.verifyPageLoaded();
// Verify scan status is "Scheduled scan"
await scansPage.verifyScheduledScanStatus(organization);
// Confirm the provider connection without launching a scan
await providersPage.completeProviderConnectionWithoutLaunchingScan(
organization,
);
},
);
});
@@ -1089,7 +1023,6 @@ test.describe("Add Provider", () => {
test.describe.serial("Add OCI Provider", () => {
// Providers page object
let providersPage: ProvidersPage;
let scansPage: ScansPage;
// Test data from environment variables
const tenancyId = process.env.E2E_OCI_TENANCY_ID ?? "";
@@ -1162,16 +1095,10 @@ test.describe("Add Provider", () => {
await providersPage.fillOCICredentials(ociCredentials);
await providersPage.clickNext();
// Launch scan
await providersPage.verifyLaunchScanPageLoaded();
await providersPage.clickNext();
// Wait for redirect to scan page
scansPage = new ScansPage(page);
await scansPage.verifyPageLoaded();
// Verify scan status is "Scheduled scan"
await scansPage.verifyScheduledScanStatus(tenancyId);
// Confirm the provider connection without launching a scan
await providersPage.completeProviderConnectionWithoutLaunchingScan(
tenancyId,
);
},
);
});
@@ -1179,7 +1106,6 @@ test.describe("Add Provider", () => {
test.describe.serial("Add AlibabaCloud Provider", () => {
// Providers page object
let providersPage: ProvidersPage;
let scansPage: ScansPage;
// Test data from environment variables
const accountId = process.env.E2E_ALIBABACLOUD_ACCOUNT_ID ?? "";
@@ -1265,16 +1191,10 @@ test.describe("Add Provider", () => {
);
await providersPage.clickNext();
// Launch scan
await providersPage.verifyLaunchScanPageLoaded();
await providersPage.clickNext();
// Wait for redirect to scan page
scansPage = new ScansPage(page);
await scansPage.verifyPageLoaded();
// Verify scan status is "Scheduled scan"
await scansPage.verifyScheduledScanStatus(accountId);
// Confirm the provider connection without launching a scan
await providersPage.completeProviderConnectionWithoutLaunchingScan(
accountId,
);
},
);
@@ -1344,23 +1264,16 @@ test.describe("Add Provider", () => {
await providersPage.fillAlibabaCloudRoleCredentials(roleCredentials);
await providersPage.clickNext();
// Launch scan
await providersPage.verifyLaunchScanPageLoaded();
await providersPage.clickNext();
// Wait for redirect to scan page
scansPage = new ScansPage(page);
await scansPage.verifyPageLoaded();
// Verify scan status is "Scheduled scan"
await scansPage.verifyScheduledScanStatus(accountId);
// Confirm the provider connection without launching a scan
await providersPage.completeProviderConnectionWithoutLaunchingScan(
accountId,
);
},
);
});
test.describe.serial("Add Google Workspace Provider", () => {
let providersPage: ProvidersPage;
let scansPage: ScansPage;
const customerId = process.env.E2E_GOOGLEWORKSPACE_CUSTOMER_ID ?? "";
const serviceAccountJson =
@@ -1428,23 +1341,16 @@ test.describe("Add Provider", () => {
);
await providersPage.clickNext();
// Launch scan
await providersPage.verifyLaunchScanPageLoaded();
await providersPage.clickNext();
// Wait for redirect to scan page
scansPage = new ScansPage(page);
await scansPage.verifyPageLoaded();
// Verify scan status is "Scheduled scan"
await scansPage.verifyScheduledScanStatus(customerId);
// Confirm the provider connection without launching a scan
await providersPage.completeProviderConnectionWithoutLaunchingScan(
customerId,
);
},
);
});
test.describe.serial("Add Vercel Provider", () => {
let providersPage: ProvidersPage;
let scansPage: ScansPage;
// Test data from environment variables
const teamId = process.env.E2E_VERCEL_TEAM_ID ?? "";
@@ -1507,27 +1413,20 @@ test.describe("Add Provider", () => {
await providersPage.fillVercelCredentials(vercelCredentials);
await providersPage.clickNext();
// Launch scan
await providersPage.verifyLaunchScanPageLoaded();
await providersPage.clickNext();
// Wait for redirect to scan page
scansPage = new ScansPage(page);
await scansPage.verifyPageLoaded();
// Verify scan status is "Scheduled scan"
await scansPage.verifyScheduledScanStatus(teamId);
// Confirm the provider connection without launching a scan
await providersPage.completeProviderConnectionWithoutLaunchingScan(
teamId,
);
},
);
});
test.describe.serial("Add Okta Provider", () => {
let providersPage: ProvidersPage;
let scansPage: ScansPage;
// Test data from environment variables
// Org Domain is lowercased by the form, so normalize here to match the
// stored provider UID used for cleanup and scan verification.
// stored provider UID used for cleanup.
const orgDomain = (process.env.E2E_OKTA_DOMAIN ?? "").toLowerCase();
const clientId = process.env.E2E_OKTA_CLIENT_ID ?? "";
const privateKeyB64 = process.env.E2E_OKTA_BASE64_PRIVATE_KEY ?? "";
@@ -1599,16 +1498,10 @@ test.describe("Add Provider", () => {
await providersPage.fillOktaCredentials(oktaCredentials);
await providersPage.clickNext();
// Launch scan
await providersPage.verifyLaunchScanPageLoaded();
await providersPage.clickNext();
// Wait for redirect to scan page
scansPage = new ScansPage(page);
await scansPage.verifyPageLoaded();
// Verify scan status is "Scheduled scan"
await scansPage.verifyScheduledScanStatus(orgDomain);
// Confirm the provider connection without launching a scan
await providersPage.completeProviderConnectionWithoutLaunchingScan(
orgDomain,
);
},
);
});
+2 -1
View File
@@ -21,6 +21,7 @@
- Admin user authentication required (admin.auth.setup setup)
- Environment variables configured for : E2E_AWS_PROVIDER_ACCOUNT_ID,E2E_AWS_PROVIDER_ACCESS_KEY and E2E_AWS_PROVIDER_SECRET_KEY
- Remove any existing AWS provider with the same Account ID before starting the test
- Create a connected AWS provider without launching a preparatory scan
- This test must be run serially and never in parallel with other tests, as it requires the Account ID Provider to be already registered.
### Flow Steps
@@ -55,5 +56,5 @@
- The scans view is tabbed (In Progress / Completed / Scheduled) and `/scans` defaults to Completed. Each empty tab renders an empty-state card instead of a table, so page-loaded checks assert the tabs shell, not the table.
- The table may take a short time to reflect the new scan; assertions look for a row containing the account ID.
- Provider cleanup performed before each test to ensure clean state
- Provider cleanup and provider connection setup are performed before each test to ensure clean state
- Tests should run serially to avoid state conflicts.
+1
View File
@@ -10,5 +10,6 @@ export * from "./providers";
export * from "./providers-table";
export * from "./resources";
export * from "./scans";
export * from "./schedules";
export * from "./tasks";
export * from "./tree";
+3
View File
@@ -5,6 +5,7 @@ import {
OrganizationUnitResource,
} from "./organizations";
import { ProviderProps } from "./providers";
import { ScanScheduleSummary } from "./scans";
export const PROVIDERS_ROW_TYPE = {
ORGANIZATION: "organization",
@@ -52,6 +53,8 @@ export interface ProvidersProviderRow
relationships: ProviderTableRelationships;
groupNames: string[];
hasSchedule: boolean;
/** Cadence/next-run summary when the provider has a configured schedule. */
scheduleSummary?: ScanScheduleSummary;
subRows?: ProvidersTableRow[];
}
+13
View File
@@ -83,12 +83,25 @@ export interface ScanProviderInfo {
connected: boolean;
}
/** Cadence summary computed from `/schedules`, e.g. "Weekly on Monday @ 9:00am". */
export interface ScanScheduleSummary {
summary: string;
/** e.g. "Weekly on Monday" */
cadence?: string;
nextScanAt?: string | null;
lastScanAt?: string | null;
}
export interface ScanProps {
type: "scans";
id: string;
attributes: ScanAttributes;
relationships: ScanRelationships;
providerInfo?: ScanResultProviderInfo;
/** Only on synthetic Scheduled-tab rows for schedules that have not fired yet. */
pendingSchedule?: ScanScheduleSummary;
/** Current schedule cadence of the scan's provider, when configured. */
providerSchedule?: ScanScheduleSummary;
}
export interface ScanEntityProviderInfo {
+97
View File
@@ -0,0 +1,97 @@
import type { ProviderProps } from "./providers";
export const SCHEDULE_FREQUENCY = {
DAILY: "DAILY",
INTERVAL: "INTERVAL",
WEEKLY: "WEEKLY",
MONTHLY: "MONTHLY",
} as const;
export type ScheduleFrequency =
(typeof SCHEDULE_FREQUENCY)[keyof typeof SCHEDULE_FREQUENCY];
/** Weekday names indexed by cron convention (0=Sunday). */
export const SCHEDULE_WEEKDAY_LABELS = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
] as const;
/**
* Scan-schedule capability modes. In Prowler OSS this is resolved purely from
* the runtime environment (Cloud vs non-Cloud); the prowler-cloud overlay
* computes a billing-aware capability and injects it via the `capability` prop.
*
* - `ADVANCED`: full scheduling through the new `/schedules/{providerId}` API
* (Prowler Cloud, subscribed/paid).
* - `DAILY_LEGACY`: Prowler OSS / non-Cloud. Only the legacy `Daily` schedule
* (`/schedules/daily`) plus optional on-demand scans are allowed.
* - `MANUAL_ONLY`: Prowler Cloud trial/onboarding. No schedules at all, only a
* manual on-demand scan subject to the account quota.
*/
export const SCAN_SCHEDULE_CAPABILITY = {
ADVANCED: "ADVANCED",
DAILY_LEGACY: "DAILY_LEGACY",
MANUAL_ONLY: "MANUAL_ONLY",
} as const;
export type ScanScheduleCapability =
(typeof SCAN_SCHEDULE_CAPABILITY)[keyof typeof SCAN_SCHEDULE_CAPABILITY];
export interface ScheduleAttributes {
scan_enabled: boolean;
scan_frequency: ScheduleFrequency;
scan_hour: number | null;
scan_timezone: string;
scan_interval_hours: number | null;
scan_day_of_week: number | null;
scan_day_of_month: number | null;
/** Read-only, server-computed next fire time; null when paused/unconfigured. */
next_scan_at?: string | null;
/** Read-only, completed_at of the provider's last completed scan. */
last_scan_at?: string | null;
}
export interface ScheduleRelationships {
provider: {
data: {
type: "providers";
id: string;
};
};
}
export interface ScheduleProps {
type: "schedules";
id: string;
attributes: ScheduleAttributes;
relationships: ScheduleRelationships;
}
export interface ScheduleApiResponse {
data: ScheduleProps;
included?: ProviderProps[];
}
export interface ScheduleUpdatePayload {
scan_enabled: boolean;
scan_frequency: ScheduleFrequency;
scan_hour: number;
scan_timezone: string;
scan_interval_hours: number | null;
scan_day_of_week: number | null;
scan_day_of_month: number | null;
}
export interface ScheduleFormValues {
frequency: ScheduleFrequency;
hour: number;
dayOfWeek: number;
dayOfMonth: number;
intervalHours: number;
launchInitialScan: boolean;
}