mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 13:01:56 +00:00
feat(ui): source scheduled scans tab from /schedules endpoint (#11670)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,7 @@ vi.mock("@/lib/server-actions-helper", () => ({
|
||||
|
||||
import {
|
||||
getSchedule,
|
||||
getSchedulesPage,
|
||||
removeSchedule,
|
||||
updateSchedule,
|
||||
updateSchedulesBulk,
|
||||
@@ -212,3 +213,53 @@ describe("schedule write actions revalidate only on success", () => {
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSchedulesPage delegates pagination to the endpoint", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
fetchMock.mockResolvedValue(new Response(null, { status: 200 }));
|
||||
handleApiErrorMock.mockReturnValue({ error: "Failed" });
|
||||
});
|
||||
|
||||
it("requests only configured schedules with native pagination and include", async () => {
|
||||
handleApiResponseMock.mockResolvedValue({
|
||||
data: [],
|
||||
included: [],
|
||||
meta: { pagination: { page: 2, pages: 4, count: 35 } },
|
||||
});
|
||||
|
||||
const result = await getSchedulesPage({
|
||||
page: 2,
|
||||
pageSize: 10,
|
||||
sort: "next_scan_at",
|
||||
filters: { "filter[provider_type__in]": "aws,azure" },
|
||||
});
|
||||
|
||||
const calledUrl = new URL(fetchMock.mock.calls[0][0] as string);
|
||||
expect(calledUrl.pathname).toBe("/api/v1/schedules");
|
||||
expect(calledUrl.searchParams.get("filter[configured]")).toBe("true");
|
||||
expect(calledUrl.searchParams.get("include")).toBe("provider");
|
||||
expect(calledUrl.searchParams.get("page[number]")).toBe("2");
|
||||
expect(calledUrl.searchParams.get("page[size]")).toBe("10");
|
||||
expect(calledUrl.searchParams.get("sort")).toBe("next_scan_at");
|
||||
expect(calledUrl.searchParams.get("filter[provider_type__in]")).toBe(
|
||||
"aws,azure",
|
||||
);
|
||||
// meta is propagated verbatim so the table paginates natively.
|
||||
expect(result?.meta?.pagination?.count).toBe(35);
|
||||
});
|
||||
|
||||
it("normalizes array filter values into a CSV param", async () => {
|
||||
handleApiResponseMock.mockResolvedValue({ data: [], meta: {} });
|
||||
|
||||
await getSchedulesPage({
|
||||
filters: { "filter[provider__in]": ["id-1", "id-2"] },
|
||||
});
|
||||
|
||||
const calledUrl = new URL(fetchMock.mock.calls[0][0] as string);
|
||||
expect(calledUrl.searchParams.get("filter[provider__in]")).toBe(
|
||||
"id-1,id-2",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,6 +81,41 @@ export const getSchedules = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
/** Fetches one page of configured schedules for the Scheduled tab, with native pagination. */
|
||||
export const getSchedulesPage = async ({
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
sort = "",
|
||||
filters = {},
|
||||
}: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sort?: string;
|
||||
filters?: Record<string, string | string[]>;
|
||||
} = {}) => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(`${apiBaseUrl}/schedules`);
|
||||
|
||||
url.searchParams.set("filter[configured]", "true");
|
||||
url.searchParams.set("include", "provider");
|
||||
url.searchParams.set("page[number]", String(page));
|
||||
url.searchParams.set("page[size]", String(pageSize));
|
||||
if (sort) url.searchParams.set("sort", sort);
|
||||
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
const normalized = Array.isArray(value) ? value.join(",") : value;
|
||||
if (normalized) url.searchParams.set(key, normalized);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { headers });
|
||||
|
||||
return handleApiResponse(response);
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSchedule = async (
|
||||
providerId: string,
|
||||
payload: ScheduleUpdatePayload,
|
||||
|
||||
@@ -2,6 +2,8 @@ import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { FilterType } from "@/types/filters";
|
||||
|
||||
import { AccountsSelector } from "./accounts-selector";
|
||||
|
||||
const multiSelectContentSpy = vi.fn();
|
||||
@@ -170,7 +172,10 @@ describe("AccountsSelector", () => {
|
||||
|
||||
it("can use provider UID values for pages whose API filters by provider_uid__in", () => {
|
||||
render(
|
||||
<AccountsSelector providers={providers} filterKey="provider_uid__in" />,
|
||||
<AccountsSelector
|
||||
providers={providers}
|
||||
filterKey={FilterType.PROVIDER_UID}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
|
||||
@@ -17,25 +17,18 @@ import {
|
||||
MultiSelectValue,
|
||||
} from "@/components/shadcn/select/multiselect";
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
import { type AccountFilterKey, FilterType } from "@/types/filters";
|
||||
import {
|
||||
getProviderDisplayName,
|
||||
type ProviderProps,
|
||||
type ProviderType,
|
||||
} from "@/types/providers";
|
||||
|
||||
const ACCOUNT_SELECTOR_FILTER = {
|
||||
PROVIDER_ID: "provider_id__in",
|
||||
PROVIDER_UID: "provider_uid__in",
|
||||
} as const;
|
||||
|
||||
type AccountSelectorFilter =
|
||||
(typeof ACCOUNT_SELECTOR_FILTER)[keyof typeof ACCOUNT_SELECTOR_FILTER];
|
||||
|
||||
/** Common props shared by both batch and instant modes. */
|
||||
interface AccountsSelectorBaseProps {
|
||||
providers: ProviderProps[];
|
||||
search?: MultiSelectSearchProp;
|
||||
filterKey?: AccountSelectorFilter;
|
||||
filterKey?: AccountFilterKey;
|
||||
id?: string;
|
||||
disabledValues?: string[];
|
||||
closeOnSelect?: boolean;
|
||||
@@ -72,7 +65,7 @@ export function AccountsSelector({
|
||||
providers,
|
||||
onBatchChange,
|
||||
selectedValues,
|
||||
filterKey = ACCOUNT_SELECTOR_FILTER.PROVIDER_ID,
|
||||
filterKey = FilterType.PROVIDER_ID,
|
||||
id = "accounts-selector",
|
||||
disabledValues = [],
|
||||
search = {
|
||||
@@ -92,7 +85,7 @@ export function AccountsSelector({
|
||||
|
||||
const visibleProviders = providers;
|
||||
const getProviderValue = (provider: ProviderProps) =>
|
||||
filterKey === ACCOUNT_SELECTOR_FILTER.PROVIDER_UID
|
||||
filterKey === FilterType.PROVIDER_UID
|
||||
? provider.attributes.uid
|
||||
: provider.id;
|
||||
const disabledValuesSet = new Set(disabledValues);
|
||||
|
||||
@@ -21,3 +21,20 @@ describe("scans page onboarding", () => {
|
||||
expect(source).toContain("onboardingAction={onboardingAction}");
|
||||
});
|
||||
});
|
||||
|
||||
describe("scans page scheduled tab source", () => {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const pagePath = path.join(currentDir, "page.tsx");
|
||||
const source = readFileSync(pagePath, "utf8");
|
||||
|
||||
it("sources the Scheduled tab from /schedules only for the advanced capability", () => {
|
||||
expect(source).toContain("getSchedulesPage");
|
||||
expect(source).toContain("SCAN_SCHEDULE_CAPABILITY.ADVANCED");
|
||||
expect(source).toContain("tab === SCAN_JOBS_TAB.SCHEDULED");
|
||||
});
|
||||
|
||||
it("maps schedule resources to rows and delegates pagination to the endpoint", () => {
|
||||
expect(source).toContain("buildScheduledTabRows");
|
||||
expect(source).toContain("pickScheduleProviderFilters");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,15 +3,17 @@ import { Suspense } from "react";
|
||||
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import { getScans } from "@/actions/scans";
|
||||
import { getSchedules } from "@/actions/schedules";
|
||||
import { getSchedules, getSchedulesPage } from "@/actions/schedules";
|
||||
import { auth } from "@/auth.config";
|
||||
import { PageReady } from "@/components/onboarding";
|
||||
import {
|
||||
appendPendingScheduleRowsToPage,
|
||||
buildScheduledTabRows,
|
||||
getProviderIdsFromScans,
|
||||
getScanJobsTab,
|
||||
getScanJobsTabFilters,
|
||||
getScanJobsUserFilters,
|
||||
pickScheduleProviderFilters,
|
||||
} from "@/components/scans/scans.utils";
|
||||
import { ScansPageShell } from "@/components/scans/scans-page-shell";
|
||||
import { ScansProvidersEmptyState } from "@/components/scans/scans-providers-empty-state";
|
||||
@@ -21,25 +23,31 @@ import { ContentLayout } from "@/components/ui";
|
||||
import {
|
||||
buildProviderScheduleSummary,
|
||||
buildSchedulesByProviderId,
|
||||
getScanScheduleCapability,
|
||||
isScheduleConfigured,
|
||||
} from "@/lib/schedules";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import {
|
||||
FilterType,
|
||||
ProviderProps,
|
||||
SCAN_JOBS_TAB,
|
||||
SCAN_TRIGGER,
|
||||
ScanProps,
|
||||
SearchParamsProps,
|
||||
} from "@/types";
|
||||
import type { ScanScheduleCapability } from "@/types/schedules";
|
||||
import {
|
||||
SCAN_SCHEDULE_CAPABILITY,
|
||||
type ScanScheduleCapability,
|
||||
} from "@/types/schedules";
|
||||
|
||||
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.
|
||||
// Pending schedule rows must honor the same provider filters as real scan rows.
|
||||
// The `__in` keys reuse the shared FilterType; the singular variants have no
|
||||
// FilterType equivalent, so they stay as literals.
|
||||
const PENDING_ROW_PROVIDER_FILTER = {
|
||||
PROVIDER_UID_IN: "provider_uid__in",
|
||||
PROVIDER_UID: "provider_uid",
|
||||
PROVIDER_TYPE_IN: "provider_type__in",
|
||||
PROVIDER_IN: FilterType.PROVIDER,
|
||||
PROVIDER: "provider",
|
||||
PROVIDER_TYPE_IN: FilterType.PROVIDER_TYPE,
|
||||
PROVIDER_TYPE: "provider_type",
|
||||
} as const;
|
||||
|
||||
@@ -47,9 +55,9 @@ 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}]`,
|
||||
const PROVIDER_ID_FILTER_KEYS = [
|
||||
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_IN}]`,
|
||||
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER}]`,
|
||||
] as const satisfies ReadonlyArray<PendingRowProviderFilterParam>;
|
||||
|
||||
const PROVIDER_TYPE_FILTER_KEYS = [
|
||||
@@ -93,8 +101,8 @@ const filterProvidersForPendingRows = (
|
||||
providers: ProviderProps[],
|
||||
searchParams: SearchParamsProps,
|
||||
): ProviderProps[] => {
|
||||
const uids = parseCsvParam(
|
||||
getFirstSearchParam(searchParams, PROVIDER_UID_FILTER_KEYS),
|
||||
const ids = parseCsvParam(
|
||||
getFirstSearchParam(searchParams, PROVIDER_ID_FILTER_KEYS),
|
||||
);
|
||||
const types = parseCsvParam(
|
||||
getFirstSearchParam(searchParams, PROVIDER_TYPE_FILTER_KEYS),
|
||||
@@ -102,7 +110,7 @@ const filterProvidersForPendingRows = (
|
||||
|
||||
return providers.filter(
|
||||
(provider) =>
|
||||
(uids.length === 0 || uids.includes(provider.attributes.uid)) &&
|
||||
(ids.length === 0 || ids.includes(provider.id)) &&
|
||||
(types.length === 0 || types.includes(provider.attributes.provider)),
|
||||
);
|
||||
};
|
||||
@@ -272,6 +280,33 @@ const SSRDataTableScans = async ({
|
||||
|
||||
const query = (filters["filter[search]"] as string) || "";
|
||||
|
||||
// Advanced (Cloud) sources the Scheduled tab from /schedules; other envs keep the legacy /scans path.
|
||||
const capability =
|
||||
scanScheduleCapability ?? getScanScheduleCapability(isCloud());
|
||||
|
||||
if (
|
||||
tab === SCAN_JOBS_TAB.SCHEDULED &&
|
||||
capability === SCAN_SCHEDULE_CAPABILITY.ADVANCED
|
||||
) {
|
||||
const schedulesPage = await getSchedulesPage({
|
||||
page,
|
||||
pageSize,
|
||||
sort,
|
||||
filters: pickScheduleProviderFilters(searchParams),
|
||||
});
|
||||
const { data, meta } = buildScheduledTabRows(schedulesPage, new Date());
|
||||
|
||||
return (
|
||||
<ScanJobsTable
|
||||
data={data}
|
||||
meta={meta}
|
||||
tab={tab}
|
||||
hasFilters={hasUserFilters}
|
||||
scanScheduleCapability={capability}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const scansData = await getScans({
|
||||
query,
|
||||
page,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { FilterType } from "@/types/filters";
|
||||
import type { ProviderProps } from "@/types/providers";
|
||||
|
||||
import { ProviderAccountSelectors } from "./provider-account-selectors";
|
||||
@@ -170,7 +171,7 @@ describe("ProviderAccountSelectors", () => {
|
||||
render(
|
||||
<ProviderAccountSelectors
|
||||
providers={providers}
|
||||
accountFilterKey="provider_uid__in"
|
||||
accountFilterKey={FilterType.PROVIDER_UID}
|
||||
accountValue="uid"
|
||||
paramsToDeleteOnChange={["page", "scanId"]}
|
||||
/>,
|
||||
@@ -229,7 +230,7 @@ describe("ProviderAccountSelectors", () => {
|
||||
<ProviderAccountSelectors
|
||||
providers={providers}
|
||||
mode="batch"
|
||||
accountFilterKey="provider_uid__in"
|
||||
accountFilterKey={FilterType.PROVIDER_UID}
|
||||
accountValue="uid"
|
||||
selectedProviderTypes={["aws"]}
|
||||
selectedAccounts={["123456789012", "prowler-project"]}
|
||||
|
||||
@@ -5,20 +5,14 @@ import { useSearchParams } from "next/navigation";
|
||||
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
|
||||
import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector";
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
import { type AccountFilterKey, FilterType } from "@/types/filters";
|
||||
import type { ProviderProps } from "@/types/providers";
|
||||
|
||||
const ACCOUNT_FILTER_KEY = {
|
||||
PROVIDER_ID: "provider_id__in",
|
||||
PROVIDER_UID: "provider_uid__in",
|
||||
} as const;
|
||||
|
||||
const ACCOUNT_VALUE = {
|
||||
ID: "id",
|
||||
UID: "uid",
|
||||
} as const;
|
||||
|
||||
type AccountFilterKey =
|
||||
(typeof ACCOUNT_FILTER_KEY)[keyof typeof ACCOUNT_FILTER_KEY];
|
||||
type AccountValue = (typeof ACCOUNT_VALUE)[keyof typeof ACCOUNT_VALUE];
|
||||
|
||||
interface ProviderAccountSelectorsBaseProps {
|
||||
@@ -97,7 +91,7 @@ const getCompatibleAccounts = ({
|
||||
|
||||
export function ProviderAccountSelectors({
|
||||
providers,
|
||||
accountFilterKey = ACCOUNT_FILTER_KEY.PROVIDER_ID,
|
||||
accountFilterKey = FilterType.PROVIDER_ID,
|
||||
accountValue = ACCOUNT_VALUE.ID,
|
||||
providerSelectorClassName,
|
||||
accountSelectorClassName,
|
||||
|
||||
@@ -248,14 +248,14 @@ describe("DataTableRowActions", () => {
|
||||
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.
|
||||
// (provider__in, by id), URL-encoded, so the provider is pre-selected.
|
||||
expect(pushMock).toHaveBeenCalledWith(
|
||||
"/scans?filter%5Bprovider_uid__in%5D=111111111111",
|
||||
"/scans?filter%5Bprovider__in%5D=provider-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("URL-encodes provider UIDs that contain unsafe characters (e.g. GitHub)", async () => {
|
||||
// Given a GitHub provider whose UID is a URL.
|
||||
it("links to scan jobs by provider id even when the uid contains unsafe chars", async () => {
|
||||
// Given a GitHub provider whose UID is a URL with unsafe chars.
|
||||
const user = userEvent.setup();
|
||||
const row = createRow(true);
|
||||
(
|
||||
@@ -278,9 +278,9 @@ describe("DataTableRowActions", () => {
|
||||
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.
|
||||
// Then the link carries the provider id, never the raw uid.
|
||||
expect(pushMock).toHaveBeenCalledWith(
|
||||
"/scans?filter%5Bprovider_uid__in%5D=https%3A%2F%2Fgithub.com%2Fprowler-cloud%2Fprowler",
|
||||
"/scans?filter%5Bprovider__in%5D=provider-1",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -559,12 +559,11 @@ export function DataTableRowActions({
|
||||
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).
|
||||
// Same key the scans filter bar binds to (`provider__in`, by id) so
|
||||
// the provider is pre-selected and the filter works on every tab,
|
||||
// including Scheduled (whose endpoint only accepts provider id).
|
||||
const params = new URLSearchParams({
|
||||
"filter[provider_uid__in]": providerUid,
|
||||
"filter[provider__in]": providerId,
|
||||
});
|
||||
router.push(`/scans?${params.toString()}`);
|
||||
}}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/shadcn";
|
||||
import { SCAN_JOBS_TAB, type ScanJobsTab } from "@/types";
|
||||
import { FilterType } from "@/types/filters";
|
||||
import type { ProviderProps } from "@/types/providers";
|
||||
|
||||
import {
|
||||
@@ -46,8 +47,8 @@ export function ScansFilterBar({
|
||||
<>
|
||||
<ProviderAccountSelectors
|
||||
providers={providers}
|
||||
accountFilterKey="provider_uid__in"
|
||||
accountValue="uid"
|
||||
accountFilterKey={FilterType.PROVIDER}
|
||||
accountValue="id"
|
||||
paramsToDeleteOnChange={["page", "scanId"]}
|
||||
providerSelectorClassName={filterItemClass}
|
||||
accountSelectorClassName={filterItemClass}
|
||||
|
||||
@@ -7,11 +7,13 @@ import {
|
||||
type ScanAttributes,
|
||||
type ScanProps,
|
||||
type ScanTrigger,
|
||||
type ScheduleProps,
|
||||
} from "@/types";
|
||||
|
||||
import {
|
||||
appendPendingScheduleRowsToPage,
|
||||
buildPendingScheduleRows,
|
||||
buildScheduledTabRows,
|
||||
formatScanDuration,
|
||||
getScanAlias,
|
||||
getScanFindingsSummary,
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
getScanScheduleLabel,
|
||||
getScanStatusLabel,
|
||||
getScanTriggerFilterOptions,
|
||||
mapScheduleToScanRow,
|
||||
} from "./scans.utils";
|
||||
|
||||
const makeScan = (
|
||||
@@ -394,4 +397,89 @@ describe("buildPendingScheduleRows", () => {
|
||||
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe("mapScheduleToScanRow", () => {
|
||||
const makeSchedule = (
|
||||
attributes: Partial<ScheduleProps["attributes"]> = {},
|
||||
): ScheduleProps => ({
|
||||
type: "schedules",
|
||||
id: "p1",
|
||||
attributes: {
|
||||
...weeklySchedule,
|
||||
next_scan_at: "2026-06-15T00:00:00Z",
|
||||
last_scan_at: "2026-06-01T10:00:00Z",
|
||||
...attributes,
|
||||
},
|
||||
relationships: {
|
||||
provider: { data: { type: "providers", id: "p1" } },
|
||||
},
|
||||
});
|
||||
|
||||
it("maps a configured schedule to a Scheduled-tab row", () => {
|
||||
const row = mapScheduleToScanRow(makeSchedule(), makeProvider("p1"), now);
|
||||
|
||||
expect(row.id).toBe("schedule-p1");
|
||||
expect(row.attributes.trigger).toBe("scheduled");
|
||||
expect(row.attributes.state).toBe("scheduled");
|
||||
expect(row.attributes.scheduled_at).toBe("2026-06-15T00:00:00Z");
|
||||
expect(row.pendingSchedule?.summary).toBe(
|
||||
"Weekly on Monday @ 9:00am (Europe/Madrid)",
|
||||
);
|
||||
expect(row.pendingSchedule?.cadence).toBe("Weekly on Monday");
|
||||
expect(row.pendingSchedule?.lastScanAt).toBe("2026-06-01T10:00:00Z");
|
||||
expect(row.providerInfo?.uid).toBe("uid-p1");
|
||||
// The schedule id IS the provider id.
|
||||
expect(row.relationships.provider.data?.id).toBe("p1");
|
||||
});
|
||||
|
||||
it("leaves providerInfo undefined when the provider is missing from included", () => {
|
||||
const row = mapScheduleToScanRow(makeSchedule(), undefined, now);
|
||||
|
||||
expect(row.providerInfo).toBeUndefined();
|
||||
});
|
||||
|
||||
it("shows no next run for a paused schedule", () => {
|
||||
const row = mapScheduleToScanRow(
|
||||
makeSchedule({ scan_enabled: false, next_scan_at: null }),
|
||||
makeProvider("p1"),
|
||||
now,
|
||||
);
|
||||
|
||||
expect(row.attributes.scheduled_at).toBeNull();
|
||||
expect(row.pendingSchedule?.nextScanAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildScheduledTabRows", () => {
|
||||
const schedule: ScheduleProps = {
|
||||
type: "schedules",
|
||||
id: "p1",
|
||||
attributes: {
|
||||
...weeklySchedule,
|
||||
next_scan_at: "2026-06-15T00:00:00Z",
|
||||
last_scan_at: null,
|
||||
},
|
||||
relationships: { provider: { data: { type: "providers", id: "p1" } } },
|
||||
};
|
||||
|
||||
it("maps schedule data and passes meta through verbatim", () => {
|
||||
const meta = makeMeta({ page: 1, pages: 3, count: 25 });
|
||||
|
||||
const result = buildScheduledTabRows(
|
||||
{ data: [schedule], included: [makeProvider("p1")], meta },
|
||||
now,
|
||||
);
|
||||
|
||||
expect(result.data.map((row) => row.id)).toEqual(["schedule-p1"]);
|
||||
expect(result.data[0].providerInfo?.uid).toBe("uid-p1");
|
||||
expect(result.meta).toBe(meta);
|
||||
});
|
||||
|
||||
it("returns an empty result on error or missing data", () => {
|
||||
expect(buildScheduledTabRows({ error: "boom" }, now)).toEqual({
|
||||
data: [],
|
||||
});
|
||||
expect(buildScheduledTabRows(null, now)).toEqual({ data: [] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import {
|
||||
PROVIDER_IN_FILTER_KEY,
|
||||
PROVIDER_TYPE_IN_FILTER_KEY,
|
||||
} from "@/lib/provider-filters";
|
||||
import {
|
||||
buildProviderScheduleSummary,
|
||||
describeScheduleCadence,
|
||||
getNextScheduledRunInTimezone,
|
||||
getScheduleCadenceParts,
|
||||
@@ -18,6 +23,7 @@ import {
|
||||
type ScanState,
|
||||
type ScanTrigger,
|
||||
type ScheduleAttributes,
|
||||
type ScheduleProps,
|
||||
type SearchParamsProps,
|
||||
} from "@/types";
|
||||
|
||||
@@ -324,6 +330,97 @@ export function appendPendingScheduleRowsToPage({
|
||||
};
|
||||
}
|
||||
|
||||
// Provider filters the `/schedules` endpoint accepts (by id / type). The scans
|
||||
// filter-bar's uid filter is not supported there, so it is intentionally dropped.
|
||||
const SCHEDULE_FORWARDED_FILTER_KEYS = [
|
||||
PROVIDER_IN_FILTER_KEY,
|
||||
PROVIDER_TYPE_IN_FILTER_KEY,
|
||||
] as const;
|
||||
|
||||
/** Provider filters forwarded to `/schedules` so the backend applies them and pagination stays native. */
|
||||
export function pickScheduleProviderFilters(
|
||||
searchParams: SearchParamsProps,
|
||||
): Record<string, string | string[]> {
|
||||
const filters: Record<string, string | string[]> = {};
|
||||
for (const key of SCHEDULE_FORWARDED_FILTER_KEYS) {
|
||||
const value = searchParams[key];
|
||||
if (typeof value === "string" || Array.isArray(value)) {
|
||||
filters[key] = value;
|
||||
}
|
||||
}
|
||||
return filters;
|
||||
}
|
||||
|
||||
interface SchedulesPageResult {
|
||||
data?: ScheduleProps[] | null;
|
||||
included?: { type: string; id: string }[];
|
||||
meta?: MetaDataProps;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
/** Builds Scheduled-tab rows + meta from a `getSchedulesPage` result (advanced/Cloud path). */
|
||||
export function buildScheduledTabRows(
|
||||
result: SchedulesPageResult | null | undefined,
|
||||
now: Date,
|
||||
): { data: ScanProps[]; meta?: MetaDataProps } {
|
||||
if (!result || result.error) return { data: [] };
|
||||
|
||||
const providerById = new Map(
|
||||
((result.included ?? []) as ProviderProps[])
|
||||
.filter((resource) => resource.type === "providers")
|
||||
.map((provider) => [provider.id, provider]),
|
||||
);
|
||||
|
||||
const data = (result.data ?? []).map((schedule) =>
|
||||
mapScheduleToScanRow(schedule, providerById.get(schedule.id), now),
|
||||
);
|
||||
|
||||
return { data, meta: result.meta };
|
||||
}
|
||||
|
||||
/** Maps a `/schedules` resource (1:1 with a provider) to the Scheduled-tab row shape. */
|
||||
export function mapScheduleToScanRow(
|
||||
schedule: ScheduleProps,
|
||||
provider: ProviderProps | undefined,
|
||||
now: Date,
|
||||
): ScanProps {
|
||||
// Reuse the canonical cadence + next/last-run summary used across the app.
|
||||
const summary = buildProviderScheduleSummary(schedule.attributes, now);
|
||||
|
||||
return {
|
||||
type: "scans",
|
||||
id: `schedule-${schedule.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: summary.nextScanAt ?? null,
|
||||
next_scan_at: null,
|
||||
},
|
||||
relationships: {
|
||||
// The schedule resource id IS the provider id.
|
||||
provider: { data: { type: "providers", id: schedule.id } },
|
||||
// No Celery task behind a schedule row.
|
||||
task: { data: { type: "tasks", id: "" } },
|
||||
},
|
||||
providerInfo: provider
|
||||
? {
|
||||
provider: provider.attributes.provider,
|
||||
uid: provider.attributes.uid,
|
||||
alias: provider.attributes.alias,
|
||||
}
|
||||
: undefined,
|
||||
pendingSchedule: summary,
|
||||
};
|
||||
}
|
||||
|
||||
function getNumericValue(
|
||||
source: Record<string, unknown>,
|
||||
keys: string[],
|
||||
|
||||
@@ -119,12 +119,13 @@ const resourcesColumn: ColumnDef<ScanProps> = {
|
||||
};
|
||||
|
||||
const actionsColumn = (
|
||||
tab: ScanJobsTab,
|
||||
capability?: ScanScheduleCapability,
|
||||
): ColumnDef<ScanProps> => ({
|
||||
id: "actions",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="" />,
|
||||
cell: ({ row }) => (
|
||||
<ScanJobsRowActions scan={row.original} capability={capability} />
|
||||
<ScanJobsRowActions scan={row.original} tab={tab} capability={capability} />
|
||||
),
|
||||
enableSorting: false,
|
||||
});
|
||||
@@ -164,7 +165,7 @@ const activeColumns = (
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
actionsColumn(capability),
|
||||
actionsColumn(SCAN_JOBS_TAB.ACTIVE, capability),
|
||||
];
|
||||
|
||||
const completedColumns = (
|
||||
@@ -195,7 +196,7 @@ const completedColumns = (
|
||||
),
|
||||
cell: ({ row }) => renderDateCell(row.original.attributes.completed_at),
|
||||
},
|
||||
actionsColumn(capability),
|
||||
actionsColumn(SCAN_JOBS_TAB.COMPLETED, capability),
|
||||
];
|
||||
|
||||
const scheduledColumns = (
|
||||
@@ -206,7 +207,7 @@ const scheduledColumns = (
|
||||
scheduledScanScheduleColumn,
|
||||
nextScanColumn,
|
||||
lastScanColumn,
|
||||
actionsColumn(capability),
|
||||
actionsColumn(SCAN_JOBS_TAB.SCHEDULED, capability),
|
||||
];
|
||||
|
||||
export function getScanJobsColumns(
|
||||
|
||||
@@ -132,7 +132,7 @@ describe("ScanJobsRowActions", () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ScanJobsRowActions scan={makeScan()} />);
|
||||
render(<ScanJobsRowActions scan={makeScan()} tab="scheduled" />);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
@@ -153,7 +153,7 @@ describe("ScanJobsRowActions", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ScanJobsRowActions scan={makeScan()} />);
|
||||
render(<ScanJobsRowActions scan={makeScan()} tab="scheduled" />);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
@@ -175,7 +175,30 @@ describe("ScanJobsRowActions", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ScanJobsRowActions scan={makeScan()} />);
|
||||
render(<ScanJobsRowActions scan={makeScan()} tab="scheduled" />);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /open actions menu/i }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("menuitem", { name: /edit scan schedule/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides Edit Scan Schedule outside the Scheduled tab even on Cloud", async () => {
|
||||
// Given - advanced capability (Cloud) but rendered in the Completed tab.
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ScanJobsRowActions
|
||||
scan={makeScan({ state: "completed" })}
|
||||
tab="completed"
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
@@ -196,6 +219,7 @@ describe("ScanJobsRowActions", () => {
|
||||
render(
|
||||
<ScanJobsRowActions
|
||||
scan={makeScan()}
|
||||
tab="scheduled"
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
|
||||
/>,
|
||||
);
|
||||
@@ -219,6 +243,7 @@ describe("ScanJobsRowActions", () => {
|
||||
render(
|
||||
<ScanJobsRowActions
|
||||
scan={makeScan()}
|
||||
tab="scheduled"
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.BLOCKED}
|
||||
/>,
|
||||
);
|
||||
@@ -243,6 +268,7 @@ describe("ScanJobsRowActions", () => {
|
||||
state: "completed",
|
||||
completed_at: "2026-01-01T10:05:00Z",
|
||||
})}
|
||||
tab="completed"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -267,6 +293,7 @@ describe("ScanJobsRowActions", () => {
|
||||
state: "completed",
|
||||
completed_at: "2026-01-01T10:05:00Z",
|
||||
})}
|
||||
tab="completed"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -293,6 +320,7 @@ describe("ScanJobsRowActions", () => {
|
||||
state: "completed",
|
||||
completed_at: "2026-01-01T10:05:00Z",
|
||||
})}
|
||||
tab="completed"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -317,6 +345,7 @@ describe("ScanJobsRowActions", () => {
|
||||
state: "completed",
|
||||
completed_at: "2026-01-01T10:05:00Z",
|
||||
})}
|
||||
tab="completed"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -357,6 +386,7 @@ describe("ScanJobsRowActions", () => {
|
||||
state: "failed",
|
||||
completed_at: "2026-01-01T10:05:00Z",
|
||||
})}
|
||||
tab="completed"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -388,7 +418,12 @@ describe("ScanJobsRowActions", () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ScanJobsRowActions scan={makeScan({ state: "completed" })} />);
|
||||
render(
|
||||
<ScanJobsRowActions
|
||||
scan={makeScan({ state: "completed" })}
|
||||
tab="completed"
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
|
||||
@@ -33,7 +33,13 @@ import { toLocalDateString } from "@/lib/date-utils";
|
||||
import { downloadScanZip } from "@/lib/helper";
|
||||
import { getScanScheduleCapability } from "@/lib/schedules";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import type { ProviderType, ScanProps, ScheduleApiResponse } from "@/types";
|
||||
import {
|
||||
type ProviderType,
|
||||
SCAN_JOBS_TAB,
|
||||
type ScanJobsTab,
|
||||
type ScanProps,
|
||||
type ScheduleApiResponse,
|
||||
} from "@/types";
|
||||
import {
|
||||
SCAN_SCHEDULE_CAPABILITY,
|
||||
type ScanScheduleCapability,
|
||||
@@ -42,6 +48,8 @@ import {
|
||||
|
||||
interface ScanJobsRowActionsProps {
|
||||
scan: ScanProps;
|
||||
/** The scan jobs tab this row is rendered in. */
|
||||
tab: ScanJobsTab;
|
||||
/**
|
||||
* Schedule capability override. Only for Prowler Cloud.
|
||||
*/
|
||||
@@ -50,6 +58,7 @@ interface ScanJobsRowActionsProps {
|
||||
|
||||
export function ScanJobsRowActions({
|
||||
scan,
|
||||
tab,
|
||||
capability,
|
||||
}: ScanJobsRowActionsProps) {
|
||||
const router = useRouter();
|
||||
@@ -170,7 +179,8 @@ export function ScanJobsRowActions({
|
||||
return (
|
||||
<div className="flex items-center justify-end">
|
||||
<ActionDropdown>
|
||||
{canEditSchedule && (
|
||||
{/* Schedule editing belongs only to the Scheduled tab. */}
|
||||
{tab === SCAN_JOBS_TAB.SCHEDULED && canEditSchedule && (
|
||||
<ActionDropdownItem
|
||||
icon={<CalendarClock />}
|
||||
label="Edit Scan Schedule"
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface CustomDropdownFilterProps {
|
||||
export enum FilterType {
|
||||
SCAN = "scan__in",
|
||||
PROVIDER = "provider__in",
|
||||
PROVIDER_ID = "provider_id__in",
|
||||
PROVIDER_UID = "provider_uid__in",
|
||||
PROVIDER_TYPE = "provider_type__in",
|
||||
REGION = "region__in",
|
||||
@@ -46,6 +47,15 @@ export enum FilterType {
|
||||
RESOURCE_GROUPS = "resource_groups__in",
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter keys the account selectors accept: a provider id (`provider__in` /
|
||||
* `provider_id__in`) or the cloud account uid (`provider_uid__in`).
|
||||
*/
|
||||
export type AccountFilterKey =
|
||||
| FilterType.PROVIDER
|
||||
| FilterType.PROVIDER_ID
|
||||
| FilterType.PROVIDER_UID;
|
||||
|
||||
/**
|
||||
* Controls the filter dispatch behavior of DataTableFilterCustom.
|
||||
* - "instant": every selection immediately updates the URL (legacy/default behavior)
|
||||
|
||||
Reference in New Issue
Block a user