Skip to content

Commit 0cabceb

Browse files
feat(ui): source scheduled scans tab from /schedules endpoint (#11670)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3ee24fb commit 0cabceb

17 files changed

Lines changed: 431 additions & 59 deletions

ui/actions/schedules/schedules.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ vi.mock("@/lib/server-actions-helper", () => ({
3535

3636
import {
3737
getSchedule,
38+
getSchedulesPage,
3839
removeSchedule,
3940
updateSchedule,
4041
updateSchedulesBulk,
@@ -212,3 +213,53 @@ describe("schedule write actions revalidate only on success", () => {
212213
expect(fetchMock).not.toHaveBeenCalled();
213214
});
214215
});
216+
217+
describe("getSchedulesPage delegates pagination to the endpoint", () => {
218+
beforeEach(() => {
219+
vi.stubGlobal("fetch", fetchMock);
220+
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
221+
fetchMock.mockResolvedValue(new Response(null, { status: 200 }));
222+
handleApiErrorMock.mockReturnValue({ error: "Failed" });
223+
});
224+
225+
it("requests only configured schedules with native pagination and include", async () => {
226+
handleApiResponseMock.mockResolvedValue({
227+
data: [],
228+
included: [],
229+
meta: { pagination: { page: 2, pages: 4, count: 35 } },
230+
});
231+
232+
const result = await getSchedulesPage({
233+
page: 2,
234+
pageSize: 10,
235+
sort: "next_scan_at",
236+
filters: { "filter[provider_type__in]": "aws,azure" },
237+
});
238+
239+
const calledUrl = new URL(fetchMock.mock.calls[0][0] as string);
240+
expect(calledUrl.pathname).toBe("/api/v1/schedules");
241+
expect(calledUrl.searchParams.get("filter[configured]")).toBe("true");
242+
expect(calledUrl.searchParams.get("include")).toBe("provider");
243+
expect(calledUrl.searchParams.get("page[number]")).toBe("2");
244+
expect(calledUrl.searchParams.get("page[size]")).toBe("10");
245+
expect(calledUrl.searchParams.get("sort")).toBe("next_scan_at");
246+
expect(calledUrl.searchParams.get("filter[provider_type__in]")).toBe(
247+
"aws,azure",
248+
);
249+
// meta is propagated verbatim so the table paginates natively.
250+
expect(result?.meta?.pagination?.count).toBe(35);
251+
});
252+
253+
it("normalizes array filter values into a CSV param", async () => {
254+
handleApiResponseMock.mockResolvedValue({ data: [], meta: {} });
255+
256+
await getSchedulesPage({
257+
filters: { "filter[provider__in]": ["id-1", "id-2"] },
258+
});
259+
260+
const calledUrl = new URL(fetchMock.mock.calls[0][0] as string);
261+
expect(calledUrl.searchParams.get("filter[provider__in]")).toBe(
262+
"id-1,id-2",
263+
);
264+
});
265+
});

ui/actions/schedules/schedules.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,41 @@ export const getSchedules = async () => {
8181
}
8282
};
8383

84+
/** Fetches one page of configured schedules for the Scheduled tab, with native pagination. */
85+
export const getSchedulesPage = async ({
86+
page = 1,
87+
pageSize = 10,
88+
sort = "",
89+
filters = {},
90+
}: {
91+
page?: number;
92+
pageSize?: number;
93+
sort?: string;
94+
filters?: Record<string, string | string[]>;
95+
} = {}) => {
96+
const headers = await getAuthHeaders({ contentType: false });
97+
const url = new URL(`${apiBaseUrl}/schedules`);
98+
99+
url.searchParams.set("filter[configured]", "true");
100+
url.searchParams.set("include", "provider");
101+
url.searchParams.set("page[number]", String(page));
102+
url.searchParams.set("page[size]", String(pageSize));
103+
if (sort) url.searchParams.set("sort", sort);
104+
105+
for (const [key, value] of Object.entries(filters)) {
106+
const normalized = Array.isArray(value) ? value.join(",") : value;
107+
if (normalized) url.searchParams.set(key, normalized);
108+
}
109+
110+
try {
111+
const response = await fetch(url.toString(), { headers });
112+
113+
return handleApiResponse(response);
114+
} catch (error) {
115+
return handleApiError(error);
116+
}
117+
};
118+
84119
export const updateSchedule = async (
85120
providerId: string,
86121
payload: ScheduleUpdatePayload,

ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { render, screen, within } from "@testing-library/react";
22
import userEvent from "@testing-library/user-event";
33
import { describe, expect, it, vi } from "vitest";
44

5+
import { FilterType } from "@/types/filters";
6+
57
import { AccountsSelector } from "./accounts-selector";
68

79
const multiSelectContentSpy = vi.fn();
@@ -170,7 +172,10 @@ describe("AccountsSelector", () => {
170172

171173
it("can use provider UID values for pages whose API filters by provider_uid__in", () => {
172174
render(
173-
<AccountsSelector providers={providers} filterKey="provider_uid__in" />,
175+
<AccountsSelector
176+
providers={providers}
177+
filterKey={FilterType.PROVIDER_UID}
178+
/>,
174179
);
175180

176181
expect(

ui/app/(prowler)/_overview/_components/accounts-selector.tsx

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,25 +17,18 @@ import {
1717
MultiSelectValue,
1818
} from "@/components/shadcn/select/multiselect";
1919
import { useUrlFilters } from "@/hooks/use-url-filters";
20+
import { type AccountFilterKey, FilterType } from "@/types/filters";
2021
import {
2122
getProviderDisplayName,
2223
type ProviderProps,
2324
type ProviderType,
2425
} from "@/types/providers";
2526

26-
const ACCOUNT_SELECTOR_FILTER = {
27-
PROVIDER_ID: "provider_id__in",
28-
PROVIDER_UID: "provider_uid__in",
29-
} as const;
30-
31-
type AccountSelectorFilter =
32-
(typeof ACCOUNT_SELECTOR_FILTER)[keyof typeof ACCOUNT_SELECTOR_FILTER];
33-
3427
/** Common props shared by both batch and instant modes. */
3528
interface AccountsSelectorBaseProps {
3629
providers: ProviderProps[];
3730
search?: MultiSelectSearchProp;
38-
filterKey?: AccountSelectorFilter;
31+
filterKey?: AccountFilterKey;
3932
id?: string;
4033
disabledValues?: string[];
4134
closeOnSelect?: boolean;
@@ -72,7 +65,7 @@ export function AccountsSelector({
7265
providers,
7366
onBatchChange,
7467
selectedValues,
75-
filterKey = ACCOUNT_SELECTOR_FILTER.PROVIDER_ID,
68+
filterKey = FilterType.PROVIDER_ID,
7669
id = "accounts-selector",
7770
disabledValues = [],
7871
search = {
@@ -92,7 +85,7 @@ export function AccountsSelector({
9285

9386
const visibleProviders = providers;
9487
const getProviderValue = (provider: ProviderProps) =>
95-
filterKey === ACCOUNT_SELECTOR_FILTER.PROVIDER_UID
88+
filterKey === FilterType.PROVIDER_UID
9689
? provider.attributes.uid
9790
: provider.id;
9891
const disabledValuesSet = new Set(disabledValues);

ui/app/(prowler)/scans/page.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,20 @@ describe("scans page onboarding", () => {
2121
expect(source).toContain("onboardingAction={onboardingAction}");
2222
});
2323
});
24+
25+
describe("scans page scheduled tab source", () => {
26+
const currentDir = path.dirname(fileURLToPath(import.meta.url));
27+
const pagePath = path.join(currentDir, "page.tsx");
28+
const source = readFileSync(pagePath, "utf8");
29+
30+
it("sources the Scheduled tab from /schedules only for the advanced capability", () => {
31+
expect(source).toContain("getSchedulesPage");
32+
expect(source).toContain("SCAN_SCHEDULE_CAPABILITY.ADVANCED");
33+
expect(source).toContain("tab === SCAN_JOBS_TAB.SCHEDULED");
34+
});
35+
36+
it("maps schedule resources to rows and delegates pagination to the endpoint", () => {
37+
expect(source).toContain("buildScheduledTabRows");
38+
expect(source).toContain("pickScheduleProviderFilters");
39+
});
40+
});

ui/app/(prowler)/scans/page.tsx

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,17 @@ import { Suspense } from "react";
33

44
import { getAllProviders } from "@/actions/providers";
55
import { getScans } from "@/actions/scans";
6-
import { getSchedules } from "@/actions/schedules";
6+
import { getSchedules, getSchedulesPage } from "@/actions/schedules";
77
import { auth } from "@/auth.config";
88
import { PageReady } from "@/components/onboarding";
99
import {
1010
appendPendingScheduleRowsToPage,
11+
buildScheduledTabRows,
1112
getProviderIdsFromScans,
1213
getScanJobsTab,
1314
getScanJobsTabFilters,
1415
getScanJobsUserFilters,
16+
pickScheduleProviderFilters,
1517
} from "@/components/scans/scans.utils";
1618
import { ScansPageShell } from "@/components/scans/scans-page-shell";
1719
import { ScansProvidersEmptyState } from "@/components/scans/scans-providers-empty-state";
@@ -21,35 +23,41 @@ import { ContentLayout } from "@/components/ui";
2123
import {
2224
buildProviderScheduleSummary,
2325
buildSchedulesByProviderId,
26+
getScanScheduleCapability,
2427
isScheduleConfigured,
2528
} from "@/lib/schedules";
29+
import { isCloud } from "@/lib/shared/env";
2630
import {
31+
FilterType,
2732
ProviderProps,
2833
SCAN_JOBS_TAB,
2934
SCAN_TRIGGER,
3035
ScanProps,
3136
SearchParamsProps,
3237
} from "@/types";
33-
import type { ScanScheduleCapability } from "@/types/schedules";
38+
import {
39+
SCAN_SCHEDULE_CAPABILITY,
40+
type ScanScheduleCapability,
41+
} from "@/types/schedules";
3442

3543
const ACTIVE_SCAN_COUNT_PAGE_SIZE = 1;
36-
// Pending schedule rows are derived from provider schedules, but must honor the
37-
// same provider filters as real scan rows. Keep these filter keys typed locally
38-
// without narrowing the global SearchParamsProps shape used by Next pages.
44+
// Pending schedule rows must honor the same provider filters as real scan rows.
45+
// The `__in` keys reuse the shared FilterType; the singular variants have no
46+
// FilterType equivalent, so they stay as literals.
3947
const PENDING_ROW_PROVIDER_FILTER = {
40-
PROVIDER_UID_IN: "provider_uid__in",
41-
PROVIDER_UID: "provider_uid",
42-
PROVIDER_TYPE_IN: "provider_type__in",
48+
PROVIDER_IN: FilterType.PROVIDER,
49+
PROVIDER: "provider",
50+
PROVIDER_TYPE_IN: FilterType.PROVIDER_TYPE,
4351
PROVIDER_TYPE: "provider_type",
4452
} as const;
4553

4654
type PendingRowProviderFilter =
4755
(typeof PENDING_ROW_PROVIDER_FILTER)[keyof typeof PENDING_ROW_PROVIDER_FILTER];
4856
type PendingRowProviderFilterParam = `filter[${PendingRowProviderFilter}]`;
4957

50-
const PROVIDER_UID_FILTER_KEYS = [
51-
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_UID_IN}]`,
52-
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_UID}]`,
58+
const PROVIDER_ID_FILTER_KEYS = [
59+
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_IN}]`,
60+
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER}]`,
5361
] as const satisfies ReadonlyArray<PendingRowProviderFilterParam>;
5462

5563
const PROVIDER_TYPE_FILTER_KEYS = [
@@ -93,16 +101,16 @@ const filterProvidersForPendingRows = (
93101
providers: ProviderProps[],
94102
searchParams: SearchParamsProps,
95103
): ProviderProps[] => {
96-
const uids = parseCsvParam(
97-
getFirstSearchParam(searchParams, PROVIDER_UID_FILTER_KEYS),
104+
const ids = parseCsvParam(
105+
getFirstSearchParam(searchParams, PROVIDER_ID_FILTER_KEYS),
98106
);
99107
const types = parseCsvParam(
100108
getFirstSearchParam(searchParams, PROVIDER_TYPE_FILTER_KEYS),
101109
);
102110

103111
return providers.filter(
104112
(provider) =>
105-
(uids.length === 0 || uids.includes(provider.attributes.uid)) &&
113+
(ids.length === 0 || ids.includes(provider.id)) &&
106114
(types.length === 0 || types.includes(provider.attributes.provider)),
107115
);
108116
};
@@ -272,6 +280,33 @@ const SSRDataTableScans = async ({
272280

273281
const query = (filters["filter[search]"] as string) || "";
274282

283+
// Advanced (Cloud) sources the Scheduled tab from /schedules; other envs keep the legacy /scans path.
284+
const capability =
285+
scanScheduleCapability ?? getScanScheduleCapability(isCloud());
286+
287+
if (
288+
tab === SCAN_JOBS_TAB.SCHEDULED &&
289+
capability === SCAN_SCHEDULE_CAPABILITY.ADVANCED
290+
) {
291+
const schedulesPage = await getSchedulesPage({
292+
page,
293+
pageSize,
294+
sort,
295+
filters: pickScheduleProviderFilters(searchParams),
296+
});
297+
const { data, meta } = buildScheduledTabRows(schedulesPage, new Date());
298+
299+
return (
300+
<ScanJobsTable
301+
data={data}
302+
meta={meta}
303+
tab={tab}
304+
hasFilters={hasUserFilters}
305+
scanScheduleCapability={capability}
306+
/>
307+
);
308+
}
309+
275310
const scansData = await getScans({
276311
query,
277312
page,

ui/components/filters/provider-account-selectors.test.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { render } from "@testing-library/react";
22
import { beforeEach, describe, expect, it, vi } from "vitest";
33

4+
import { FilterType } from "@/types/filters";
45
import type { ProviderProps } from "@/types/providers";
56

67
import { ProviderAccountSelectors } from "./provider-account-selectors";
@@ -170,7 +171,7 @@ describe("ProviderAccountSelectors", () => {
170171
render(
171172
<ProviderAccountSelectors
172173
providers={providers}
173-
accountFilterKey="provider_uid__in"
174+
accountFilterKey={FilterType.PROVIDER_UID}
174175
accountValue="uid"
175176
paramsToDeleteOnChange={["page", "scanId"]}
176177
/>,
@@ -229,7 +230,7 @@ describe("ProviderAccountSelectors", () => {
229230
<ProviderAccountSelectors
230231
providers={providers}
231232
mode="batch"
232-
accountFilterKey="provider_uid__in"
233+
accountFilterKey={FilterType.PROVIDER_UID}
233234
accountValue="uid"
234235
selectedProviderTypes={["aws"]}
235236
selectedAccounts={["123456789012", "prowler-project"]}

ui/components/filters/provider-account-selectors.tsx

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,14 @@ import { useSearchParams } from "next/navigation";
55
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
66
import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector";
77
import { useUrlFilters } from "@/hooks/use-url-filters";
8+
import { type AccountFilterKey, FilterType } from "@/types/filters";
89
import type { ProviderProps } from "@/types/providers";
910

10-
const ACCOUNT_FILTER_KEY = {
11-
PROVIDER_ID: "provider_id__in",
12-
PROVIDER_UID: "provider_uid__in",
13-
} as const;
14-
1511
const ACCOUNT_VALUE = {
1612
ID: "id",
1713
UID: "uid",
1814
} as const;
1915

20-
type AccountFilterKey =
21-
(typeof ACCOUNT_FILTER_KEY)[keyof typeof ACCOUNT_FILTER_KEY];
2216
type AccountValue = (typeof ACCOUNT_VALUE)[keyof typeof ACCOUNT_VALUE];
2317

2418
interface ProviderAccountSelectorsBaseProps {
@@ -97,7 +91,7 @@ const getCompatibleAccounts = ({
9791

9892
export function ProviderAccountSelectors({
9993
providers,
100-
accountFilterKey = ACCOUNT_FILTER_KEY.PROVIDER_ID,
94+
accountFilterKey = FilterType.PROVIDER_ID,
10195
accountValue = ACCOUNT_VALUE.ID,
10296
providerSelectorClassName,
10397
accountSelectorClassName,

0 commit comments

Comments
 (0)