diff --git a/ui/actions/schedules/schedules.test.ts b/ui/actions/schedules/schedules.test.ts index d676e12d67..3122430f83 100644 --- a/ui/actions/schedules/schedules.test.ts +++ b/ui/actions/schedules/schedules.test.ts @@ -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", + ); + }); +}); diff --git a/ui/actions/schedules/schedules.ts b/ui/actions/schedules/schedules.ts index 6b197823eb..63ef862b3c 100644 --- a/ui/actions/schedules/schedules.ts +++ b/ui/actions/schedules/schedules.ts @@ -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; +} = {}) => { + 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, diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx index 19c63cf3d2..8a738ef5e1 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx @@ -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( - , + , ); expect( diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx index 0c389febb7..128784517f 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx @@ -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); diff --git a/ui/app/(prowler)/scans/page.test.ts b/ui/app/(prowler)/scans/page.test.ts index 3ea7bcfa7c..08c36aaf46 100644 --- a/ui/app/(prowler)/scans/page.test.ts +++ b/ui/app/(prowler)/scans/page.test.ts @@ -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"); + }); +}); diff --git a/ui/app/(prowler)/scans/page.tsx b/ui/app/(prowler)/scans/page.tsx index f50126719e..7027445cc1 100644 --- a/ui/app/(prowler)/scans/page.tsx +++ b/ui/app/(prowler)/scans/page.tsx @@ -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; 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 ( + + ); + } + const scansData = await getScans({ query, page, diff --git a/ui/components/filters/provider-account-selectors.test.tsx b/ui/components/filters/provider-account-selectors.test.tsx index 7d576251ac..a8b8bd1689 100644 --- a/ui/components/filters/provider-account-selectors.test.tsx +++ b/ui/components/filters/provider-account-selectors.test.tsx @@ -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( , @@ -229,7 +230,7 @@ describe("ProviderAccountSelectors", () => { { 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", ); }); diff --git a/ui/components/providers/table/data-table-row-actions.tsx b/ui/components/providers/table/data-table-row-actions.tsx index a2c63e0cb7..6e28b68042 100644 --- a/ui/components/providers/table/data-table-row-actions.tsx +++ b/ui/components/providers/table/data-table-row-actions.tsx @@ -559,12 +559,11 @@ export function DataTableRowActions({ icon={} 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()}`); }} diff --git a/ui/components/scans/scans-filter-bar.tsx b/ui/components/scans/scans-filter-bar.tsx index 8ab48a8f9c..9e11fddf0e 100644 --- a/ui/components/scans/scans-filter-bar.tsx +++ b/ui/components/scans/scans-filter-bar.tsx @@ -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({ <> { expect(rows).toHaveLength(0); }); + + describe("mapScheduleToScanRow", () => { + const makeSchedule = ( + attributes: Partial = {}, + ): 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: [] }); + }); + }); }); diff --git a/ui/components/scans/scans.utils.ts b/ui/components/scans/scans.utils.ts index 4d96046019..978e7e87e0 100644 --- a/ui/components/scans/scans.utils.ts +++ b/ui/components/scans/scans.utils.ts @@ -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 { + const filters: Record = {}; + 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, keys: string[], diff --git a/ui/components/scans/table/scan-jobs-columns.tsx b/ui/components/scans/table/scan-jobs-columns.tsx index 501f4e962f..1367c718de 100644 --- a/ui/components/scans/table/scan-jobs-columns.tsx +++ b/ui/components/scans/table/scan-jobs-columns.tsx @@ -119,12 +119,13 @@ const resourcesColumn: ColumnDef = { }; const actionsColumn = ( + tab: ScanJobsTab, capability?: ScanScheduleCapability, ): ColumnDef => ({ id: "actions", header: ({ column }) => , cell: ({ row }) => ( - + ), 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( diff --git a/ui/components/scans/table/scan-jobs-row-actions.test.tsx b/ui/components/scans/table/scan-jobs-row-actions.test.tsx index 8d9cacd5fa..dff5a81677 100644 --- a/ui/components/scans/table/scan-jobs-row-actions.test.tsx +++ b/ui/components/scans/table/scan-jobs-row-actions.test.tsx @@ -132,7 +132,7 @@ describe("ScanJobsRowActions", () => { // Given const user = userEvent.setup(); - render(); + render(); // When await user.click( @@ -153,7 +153,7 @@ describe("ScanJobsRowActions", () => { vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); const user = userEvent.setup(); - render(); + render(); // When await user.click( @@ -175,7 +175,30 @@ describe("ScanJobsRowActions", () => { vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); const user = userEvent.setup(); - render(); + render(); + + // 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( + , + ); // When await user.click( @@ -196,6 +219,7 @@ describe("ScanJobsRowActions", () => { render( , ); @@ -219,6 +243,7 @@ describe("ScanJobsRowActions", () => { render( , ); @@ -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(); + render( + , + ); // When await user.click( diff --git a/ui/components/scans/table/scan-jobs-row-actions.tsx b/ui/components/scans/table/scan-jobs-row-actions.tsx index 9fa3beb8f6..69935ed85c 100644 --- a/ui/components/scans/table/scan-jobs-row-actions.tsx +++ b/ui/components/scans/table/scan-jobs-row-actions.tsx @@ -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 (
- {canEditSchedule && ( + {/* Schedule editing belongs only to the Scheduled tab. */} + {tab === SCAN_JOBS_TAB.SCHEDULED && canEditSchedule && ( } label="Edit Scan Schedule" diff --git a/ui/types/filters.ts b/ui/types/filters.ts index 9630318712..e011a70030 100644 --- a/ui/types/filters.ts +++ b/ui/types/filters.ts @@ -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)