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:
Alejandro Bailo
2026-06-23 13:44:37 +02:00
committed by GitHub
parent 3ee24fba51
commit 0cabceb09c
17 changed files with 431 additions and 59 deletions
+51
View File
@@ -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",
);
});
});
+35
View File
@@ -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);
+17
View File
@@ -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");
});
});
+49 -14
View File
@@ -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()}`);
}}
+3 -2
View File
@@ -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}
+88
View File
@@ -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: [] });
});
});
});
+97
View File
@@ -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"
+10
View File
@@ -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)