diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 97f0dc98305..ce2be966965 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -7,6 +7,8 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🚀 Added - Controlled `402` and `403` Server Action error messages for alert seed and mutation flows [(#11629)](https://github.com/prowler-cloud/prowler/pull/11629) +- Filter the Overview, Findings, Resources, Scans, and Providers views by provider group [(#11659)](https://github.com/prowler-cloud/prowler/pull/11659) +- Filter the Compliance overview and detail pages by provider [(#11668)](https://github.com/prowler-cloud/prowler/pull/11668) ### 🐞 Fixed diff --git a/ui/actions/compliances/compliances.test.ts b/ui/actions/compliances/compliances.test.ts new file mode 100644 index 00000000000..207280a4c20 --- /dev/null +++ b/ui/actions/compliances/compliances.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted( + () => ({ + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + handleApiResponseMock: vi.fn(), + }), +); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: getAuthHeadersMock, +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiResponse: handleApiResponseMock, +})); + +import { + getComplianceOverviewMetadataInfo, + getComplianceRequirements, + getCompliancesOverview, +} from "./compliances"; + +const calledUrl = () => new URL(fetchMock.mock.calls[0][0] as string); + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + fetchMock.mockResolvedValue(new Response(null, { status: 200 })); + handleApiResponseMock.mockResolvedValue({ data: [] }); +}); + +describe("getCompliancesOverview", () => { + it("sends scan_id and region in scan mode", async () => { + await getCompliancesOverview({ scanId: "scan-1", region: "eu-west-1" }); + + const url = calledUrl(); + expect(url.searchParams.get("filter[scan_id]")).toBe("scan-1"); + expect(url.searchParams.get("filter[region__in]")).toBe("eu-west-1"); + }); + + it("forwards provider filters and omits scan_id in aggregated mode", async () => { + await getCompliancesOverview({ + scanId: "scan-1", + filters: { "filter[provider_type__in]": "aws,gcp" }, + }); + + const url = calledUrl(); + expect(url.searchParams.get("filter[provider_type__in]")).toBe("aws,gcp"); + // XOR: provider filters present -> never send scan_id (avoids backend 400) + expect(url.searchParams.get("filter[scan_id]")).toBeNull(); + }); +}); + +describe("getComplianceOverviewMetadataInfo", () => { + it("forwards provider filters", async () => { + await getComplianceOverviewMetadataInfo({ + filters: { "filter[provider_groups__in]": "g1,g2" }, + }); + + expect(calledUrl().searchParams.get("filter[provider_groups__in]")).toBe( + "g1,g2", + ); + }); +}); + +describe("getComplianceRequirements", () => { + it("appends compliance_id and scan_id in scan mode", async () => { + await getComplianceRequirements({ + complianceId: "cis_2.0_aws", + scanId: "scan-1", + }); + + const url = calledUrl(); + expect(url.searchParams.get("filter[compliance_id]")).toBe("cis_2.0_aws"); + expect(url.searchParams.get("filter[scan_id]")).toBe("scan-1"); + }); + + it("forwards provider filters and omits scan_id in aggregated mode", async () => { + await getComplianceRequirements({ + complianceId: "cis_2.0_aws", + scanId: "scan-1", + filters: { "filter[provider_id__in]": "p1,p2" }, + }); + + const url = calledUrl(); + expect(url.searchParams.get("filter[compliance_id]")).toBe("cis_2.0_aws"); + expect(url.searchParams.get("filter[provider_id__in]")).toBe("p1,p2"); + expect(url.searchParams.get("filter[scan_id]")).toBeNull(); + }); + + it("omits scan_id when no scan is provided", async () => { + await getComplianceRequirements({ complianceId: "cis_2.0_aws" }); + + expect(calledUrl().searchParams.get("filter[scan_id]")).toBeNull(); + }); +}); diff --git a/ui/actions/compliances/compliances.ts b/ui/actions/compliances/compliances.ts index e06b06d29df..4ae9331367a 100644 --- a/ui/actions/compliances/compliances.ts +++ b/ui/actions/compliances/compliances.ts @@ -1,6 +1,11 @@ "use server"; import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { + type ComplianceFilters, + type ComplianceProviderFilters, + hasComplianceProviderFilters, +} from "@/lib/compliance/compliance-provider-filters"; import { handleApiResponse } from "@/lib/server-actions-helper"; export const getCompliancesOverview = async ({ @@ -10,7 +15,7 @@ export const getCompliancesOverview = async ({ }: { scanId?: string; region?: string | string[]; - filters?: Record; + filters?: ComplianceProviderFilters; } = {}) => { const headers = await getAuthHeaders({ contentType: false }); @@ -27,7 +32,10 @@ export const getCompliancesOverview = async ({ Object.entries(filters).forEach(([key, value]) => setParam(key, value)); - setParam("filter[scan_id]", scanId); + // XOR: the backend rejects filter[scan_id] together with provider filters. + if (!hasComplianceProviderFilters(filters)) { + setParam("filter[scan_id]", scanId); + } setParam("filter[region__in]", region); try { const response = await fetch(url.toString(), { @@ -46,7 +54,7 @@ export const getComplianceOverviewMetadataInfo = async ({ filters = {}, }: { sort?: string; - filters?: Record; + filters?: ComplianceFilters; } = {}) => { const headers = await getAuthHeaders({ contentType: false }); @@ -111,22 +119,31 @@ export const getComplianceRequirements = async ({ complianceId, scanId, region, + filters = {}, }: { complianceId: string; - scanId: string; + scanId?: string; region?: string | string[]; + filters?: ComplianceProviderFilters; }) => { const headers = await getAuthHeaders({ contentType: false }); try { const url = new URL(`${apiBaseUrl}/compliance-overviews/requirements`); url.searchParams.append("filter[compliance_id]", complianceId); - url.searchParams.append("filter[scan_id]", scanId); + + // Forward provider-scope filters (aggregated mode); XOR with scan_id. + Object.entries(filters).forEach(([key, value]) => { + if (value && value.trim() !== "") url.searchParams.append(key, value); + }); + + if (scanId && !hasComplianceProviderFilters(filters)) { + url.searchParams.append("filter[scan_id]", scanId); + } if (region) { const regionValue = Array.isArray(region) ? region.join(",") : region; url.searchParams.append("filter[region__in]", regionValue); - //remove page param } url.searchParams.delete("page"); diff --git a/ui/actions/finding-groups/finding-groups.ts b/ui/actions/finding-groups/finding-groups.ts index bf5df80aae9..faa697cf32e 100644 --- a/ui/actions/finding-groups/finding-groups.ts +++ b/ui/actions/finding-groups/finding-groups.ts @@ -2,6 +2,7 @@ import { redirect } from "next/navigation"; +import type { FindingsFilterParam } from "@/actions/findings/findings-filters"; import { apiBaseUrl, composeSort, @@ -15,7 +16,6 @@ import { } from "@/lib"; import { appendSanitizedProviderFilters } from "@/lib/provider-filters"; import { handleApiResponse } from "@/lib/server-actions-helper"; -import { FilterParam } from "@/types/filters"; /** * Maps filter[search] to filter[check_title__icontains] for finding-groups. @@ -39,7 +39,7 @@ function mapSearchFilter( * finding-group resources sub-endpoint. These must be stripped before * calling the resources API to avoid empty results. */ -const FINDING_GROUP_RESOURCE_UNSUPPORTED_FILTERS: FilterParam[] = [ +const FINDING_GROUP_RESOURCE_UNSUPPORTED_FILTERS: FindingsFilterParam[] = [ "filter[service__in]", "filter[scan__in]", "filter[scan_id]", @@ -53,7 +53,7 @@ function normalizeFindingGroupResourceFilters( Object.entries(filters).filter( ([key]) => !FINDING_GROUP_RESOURCE_UNSUPPORTED_FILTERS.includes( - key as FilterParam, + key as FindingsFilterParam, ), ), ); diff --git a/ui/actions/findings/findings-filters.ts b/ui/actions/findings/findings-filters.ts new file mode 100644 index 00000000000..e81a3ec430b --- /dev/null +++ b/ui/actions/findings/findings-filters.ts @@ -0,0 +1,32 @@ +import { FILTER_FIELD, FilterParam } from "@/types/filters"; + +/** + * URL filter param keys the findings view supports, e.g. `filter[severity__in]`. + * Composed from the shared fields it uses plus a few findings-only extras + * (alternate scan/date/delta forms not used by other views). + */ +export type FindingsFilterParam = FilterParam< + // findings uses provider_id, not provider_uid + | (typeof FILTER_FIELD)[ + | "PROVIDER_TYPE" + | "PROVIDER_ID" + | "PROVIDER_GROUPS" + | "REGION" + | "SERVICE" + | "SEVERITY" + | "STATUS" + | "DELTA" + | "RESOURCE_TYPE" + | "CATEGORY" + | "RESOURCE_GROUPS" + | "SCAN"] + // findings-only extras + | "delta__in" + | "scan" + | "scan_id" + | "scan_id__in" + | "inserted_at" + | "inserted_at__gte" + | "inserted_at__lte" + | "muted" +>; diff --git a/ui/actions/manage-groups/manage-groups.test.ts b/ui/actions/manage-groups/manage-groups.test.ts new file mode 100644 index 00000000000..c016832a1f5 --- /dev/null +++ b/ui/actions/manage-groups/manage-groups.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + fetchMock, + getAuthHeadersMock, + handleApiErrorMock, + handleApiResponseMock, +} = vi.hoisted(() => ({ + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + handleApiErrorMock: vi.fn(), + handleApiResponseMock: vi.fn(), +})); + +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: getAuthHeadersMock, + getErrorMessage: vi.fn(), +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiError: handleApiErrorMock, + handleApiResponse: handleApiResponseMock, +})); + +import { getAllProviderGroups } from "./manage-groups"; + +const makeGroup = (id: string, name: string) => ({ + type: "provider-groups" as const, + id, + attributes: { name, inserted_at: "", updated_at: "" }, + relationships: { + providers: { meta: { count: 0 }, data: [] }, + roles: { meta: { count: 0 }, data: [] }, + }, + links: { self: "" }, +}); + +const makePage = ( + data: ReturnType[], + page: number, + pages: number, +) => ({ + links: { first: "", last: "", next: null, prev: null }, + data, + meta: { pagination: { page, pages, count: data.length } }, +}); + +describe("getAllProviderGroups", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + fetchMock.mockResolvedValue(new Response(null, { status: 200 })); + }); + + it("merges every page into a single response with collapsed pagination", async () => { + handleApiResponseMock + .mockResolvedValueOnce( + makePage( + [makeGroup("g1", "Group 1"), makeGroup("g2", "Group 2")], + 1, + 2, + ), + ) + .mockResolvedValueOnce(makePage([makeGroup("g3", "Group 3")], 2, 2)); + + const result = await getAllProviderGroups(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(result?.data.map((group) => group.id)).toEqual(["g1", "g2", "g3"]); + expect(result?.meta.pagination).toMatchObject({ + page: 1, + pages: 1, + count: 3, + }); + }); + + it("stops after the first page when there is only one page", async () => { + handleApiResponseMock.mockResolvedValueOnce( + makePage([makeGroup("g1", "Group 1")], 1, 1), + ); + + const result = await getAllProviderGroups(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result?.data).toHaveLength(1); + }); + + it("returns undefined when the first page has no data", async () => { + handleApiResponseMock.mockResolvedValueOnce(makePage([], 1, 1)); + + const result = await getAllProviderGroups(); + + expect(result).toBeUndefined(); + }); + + it("returns undefined when the request throws", async () => { + fetchMock.mockRejectedValueOnce(new Error("network down")); + + const result = await getAllProviderGroups(); + + expect(result).toBeUndefined(); + }); + + it("returns undefined when a later page resolves to an error payload", async () => { + handleApiResponseMock + .mockResolvedValueOnce(makePage([makeGroup("g1", "Group 1")], 1, 2)) + .mockResolvedValueOnce({ error: "Forbidden", status: 403 }); + + const result = await getAllProviderGroups(); + + expect(result).toBeUndefined(); + }); + + it("returns undefined instead of a truncated list when the max-page cap is hit", async () => { + // Given an API that always reports more pages than the 50-page safety cap + handleApiResponseMock.mockImplementation((response: Response) => { + void response; + return Promise.resolve(makePage([makeGroup("g", "Group")], 1, 9999)); + }); + + // When fetching every page + const result = await getAllProviderGroups(); + + // Then it must not return a partial/truncated list; bail out instead + expect(result).toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(50); + }); +}); diff --git a/ui/actions/manage-groups/manage-groups.ts b/ui/actions/manage-groups/manage-groups.ts index 933dabbdbcc..c916a89d397 100644 --- a/ui/actions/manage-groups/manage-groups.ts +++ b/ui/actions/manage-groups/manage-groups.ts @@ -51,6 +51,87 @@ export const getProviderGroups = async ({ } }; +/** + * Fetches all provider groups by iterating through every page. + * Used to populate filter dropdowns (e.g. the Provider Group selector) without + * the pagination cap that `getProviderGroups` applies for the management table. + */ +export const getAllProviderGroups = async (): Promise< + ProviderGroupsResponse | undefined +> => { + const pageSize = 100; // Larger page size to minimize API calls + const maxPages = 50; // Safety limit: 50 pages × 100 = 5000 groups max + let currentPage = 1; + const allGroups: ProviderGroupsResponse["data"] = []; + let lastResponse: ProviderGroupsResponse | undefined; + let hasMorePages = true; + + try { + const headers = await getAuthHeaders({ contentType: false }); + while (hasMorePages && currentPage <= maxPages) { + const url = new URL(`${apiBaseUrl}/provider-groups`); + url.searchParams.append("page[number]", currentPage.toString()); + url.searchParams.append("page[size]", pageSize.toString()); + + const response = await fetch(url.toString(), { headers }); + const data = (await handleApiResponse(response)) as + | ProviderGroupsResponse + | { error: string; status?: number } + | undefined; + + // A later page resolving to an API error payload must abort rather than + // be treated as "no more pages", which would silently truncate groups. + if (data && "error" in data) { + console.error("Error fetching all provider groups:", data.error); + return undefined; + } + + if (!data?.data || data.data.length === 0) { + hasMorePages = false; + continue; + } + + allGroups.push(...data.data); + lastResponse = data; + + const totalPages = data.meta?.pagination?.pages || 1; + if (currentPage >= totalPages) { + hasMorePages = false; + } else { + currentPage++; + } + } + + if (hasMorePages && currentPage > maxPages) { + console.error( + `Error fetching all provider groups: exceeded max page limit (${maxPages})`, + ); + return undefined; + } + + if (lastResponse) { + return { + ...lastResponse, + data: allGroups, + meta: { + ...lastResponse.meta, + pagination: { + ...lastResponse.meta?.pagination, + page: 1, + pages: 1, + count: allGroups.length, + }, + }, + }; + } + + return undefined; + } catch (error) { + console.error("Error fetching all provider groups:", error); + return undefined; + } +}; + export const getProviderGroupInfoById = async (providerGroupId: string) => { const headers = await getAuthHeaders({ contentType: false }); const url = new URL(`${apiBaseUrl}/provider-groups/${providerGroupId}`); diff --git a/ui/actions/overview/overview-filters.ts b/ui/actions/overview/overview-filters.ts new file mode 100644 index 00000000000..186977f9cef --- /dev/null +++ b/ui/actions/overview/overview-filters.ts @@ -0,0 +1,16 @@ +import { FILTER_FIELD, FilterParam } from "@/types/filters"; + +/** + * URL filter param keys the overview dashboard scopes its widgets by. Overview has + * no single action; its widgets read these keys from the URL filters. + */ +export type OverviewFilterParam = FilterParam< + (typeof FILTER_FIELD)["PROVIDER_TYPE" | "PROVIDER_ID" | "PROVIDER_GROUPS"] +>; + +/** The `filter[...]` keys overview widgets read from the URL. */ +export const OVERVIEW_FILTER_PARAM = { + PROVIDER_TYPE: `filter[${FILTER_FIELD.PROVIDER_TYPE}]`, + PROVIDER_ID: `filter[${FILTER_FIELD.PROVIDER_ID}]`, + PROVIDER_GROUPS: `filter[${FILTER_FIELD.PROVIDER_GROUPS}]`, +} as const satisfies Record; diff --git a/ui/actions/providers/providers-filters.ts b/ui/actions/providers/providers-filters.ts new file mode 100644 index 00000000000..71184c0b810 --- /dev/null +++ b/ui/actions/providers/providers-filters.ts @@ -0,0 +1,18 @@ +import { FILTER_FIELD, FilterParam } from "@/types/filters"; +import { PROVIDERS_PAGE_FILTER } from "@/types/providers-table"; + +/** + * URL filter param keys the providers list supports, e.g. `filter[provider__in]`. + * Provider scope plus its providers-only extras (`provider__in` API param, + * `connected` status). + */ +export type ProvidersFilterParam = FilterParam< + | (typeof FILTER_FIELD)["PROVIDER_TYPE" | "PROVIDER_GROUPS" | "PROVIDER_UID"] + | (typeof PROVIDERS_PAGE_FILTER)["PROVIDER" | "STATUS"] +>; + +/** `filter[...]` keys used when mapping the provider-type filter to the API param. */ +export const PROVIDERS_FILTER_PARAM = { + PROVIDER: `filter[${PROVIDERS_PAGE_FILTER.PROVIDER}]`, + PROVIDER_TYPE: `filter[${PROVIDERS_PAGE_FILTER.PROVIDER_TYPE}]`, +} as const satisfies Record; diff --git a/ui/actions/resources/resources-filters.ts b/ui/actions/resources/resources-filters.ts new file mode 100644 index 00000000000..52681c7050d --- /dev/null +++ b/ui/actions/resources/resources-filters.ts @@ -0,0 +1,16 @@ +import { FILTER_FIELD, FilterParam } from "@/types/filters"; + +/** + * URL filter param keys the resources view supports, e.g. `filter[type__in]`. + * The shared core plus its resources-only dimensions (`type__in`, `groups__in`). + */ +export type ResourcesFilterParam = FilterParam< + | (typeof FILTER_FIELD)[ + | "PROVIDER_TYPE" + | "PROVIDER_ID" + | "PROVIDER_GROUPS" + | "REGION" + | "SERVICE"] + | "type__in" + | "groups__in" +>; diff --git a/ui/actions/scans/scans-filters.ts b/ui/actions/scans/scans-filters.ts new file mode 100644 index 00000000000..5f186041b51 --- /dev/null +++ b/ui/actions/scans/scans-filters.ts @@ -0,0 +1,25 @@ +import { FILTER_FIELD, FilterParam } from "@/types/filters"; + +/** + * Provider filter fields used to match/clear synthetic pending scan rows — the + * `__in` forms (shared with real scan rows) plus the exact forms, and the + * provider-group `__in` form so pending rows honor the group filter too. + */ +export const SCANS_PROVIDER_FILTER_FIELD = { + PROVIDER_UID_IN: FILTER_FIELD.PROVIDER_UID, + PROVIDER_UID: "provider_uid", + PROVIDER_TYPE_IN: FILTER_FIELD.PROVIDER_TYPE, + PROVIDER_TYPE: "provider_type", + PROVIDER_GROUPS_IN: FILTER_FIELD.PROVIDER_GROUPS, +} as const; + +/** + * URL filter param keys the scans view supports, e.g. `filter[state__in]`. + * Provider scope (scans uses provider_uid, not provider_id) including provider + * groups and the exact pending-row provider forms, plus the scans-only dimensions. + */ +export type ScansFilterParam = FilterParam< + | (typeof SCANS_PROVIDER_FILTER_FIELD)[keyof typeof SCANS_PROVIDER_FILTER_FIELD] + | "state__in" + | "trigger" +>; diff --git a/ui/app/(prowler)/_overview/_lib/provider-scope.test.ts b/ui/app/(prowler)/_overview/_lib/provider-scope.test.ts new file mode 100644 index 00000000000..3071f848379 --- /dev/null +++ b/ui/app/(prowler)/_overview/_lib/provider-scope.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; + +import { ProviderProps } from "@/types/providers"; + +import { + filterProvidersByScope, + parseFilterIds, + scopeProvidersByGroup, +} from "./provider-scope"; + +const makeProvider = ( + id: string, + provider: string, + groupIds: string[] = [], +): ProviderProps => + ({ + id, + attributes: { provider }, + relationships: { + provider_groups: { + data: groupIds.map((gid) => ({ type: "provider-groups", id: gid })), + }, + }, + }) as unknown as ProviderProps; + +describe("parseFilterIds", () => { + it("returns an empty array for undefined", () => { + // Given / When / Then + expect(parseFilterIds(undefined)).toEqual([]); + }); + + it("returns an empty array for an empty string", () => { + // Given an empty param value (e.g. "filter[provider_groups__in]=") + // When / Then it must not produce a [""] match + expect(parseFilterIds("")).toEqual([]); + }); + + it("drops whitespace-only and empty segments", () => { + // Given a blank/whitespace value + // When / Then + expect(parseFilterIds(" ")).toEqual([]); + expect(parseFilterIds(",")).toEqual([]); + expect(parseFilterIds("a,,b")).toEqual(["a", "b"]); + }); + + it("splits and trims comma-separated ids", () => { + expect(parseFilterIds(" a , b ")).toEqual(["a", "b"]); + }); + + it("normalizes array param values", () => { + expect(parseFilterIds(["a", "", "b"])).toEqual(["a", "b"]); + }); +}); + +describe("scopeProvidersByGroup", () => { + const providers = [ + makeProvider("p1", "aws", ["g1"]), + makeProvider("p2", "gcp", ["g2"]), + makeProvider("p3", "azure", []), + ]; + + it("returns every provider when no group is selected", () => { + expect(scopeProvidersByGroup(providers, [])).toEqual(providers); + }); + + it("keeps only providers that belong to a selected group", () => { + // When scoping to g1 + const result = scopeProvidersByGroup(providers, ["g1"]); + + // Then only the g1 member remains + expect(result.map((p) => p.id)).toEqual(["p1"]); + }); + + it("excludes providers with no group memberships", () => { + expect(scopeProvidersByGroup(providers, ["g2"]).map((p) => p.id)).toEqual([ + "p2", + ]); + }); +}); + +describe("filterProvidersByScope", () => { + const providers = [ + makeProvider("p1", "aws", ["g1"]), + makeProvider("p2", "gcp", ["g1"]), + makeProvider("p3", "aws", ["g2"]), + makeProvider("p4", "azure", []), + ]; + + it("returns every provider when no dimension is set", () => { + const result = filterProvidersByScope(providers, { + providerIds: [], + providerTypes: [], + providerGroupIds: [], + }); + + expect(result).toEqual(providers); + }); + + it("filters by provider id", () => { + const result = filterProvidersByScope(providers, { + providerIds: ["p2"], + providerTypes: [], + providerGroupIds: [], + }); + + expect(result.map((p) => p.id)).toEqual(["p2"]); + }); + + it("filters by provider type case-insensitively", () => { + const result = filterProvidersByScope(providers, { + providerIds: [], + providerTypes: ["AWS"], + providerGroupIds: [], + }); + + expect(result.map((p) => p.id)).toEqual(["p1", "p3"]); + }); + + it("filters by provider group", () => { + const result = filterProvidersByScope(providers, { + providerIds: [], + providerTypes: [], + providerGroupIds: ["g1"], + }); + + expect(result.map((p) => p.id)).toEqual(["p1", "p2"]); + }); + + it("composes group AND type (the risk-plot regression)", () => { + // Given both a group and a type filter are active + // When combining group g1 with type aws + const result = filterProvidersByScope(providers, { + providerIds: [], + providerTypes: ["aws"], + providerGroupIds: ["g1"], + }); + + // Then only providers matching BOTH survive (p1), not all aws or all g1 + expect(result.map((p) => p.id)).toEqual(["p1"]); + }); + + it("composes id AND group", () => { + // p3 is aws/g2; selecting it together with group g1 yields nothing + const result = filterProvidersByScope(providers, { + providerIds: ["p3"], + providerTypes: [], + providerGroupIds: ["g1"], + }); + + expect(result).toEqual([]); + }); + + it("composes all three dimensions", () => { + const result = filterProvidersByScope(providers, { + providerIds: ["p1", "p2"], + providerTypes: ["aws"], + providerGroupIds: ["g1"], + }); + + expect(result.map((p) => p.id)).toEqual(["p1"]); + }); +}); diff --git a/ui/app/(prowler)/_overview/_lib/provider-scope.ts b/ui/app/(prowler)/_overview/_lib/provider-scope.ts new file mode 100644 index 00000000000..49973b201b0 --- /dev/null +++ b/ui/app/(prowler)/_overview/_lib/provider-scope.ts @@ -0,0 +1,71 @@ +import { ProviderProps } from "@/types/providers"; + +export interface ProviderScopeFilters { + providerIds: string[]; + providerTypes: string[]; + providerGroupIds: string[]; +} + +/** + * Normalize a comma-separated filter param into trimmed, non-empty ids. + * Guards against blank values (e.g. an empty "filter[...]=" param) so they are + * treated as "no filter" instead of matching against an empty-string id. + */ +export const parseFilterIds = ( + value: string | string[] | undefined, +): string[] => { + if (value === undefined) return []; + const raw = Array.isArray(value) ? value.join(",") : value; + return raw + .split(",") + .map((id) => id.trim()) + .filter((id) => id.length > 0); +}; + +const belongsToGroup = (provider: ProviderProps, groupIds: string[]): boolean => + provider.relationships.provider_groups?.data?.some((group) => + groupIds.includes(group.id), + ) ?? false; + +/** + * Keep only providers belonging to one of the selected groups. An empty group + * list means "no group filter" and returns every provider unchanged. + */ +export const scopeProvidersByGroup = ( + providers: ProviderProps[], + groupIds: string[], +): ProviderProps[] => + groupIds.length === 0 + ? providers + : providers.filter((p) => belongsToGroup(p, groupIds)); + +/** + * Filter providers by every active scope dimension (id, type, group) combined + * with AND. Each empty dimension is skipped, so a provider is kept only when it + * satisfies all the filters that are actually set. + */ +export const filterProvidersByScope = ( + providers: ProviderProps[], + { providerIds, providerTypes, providerGroupIds }: ProviderScopeFilters, +): ProviderProps[] => { + const normalizedTypes = providerTypes.map((type) => type.toLowerCase()); + + return providers.filter((provider) => { + if (providerIds.length > 0 && !providerIds.includes(provider.id)) { + return false; + } + if ( + normalizedTypes.length > 0 && + !normalizedTypes.includes(provider.attributes.provider.toLowerCase()) + ) { + return false; + } + if ( + providerGroupIds.length > 0 && + !belongsToGroup(provider, providerGroupIds) + ) { + return false; + } + return true; + }); +}; diff --git a/ui/app/(prowler)/_overview/graphs-tabs/risk-pipeline-view/risk-pipeline-view.ssr.tsx b/ui/app/(prowler)/_overview/graphs-tabs/risk-pipeline-view/risk-pipeline-view.ssr.tsx index b8479432e62..2a4b100379f 100644 --- a/ui/app/(prowler)/_overview/graphs-tabs/risk-pipeline-view/risk-pipeline-view.ssr.tsx +++ b/ui/app/(prowler)/_overview/graphs-tabs/risk-pipeline-view/risk-pipeline-view.ssr.tsx @@ -3,11 +3,16 @@ import { getFindingsBySeverity, SeverityByProviderType, } from "@/actions/overview"; +import { OVERVIEW_FILTER_PARAM } from "@/actions/overview/overview-filters"; import { getAllProviders } from "@/actions/providers"; import { SankeyChart } from "@/components/graphs/sankey-chart"; import { SearchParamsProps } from "@/types"; import { pickFilterParams } from "../../_lib/filter-params"; +import { + parseFilterIds, + scopeProvidersByGroup, +} from "../../_lib/provider-scope"; export async function RiskPipelineViewSSR({ searchParams, @@ -16,27 +21,31 @@ export async function RiskPipelineViewSSR({ }) { const filters = pickFilterParams(searchParams); - const providerTypeFilter = filters["filter[provider_type__in]"]; - const providerIdFilter = filters["filter[provider_id__in]"]; + const providerTypeFilter = filters[OVERVIEW_FILTER_PARAM.PROVIDER_TYPE]; + const providerIdFilter = filters[OVERVIEW_FILTER_PARAM.PROVIDER_ID]; + const providerGroupsFilter = filters[OVERVIEW_FILTER_PARAM.PROVIDER_GROUPS]; // Fetch providers list to know account types const providersListResponse = await getAllProviders(); const allProviders = providersListResponse?.data || []; + // Scope the provider set to the selected groups so we enumerate only their + // provider types below (the per-type API calls also carry the group filter). + const selectedGroupIds = parseFilterIds(providerGroupsFilter); + const scopedProviders = scopeProvidersByGroup(allProviders, selectedGroupIds); + // Build severityByProviderType based on filters const severityByProviderType: SeverityByProviderType = {}; let selectedProviderTypes: string[] | undefined; if (providerIdFilter) { // Case: Accounts are selected - group by provider type and make parallel calls - const selectedAccountIds = String(providerIdFilter) - .split(",") - .map((id) => id.trim()); + const selectedAccountIds = parseFilterIds(providerIdFilter); // Group selected accounts by provider type const accountsByType: Record = {}; for (const accountId of selectedAccountIds) { - const provider = allProviders.find((p) => p.id === accountId); + const provider = scopedProviders.find((p) => p.id === accountId); if (provider) { const type = provider.attributes.provider.toLowerCase(); if (!accountsByType[type]) { @@ -70,9 +79,9 @@ export async function RiskPipelineViewSSR({ } } else if (providerTypeFilter) { // Case: Provider types are selected - make parallel calls for each type - selectedProviderTypes = String(providerTypeFilter) - .split(",") - .map((t) => t.trim().toLowerCase()); + selectedProviderTypes = parseFilterIds(providerTypeFilter).map((type) => + type.toLowerCase(), + ); const severityPromises = selectedProviderTypes.map(async (providerType) => { const response = await getFindingsBySeverity({ @@ -93,9 +102,10 @@ export async function RiskPipelineViewSSR({ } } } else { - // Case: No filters - get all provider types and make parallel calls + // Case: No account/type filter - enumerate provider types (scoped to the + // selected groups when a group filter is active) and make parallel calls. const allProviderTypes = Array.from( - new Set(allProviders.map((p) => p.attributes.provider.toLowerCase())), + new Set(scopedProviders.map((p) => p.attributes.provider.toLowerCase())), ); const severityPromises = allProviderTypes.map(async (providerType) => { diff --git a/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot.ssr.tsx b/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot.ssr.tsx index 887eb7a5d5d..1f4d3625d4b 100644 --- a/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot.ssr.tsx +++ b/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot.ssr.tsx @@ -1,5 +1,6 @@ import { Info } from "lucide-react"; +import { OVERVIEW_FILTER_PARAM } from "@/actions/overview/overview-filters"; import { adaptToRiskPlotData, getProvidersRiskData, @@ -8,6 +9,10 @@ import { getAllProviders } from "@/actions/providers"; import { SearchParamsProps } from "@/types"; import { pickFilterParams } from "../../_lib/filter-params"; +import { + filterProvidersByScope, + parseFilterIds, +} from "../../_lib/provider-scope"; import { RiskPlotClient } from "./risk-plot-client"; export async function RiskPlotSSR({ @@ -17,31 +22,19 @@ export async function RiskPlotSSR({ }) { const filters = pickFilterParams(searchParams); - const providerTypeFilter = filters["filter[provider_type__in]"]; - const providerIdFilter = filters["filter[provider_id__in]"]; - // Fetch all providers const providersListResponse = await getAllProviders(); const allProviders = providersListResponse?.data || []; - // Filter providers based on search params - let filteredProviders = allProviders; - - if (providerIdFilter) { - // Filter by specific provider IDs - const selectedIds = String(providerIdFilter) - .split(",") - .map((id) => id.trim()); - filteredProviders = allProviders.filter((p) => selectedIds.includes(p.id)); - } else if (providerTypeFilter) { - // Filter by provider types - const selectedTypes = String(providerTypeFilter) - .split(",") - .map((t) => t.trim().toLowerCase()); - filteredProviders = allProviders.filter((p) => - selectedTypes.includes(p.attributes.provider.toLowerCase()), - ); - } + // Compose every active provider-scope filter with AND so combining e.g. a + // group and a type narrows to providers matching both. + const filteredProviders = filterProvidersByScope(allProviders, { + providerIds: parseFilterIds(filters[OVERVIEW_FILTER_PARAM.PROVIDER_ID]), + providerTypes: parseFilterIds(filters[OVERVIEW_FILTER_PARAM.PROVIDER_TYPE]), + providerGroupIds: parseFilterIds( + filters[OVERVIEW_FILTER_PARAM.PROVIDER_GROUPS], + ), + }); // No providers to show if (filteredProviders.length === 0) { diff --git a/ui/app/(prowler)/_overview/severity-over-time/_components/finding-severity-over-time.tsx b/ui/app/(prowler)/_overview/severity-over-time/_components/finding-severity-over-time.tsx index 9e0802d4ff6..b65b6a21f07 100644 --- a/ui/app/(prowler)/_overview/severity-over-time/_components/finding-severity-over-time.tsx +++ b/ui/app/(prowler)/_overview/severity-over-time/_components/finding-severity-over-time.tsx @@ -3,6 +3,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { useState } from "react"; +import { OVERVIEW_FILTER_PARAM } from "@/actions/overview/overview-filters"; import { getSeverityTrendsByTimeRange } from "@/actions/overview/severity-trends"; import { LineChart } from "@/components/graphs/line-chart"; import { LineConfig, LineDataPoint } from "@/components/graphs/types"; @@ -42,10 +43,16 @@ export const FindingSeverityOverTime = ({ const getActiveProviderFilters = (): Record => { const filters: Record = {}; - const providerType = searchParams.get("filter[provider_type__in]"); - const providerId = searchParams.get("filter[provider_id__in]"); - if (providerType) filters["filter[provider_type__in]"] = providerType; - if (providerId) filters["filter[provider_id__in]"] = providerId; + const providerType = searchParams.get(OVERVIEW_FILTER_PARAM.PROVIDER_TYPE); + const providerId = searchParams.get(OVERVIEW_FILTER_PARAM.PROVIDER_ID); + const providerGroups = searchParams.get( + OVERVIEW_FILTER_PARAM.PROVIDER_GROUPS, + ); + if (providerType) + filters[OVERVIEW_FILTER_PARAM.PROVIDER_TYPE] = providerType; + if (providerId) filters[OVERVIEW_FILTER_PARAM.PROVIDER_ID] = providerId; + if (providerGroups) + filters[OVERVIEW_FILTER_PARAM.PROVIDER_GROUPS] = providerGroups; return filters; }; diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx index f5d42f8bc8d..70320761eeb 100644 --- a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -27,6 +27,11 @@ import { import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance"; import { ContentLayout } from "@/components/ui"; import { getComplianceMapper } from "@/lib/compliance/compliance-mapper"; +import { + type ComplianceProviderFilters, + extractComplianceProviderFilters, + hasComplianceProviderFilters, +} from "@/lib/compliance/compliance-provider-filters"; import { getReportTypeForCompliance, pickLatestCisPerProvider, @@ -37,15 +42,19 @@ import { Framework, RequirementsTotals, } from "@/types/compliance"; +import { SearchParamsProps } from "@/types/components"; import { ScanEntity } from "@/types/scans"; -interface ComplianceDetailSearchParams { +interface ComplianceDetailSearchParams extends SearchParamsProps { complianceId: string; version?: string; scanId?: string; section?: string; "filter[region__in]"?: string; "filter[cis_profile_level]"?: string; + "filter[provider_type__in]"?: string; + "filter[provider_id__in]"?: string; + "filter[provider_groups__in]"?: string; page?: string; pageSize?: string; } @@ -64,6 +73,10 @@ export default async function ComplianceDetail({ const cisProfileFilter = resolvedSearchParams["filter[cis_profile_level]"]; const logoPath = getComplianceIcon(compliancetitle); + const hasProviderFilters = hasComplianceProviderFilters(resolvedSearchParams); + const providerFilters = + extractComplianceProviderFilters(resolvedSearchParams); + // Create a key that excludes pagination parameters to preserve accordion state avoiding reloads with pagination const paramsForKey = Object.fromEntries( Object.entries(resolvedSearchParams).filter( @@ -78,14 +91,15 @@ export default async function ComplianceDetail({ : `${formattedTitle}`; let selectedScan: ScanEntity | null = null; - const selectedScanId = scanId || null; + // Aggregated mode ignores scanId entirely (backend XOR); provider filters drive scope. + const selectedScanId = hasProviderFilters ? null : scanId || null; const [metadataInfoData, attributesData, selectedScanResponse] = await Promise.all([ getComplianceOverviewMetadataInfo({ - filters: { - "filter[scan_id]": selectedScanId ?? undefined, - }, + filters: hasProviderFilters + ? providerFilters + : { "filter[scan_id]": selectedScanId ?? undefined }, }), getComplianceAttributes(complianceId, selectedScanId ?? undefined), selectedScanId @@ -238,6 +252,8 @@ export default async function ComplianceDetail({ attributesData={attributesData} threatScoreData={threatScoreData} targetSection={section} + hasProviderFilters={hasProviderFilters} + providerFilters={providerFilters} /> @@ -252,6 +268,8 @@ const SSRComplianceContent = async ({ attributesData, threatScoreData, targetSection, + hasProviderFilters, + providerFilters, }: { complianceId: string; scanId: string; @@ -263,15 +281,18 @@ const SSRComplianceContent = async ({ sectionScores: Record; } | null; targetSection?: string; + hasProviderFilters: boolean; + providerFilters: ComplianceProviderFilters; }) => { const requirementsData = await getComplianceRequirements({ complianceId, - scanId, + scanId: scanId || undefined, region, + filters: hasProviderFilters ? providerFilters : undefined, }); const type = requirementsData?.data?.[0]?.type; - if (!scanId || type === "tasks") { + if ((!scanId && !hasProviderFilters) || type === "tasks") { return (
diff --git a/ui/app/(prowler)/compliance/page.test.tsx b/ui/app/(prowler)/compliance/page.test.tsx index 42bbbe672f3..e149dd5aded 100644 --- a/ui/app/(prowler)/compliance/page.test.tsx +++ b/ui/app/(prowler)/compliance/page.test.tsx @@ -13,4 +13,15 @@ describe("Compliance overview page", () => { expect(source).toContain("ComplianceOverviewGrid"); expect(source).not.toContain("filter[search]"); }); + + it("switches to aggregated mode when provider filters are present", () => { + expect(source).toContain("hasComplianceProviderFilters"); + expect(source).toContain("extractComplianceProviderFilters"); + }); + + it("feeds the provider and provider-group selectors", () => { + expect(source).toContain("getAllProviders"); + expect(source).toContain("getAllProviderGroups"); + expect(source).toContain("providerGroups={"); + }); }); diff --git a/ui/app/(prowler)/compliance/page.tsx b/ui/app/(prowler)/compliance/page.tsx index d4ee609a0c9..a3d75a47a5c 100644 --- a/ui/app/(prowler)/compliance/page.tsx +++ b/ui/app/(prowler)/compliance/page.tsx @@ -5,7 +5,9 @@ import { getComplianceOverviewMetadataInfo, getCompliancesOverview, } from "@/actions/compliances"; +import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups"; import { getThreatScore } from "@/actions/overview"; +import { getAllProviders } from "@/actions/providers/providers"; import { getScans } from "@/actions/scans"; import { ComplianceSkeletonGrid, @@ -17,6 +19,11 @@ import { ComplianceOverviewGrid } from "@/components/compliance/compliance-overv import { Alert, AlertDescription } from "@/components/shadcn/alert"; import { Card, CardContent } from "@/components/shadcn/card/card"; import { ContentLayout } from "@/components/ui"; +import { + type ComplianceProviderFilters, + extractComplianceProviderFilters, + hasComplianceProviderFilters, +} from "@/lib/compliance/compliance-provider-filters"; import { pickLatestCisPerProvider } from "@/lib/compliance/compliance-report-types"; import { ExpandedScanData, @@ -34,16 +41,24 @@ export default async function Compliance({ const resolvedSearchParams = await searchParams; const searchParamsKey = JSON.stringify(resolvedSearchParams || {}); - const scansData = await getScans({ - filters: { - "filter[state]": "completed", - }, - pageSize: 50, - fields: { - scans: "name,completed_at,provider", - }, - include: "provider", - }); + const hasProviderFilters = hasComplianceProviderFilters(resolvedSearchParams); + const providerFilters = + extractComplianceProviderFilters(resolvedSearchParams); + + const [scansData, providersData, providerGroupsData] = await Promise.all([ + getScans({ + filters: { + "filter[state]": "completed", + }, + pageSize: 50, + fields: { + scans: "name,completed_at,provider", + }, + include: "provider", + }), + getAllProviders(), + getAllProviderGroups(), + ]); if (!scansData?.data) { return ( @@ -90,9 +105,12 @@ export default async function Compliance({ const scanIdFromUrl = Array.isArray(scanIdParam) ? scanIdParam[0] : scanIdParam; - const selectedScanId: string | null = - scanIdFromUrl || expandedScansData[0]?.id || null; - const onboardingAction = selectedScanId + // Aggregated mode ignores scanId entirely (backend XOR); provider filters drive scope. + const selectedScanId: string | null = hasProviderFilters + ? null + : scanIdFromUrl || expandedScansData[0]?.id || null; + const hasScope = hasProviderFilters || Boolean(selectedScanId); + const onboardingAction = hasScope ? { flowId: "view-compliance" } : { flowId: "view-compliance", @@ -115,13 +133,15 @@ export default async function Compliance({ } : undefined; - const metadataInfoData = selectedScanId - ? await getComplianceOverviewMetadataInfo({ - filters: { - "filter[scan_id]": selectedScanId, - }, - }) - : { data: { attributes: { regions: [] } } }; + const metadataInfoData = hasProviderFilters + ? await getComplianceOverviewMetadataInfo({ filters: providerFilters }) + : selectedScanId + ? await getComplianceOverviewMetadataInfo({ + filters: { + "filter[scan_id]": selectedScanId, + }, + }) + : { data: { attributes: { regions: [] } } }; const uniqueRegions = metadataInfoData?.data?.attributes?.regions || []; @@ -146,13 +166,15 @@ export default async function Compliance({ icon="lucide:shield-check" onboardingAction={onboardingAction} > - {selectedScanId ? ( + {hasScope ? ( <>
@@ -182,6 +204,8 @@ export default async function Compliance({ searchParams={resolvedSearchParams} scanId={selectedScanId} selectedScan={selectedScanData} + hasProviderFilters={hasProviderFilters} + providerFilters={providerFilters} /> @@ -196,15 +220,23 @@ const SSRComplianceGrid = async ({ searchParams, scanId, selectedScan, + hasProviderFilters, + providerFilters, }: { searchParams: SearchParamsProps; scanId: string | null; selectedScan?: ScanEntity; + hasProviderFilters: boolean; + providerFilters: ComplianceProviderFilters; }) => { const regionFilter = searchParams["filter[region__in]"]?.toString() || ""; - const compliancesData = - scanId && scanId.trim() !== "" + const compliancesData = hasProviderFilters + ? await getCompliancesOverview({ + region: regionFilter, + filters: providerFilters, + }) + : scanId && scanId.trim() !== "" ? await getCompliancesOverview({ scanId, region: regionFilter, @@ -230,8 +262,9 @@ const SSRComplianceGrid = async ({ - This scan has no compliance data available yet, please select a - different one. + {hasProviderFilters + ? "No completed scans match the selected providers." + : "This scan has no compliance data available yet, please select a different one."} ); diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx index 65e8eca9cd0..96c1ca05dce 100644 --- a/ui/app/(prowler)/findings/page.tsx +++ b/ui/app/(prowler)/findings/page.tsx @@ -6,6 +6,7 @@ import { getLatestFindingGroups, } from "@/actions/finding-groups"; import { getLatestMetadataInfo, getMetadataInfo } from "@/actions/findings"; +import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups"; import { getAllProviders } from "@/actions/providers"; import { getScan, getScans } from "@/actions/scans"; import { SeedFromFindingsButton } from "@/app/(prowler)/alerts/_components"; @@ -36,8 +37,9 @@ export default async function Findings({ const { encodedSort } = extractSortAndKey(resolvedSearchParams); const { filters, query } = extractFiltersAndQuery(resolvedSearchParams); - const [providersData, scansData] = await Promise.all([ + const [providersData, providerGroupsData, scansData] = await Promise.all([ getAllProviders(), + getAllProviderGroups(), getScans({ pageSize: 50 }), ]); @@ -99,6 +101,7 @@ export default async function Findings({
; }) { const resolvedSearchParams = await searchParams; - const providersData = await getAllProviders(); + const [providersData, providerGroupsData] = await Promise.all([ + getAllProviders(), + getAllProviderGroups(), + ]); return (
+
diff --git a/ui/app/(prowler)/providers/page.tsx b/ui/app/(prowler)/providers/page.tsx index e6186df10a8..72b0af9d7f4 100644 --- a/ui/app/(prowler)/providers/page.tsx +++ b/ui/app/(prowler)/providers/page.tsx @@ -118,6 +118,7 @@ const ProvidersTabContent = async ({ isCloud={process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"} filters={providersView.filters} providers={providersView.providers} + providerGroups={providersView.providerGroups} metadata={providersView.metadata} rows={providersView.rows} /> diff --git a/ui/app/(prowler)/providers/providers-page.utils.test.ts b/ui/app/(prowler)/providers/providers-page.utils.test.ts index b5dcd6d8448..87a758f7b48 100644 --- a/ui/app/(prowler)/providers/providers-page.utils.test.ts +++ b/ui/app/(prowler)/providers/providers-page.utils.test.ts @@ -18,6 +18,10 @@ const schedulesActionsMock = vi.hoisted(() => ({ getSchedules: vi.fn(), })); +const manageGroupsActionsMock = vi.hoisted(() => ({ + getAllProviderGroups: vi.fn(), +})); + vi.mock("@/actions/providers", () => providersActionsMock); vi.mock( "@/actions/organizations/organizations", @@ -25,6 +29,7 @@ vi.mock( ); vi.mock("@/actions/scans", () => scansActionsMock); vi.mock("@/actions/schedules", () => schedulesActionsMock); +vi.mock("@/actions/manage-groups/manage-groups", () => manageGroupsActionsMock); import { SearchParamsProps } from "@/types"; import { ProvidersApiResponse } from "@/types/providers"; diff --git a/ui/app/(prowler)/providers/providers-page.utils.ts b/ui/app/(prowler)/providers/providers-page.utils.ts index a37d6eb5106..ba3a947772f 100644 --- a/ui/app/(prowler)/providers/providers-page.utils.ts +++ b/ui/app/(prowler)/providers/providers-page.utils.ts @@ -1,8 +1,10 @@ +import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups"; import { listOrganizationsSafe, listOrganizationUnitsSafe, } from "@/actions/organizations/organizations"; import { getAllProviders, getProviders } from "@/actions/providers"; +import { PROVIDERS_FILTER_PARAM } from "@/actions/providers/providers-filters"; import { getScans } from "@/actions/scans"; import { getSchedules } from "@/actions/schedules"; import { @@ -465,13 +467,12 @@ export async function loadProvidersAccountsViewData({ // Map provider_type__in (used by ProviderTypeSelector) to provider__in (API param) const providerTypeFilter = - providerFilters[`filter[${PROVIDERS_PAGE_FILTER.PROVIDER_TYPE}]`]; + providerFilters[PROVIDERS_FILTER_PARAM.PROVIDER_TYPE]; if (providerTypeFilter) { - providerFilters[`filter[${PROVIDERS_PAGE_FILTER.PROVIDER}]`] = - providerTypeFilter; + providerFilters[PROVIDERS_FILTER_PARAM.PROVIDER] = providerTypeFilter; } - delete providerFilters[`filter[${PROVIDERS_PAGE_FILTER.PROVIDER_TYPE}]`]; + delete providerFilters[PROVIDERS_FILTER_PARAM.PROVIDER_TYPE]; const emptyOrganizationsResponse: OrganizationListResponse = { data: [], @@ -483,6 +484,7 @@ export async function loadProvidersAccountsViewData({ const [ providersResponse, allProvidersResponse, + allProviderGroupsResponse, scansResponse, schedulesResponse, organizationsResponse, @@ -500,6 +502,8 @@ export async function loadProvidersAccountsViewData({ // Unfiltered fetch for ProviderTypeSelector — only needs distinct types; // TODO: Replace with a dedicated lightweight endpoint when available. resolveActionResult(getAllProviders()), + // Unfiltered fetch for the Provider Group selector dropdown. + resolveActionResult(getAllProviderGroups()), // Fetch active scheduled scans to flag providers whose schedule has fired. resolveActionResult( getScans({ @@ -545,6 +549,7 @@ export async function loadProvidersAccountsViewData({ filters: createProvidersFilters(), metadata: providersResponse?.meta, providers: allProvidersResponse?.data ?? [], + providerGroups: allProviderGroupsResponse?.data ?? [], rows, }; } diff --git a/ui/app/(prowler)/resources/page.tsx b/ui/app/(prowler)/resources/page.tsx index fb7e5ab63d4..9c9227160c4 100644 --- a/ui/app/(prowler)/resources/page.tsx +++ b/ui/app/(prowler)/resources/page.tsx @@ -1,5 +1,6 @@ import { Suspense } from "react"; +import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups"; import { getAllProviders } from "@/actions/providers"; import { getLatestMetadataInfo, @@ -37,19 +38,23 @@ export default async function Resources({ const initialResourceId = resolvedSearchParams.resourceId?.toString(); - const [metadataInfoData, providersData, resourceByIdData] = await Promise.all( - [ - (hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo)({ - query, - filters: outputFilters, - sort: encodedSort, - }), - getAllProviders(), - initialResourceId - ? getResourceById(initialResourceId, { include: ["provider"] }) - : Promise.resolve(undefined), - ], - ); + const [ + metadataInfoData, + providersData, + providerGroupsData, + resourceByIdData, + ] = await Promise.all([ + (hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo)({ + query, + filters: outputFilters, + sort: encodedSort, + }), + getAllProviders(), + getAllProviderGroups(), + initialResourceId + ? getResourceById(initialResourceId, { include: ["provider"] }) + : Promise.resolve(undefined), + ]); const processedResource = resourceByIdData?.data ? (() => { @@ -80,6 +85,7 @@ export default async function Resources({
; + `filter[${SCANS_PROVIDER_FILTER_FIELD.PROVIDER_UID_IN}]`, + `filter[${SCANS_PROVIDER_FILTER_FIELD.PROVIDER_UID}]`, +] as const satisfies ReadonlyArray; const PROVIDER_TYPE_FILTER_KEYS = [ - `filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_TYPE_IN}]`, - `filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_TYPE}]`, -] as const satisfies ReadonlyArray; + `filter[${SCANS_PROVIDER_FILTER_FIELD.PROVIDER_TYPE_IN}]`, + `filter[${SCANS_PROVIDER_FILTER_FIELD.PROVIDER_TYPE}]`, +] as const satisfies ReadonlyArray; + +const PROVIDER_GROUP_FILTER_KEYS = [ + `filter[${SCANS_PROVIDER_FILTER_FIELD.PROVIDER_GROUPS_IN}]`, +] as const satisfies ReadonlyArray; const getFilterSearchQuery = ( filters: Record, @@ -77,7 +75,7 @@ const parseCsvParam = (value?: string | string[]): string[] => { const getFirstSearchParam = ( searchParams: SearchParamsProps, - keys: ReadonlyArray, + keys: ReadonlyArray, ): string | string[] | undefined => { for (const key of keys) { const value = searchParams[key]; @@ -98,11 +96,18 @@ const filterProvidersForPendingRows = ( const types = parseCsvParam( getFirstSearchParam(searchParams, PROVIDER_TYPE_FILTER_KEYS), ); + const groups = parseCsvParam( + getFirstSearchParam(searchParams, PROVIDER_GROUP_FILTER_KEYS), + ); return providers.filter( (provider) => (uids.length === 0 || uids.includes(provider.attributes.uid)) && - (types.length === 0 || types.includes(provider.attributes.provider)), + (types.length === 0 || types.includes(provider.attributes.provider)) && + (groups.length === 0 || + (provider.relationships?.provider_groups?.data ?? []).some((group) => + groups.includes(group.id), + )), ); }; @@ -168,8 +173,18 @@ export default async function Scans({ const session = await auth(); const resolvedSearchParams = await searchParams; - const providersData = await getAllProviders(); - const providers = providersData?.data ?? []; + const [providersResult, providerGroupsResult] = await Promise.allSettled([ + getAllProviders(), + getAllProviderGroups(), + ]); + const providers = + providersResult.status === "fulfilled" + ? (providersResult.value?.data ?? []) + : []; + const providerGroups = + providerGroupsResult.status === "fulfilled" + ? (providerGroupsResult.value?.data ?? []) + : []; const connectedProviders = providers.filter( (provider: ProviderProps) => @@ -220,6 +235,7 @@ export default async function Scans({ ) : ( diff --git a/ui/components/compliance/compliance-accordion/client-accordion-content.test.tsx b/ui/components/compliance/compliance-accordion/client-accordion-content.test.tsx new file mode 100644 index 00000000000..bb1bfde5c7a --- /dev/null +++ b/ui/components/compliance/compliance-accordion/client-accordion-content.test.tsx @@ -0,0 +1,111 @@ +import { render, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { Requirement } from "@/types/compliance"; + +import { ClientAccordionContent } from "./client-accordion-content"; + +const { getFindingsMock, getLatestFindingsMock } = vi.hoisted(() => ({ + getFindingsMock: vi.fn(), + getLatestFindingsMock: vi.fn(), +})); + +let currentSearchParams = new URLSearchParams(); + +vi.mock("next/navigation", () => ({ + useSearchParams: () => currentSearchParams, +})); + +vi.mock("@/actions/findings/findings", () => ({ + getFindings: getFindingsMock, + getLatestFindings: getLatestFindingsMock, +})); + +vi.mock("@/components/findings/table", () => ({ + getStandaloneFindingColumns: () => [], + SkeletonTableFindings: () =>
, +})); + +vi.mock("@/components/ui/accordion/Accordion", () => ({ + Accordion: () =>
, +})); + +vi.mock("@/components/ui/table", () => ({ + DataTable: () =>
, +})); + +vi.mock("@/lib/compliance/compliance-mapper", () => ({ + getComplianceMapper: () => ({ getDetailsComponent: () => null }), +})); + +vi.mock("@/lib", () => ({ + createDict: () => ({}), + FINDINGS_DEFAULT_SORT: "severity", + MUTED_FILTER: { EXCLUDE: "false" }, +})); + +const requirement = { + check_ids: ["check-1"], + status: "FAIL", +} as unknown as Requirement; + +describe("ClientAccordionContent findings drill-down", () => { + beforeEach(() => { + vi.clearAllMocks(); + getFindingsMock.mockResolvedValue({ data: [], meta: {} }); + getLatestFindingsMock.mockResolvedValue({ data: [], meta: {} }); + }); + + describe("when provider filters drive aggregated mode", () => { + it("loads findings from the latest endpoint, not the scan-scoped one", async () => { + // Given - the URL carries a provider-scope filter and no scanId + currentSearchParams = new URLSearchParams({ + complianceId: "cis_2.0_aws", + "filter[provider_type__in]": "aws", + }); + + // When + render( + , + ); + + // Then - /findings 400s without a scan or date filter, so aggregated mode + // must use /findings/latest, forwarding the provider filters and no scan + await waitFor(() => + expect(getLatestFindingsMock).toHaveBeenCalledTimes(1), + ); + expect(getFindingsMock).not.toHaveBeenCalled(); + const { filters } = getLatestFindingsMock.mock.calls[0][0]; + expect(filters).toMatchObject({ "filter[provider_type__in]": "aws" }); + expect(filters).not.toHaveProperty("filter[scan]"); + }); + }); + + describe("when a single scan drives the scope", () => { + it("loads findings from the scan-scoped endpoint", async () => { + // Given - no provider filters, a concrete scanId + currentSearchParams = new URLSearchParams({ + complianceId: "cis_2.0_aws", + }); + + // When + render( + , + ); + + // Then + await waitFor(() => expect(getFindingsMock).toHaveBeenCalledTimes(1)); + expect(getLatestFindingsMock).not.toHaveBeenCalled(); + const { filters } = getFindingsMock.mock.calls[0][0]; + expect(filters).toMatchObject({ "filter[scan]": "scan-1" }); + }); + }); +}); diff --git a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx index 4a0ba6b1986..43051ebef08 100644 --- a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx +++ b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx @@ -3,7 +3,7 @@ import { useSearchParams } from "next/navigation"; import { useEffect, useRef, useState } from "react"; -import { getFindings } from "@/actions/findings/findings"; +import { getFindings, getLatestFindings } from "@/actions/findings/findings"; import { getStandaloneFindingColumns, SkeletonTableFindings, @@ -12,6 +12,7 @@ import { Accordion } from "@/components/ui/accordion/Accordion"; import { DataTable } from "@/components/ui/table"; import { createDict, FINDINGS_DEFAULT_SORT, MUTED_FILTER } from "@/lib"; import { getComplianceMapper } from "@/lib/compliance/compliance-mapper"; +import { extractComplianceProviderFilters } from "@/lib/compliance/compliance-provider-filters"; import { Requirement } from "@/types/compliance"; import { FindingProps, FindingsResponse } from "@/types/components"; @@ -46,6 +47,12 @@ export const ClientAccordionContent = ({ // so the requirement view stays consistent with every other findings // surface in the app (findings page, resource drawer, overview widgets). const mutedFilter = searchParams.get("filter[muted]") || MUTED_FILTER.EXCLUDE; + // Aggregated mode: the detail page carries provider filters instead of a scanId, + // so scope this requirement's findings by those providers rather than one scan. + // Stable string key keeps the effect deps free of a per-render object. + const providerScopeKey = new URLSearchParams( + extractComplianceProviderFilters(searchParams), + ).toString(); useEffect(() => { async function loadFindings() { @@ -68,10 +75,18 @@ export const ClientAccordionContent = ({ try { const checkIds = requirement.check_ids; const encodedSort = sort.replace(/^\+/, ""); - const findingsData = await getFindings({ + // Aggregated mode carries provider filters but no scan/date, which the + // /findings endpoint rejects (400). Use /findings/latest there — it + // needs neither and scopes to the latest scan per matching provider. + const isAggregated = providerScopeKey.length > 0; + const scopeFilters = isAggregated + ? Object.fromEntries(new URLSearchParams(providerScopeKey)) + : { "filter[scan]": scanId }; + const loadFindings = isAggregated ? getLatestFindings : getFindings; + const findingsData = await loadFindings({ filters: { "filter[check_id__in]": checkIds.join(","), - "filter[scan]": scanId, + ...scopeFilters, "filter[muted]": mutedFilter, ...(region && { "filter[region__in]": region }), }, @@ -115,6 +130,7 @@ export const ClientAccordionContent = ({ }, [ requirement, scanId, + providerScopeKey, pageNumber, pageSize, sort, diff --git a/ui/components/compliance/compliance-card.test.tsx b/ui/components/compliance/compliance-card.test.tsx index 996c8cb9799..b5a24b9cc2c 100644 --- a/ui/components/compliance/compliance-card.test.tsx +++ b/ui/components/compliance/compliance-card.test.tsx @@ -27,4 +27,21 @@ describe("ComplianceCard", () => { expect(source).toContain('orientation="column"'); expect(source).toContain('buttonWidth="icon"'); }); + + it("derives aggregated mode from the provider filters in the URL", () => { + expect(source).toContain("extractComplianceProviderFilters"); + expect(source).toContain("const isAggregated ="); + }); + + it("hides the per-scan PDF download in aggregated mode", () => { + expect(source).toContain("{!isAggregated && ("); + expect(source).toContain(" { + expect(source).toContain( + "providerFilters: isAggregated ? providerFilters : undefined", + ); + expect(source).toContain("scanId: isAggregated ? null : scanId"); + }); }); diff --git a/ui/components/compliance/compliance-card.tsx b/ui/components/compliance/compliance-card.tsx index 3972bb8db49..7dd53eadb5c 100644 --- a/ui/components/compliance/compliance-card.tsx +++ b/ui/components/compliance/compliance-card.tsx @@ -11,6 +11,7 @@ import { TooltipTrigger, } from "@/components/shadcn/tooltip"; import { buildComplianceDetailPath } from "@/lib/compliance/compliance-detail-url"; +import { extractComplianceProviderFilters } from "@/lib/compliance/compliance-provider-filters"; import { getReportTypeForCompliance } from "@/lib/compliance/compliance-report-types"; import { getScoreIndicatorClass, @@ -55,6 +56,11 @@ export const ComplianceCard: React.FC = ({ const router = useRouter(); const hasRegionFilter = searchParams.has("filter[region__in]"); + // Aggregated mode: provider filters replace the single-scan scope, so per-scan + // affordances (CIS PDF) are hidden and the drill-down carries provider filters. + const providerFilters = extractComplianceProviderFilters(searchParams); + const isAggregated = Object.keys(providerFilters).length > 0; + const formatTitle = (title: string) => { return title.split("-").join(" "); }; @@ -75,8 +81,9 @@ export const ComplianceCard: React.FC = ({ title, complianceId: id, version, - scanId, + scanId: isAggregated ? null : scanId, regionFilter: searchParams.get("filter[region__in]"), + providerFilters: isAggregated ? providerFilters : undefined, }), ); }; @@ -88,32 +95,34 @@ export const ComplianceCard: React.FC = ({ className="relative cursor-pointer transition-shadow hover:shadow-md" onClick={navigateToDetail} > -
e.stopPropagation()} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.stopPropagation(); - } - }} - role="group" - tabIndex={0} - > - -
+ {!isAggregated && ( +
e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.stopPropagation(); + } + }} + role="group" + tabIndex={0} + > + +
+ )}
diff --git a/ui/components/compliance/compliance-header/compliance-filters.test.tsx b/ui/components/compliance/compliance-header/compliance-filters.test.tsx new file mode 100644 index 00000000000..91ceab97762 --- /dev/null +++ b/ui/components/compliance/compliance-header/compliance-filters.test.tsx @@ -0,0 +1,148 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ComplianceFilters } from "./compliance-filters"; + +const { pushMock, updateFilterMock } = vi.hoisted(() => ({ + pushMock: vi.fn(), + updateFilterMock: vi.fn(), +})); + +let currentSearchParams = new URLSearchParams(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: pushMock }), + useSearchParams: () => currentSearchParams, +})); + +vi.mock("@/hooks/use-url-filters", () => ({ + useUrlFilters: () => ({ updateFilter: updateFilterMock }), +})); + +vi.mock("./scan-selector", () => ({ + ScanSelector: ({ + onSelectionChange, + }: { + onSelectionChange: (key: string) => void; + }) => ( +
+ ), + MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + MultiSelectValue: ({ placeholder }: { placeholder: string }) => ( + {placeholder} + ), + MultiSelectContent: ({ + children, + search, + }: { + children: React.ReactNode; + search?: unknown; + }) => { + multiSelectContentSpy(search); + return
{children}
; + }, + MultiSelectItem: ({ + children, + value, + keywords, + }: { + children: React.ReactNode; + value: string; + keywords?: string[]; + }) => ( +
+ {children} +
+ ), +})); + +const makeGroup = (id: string, name: string): ProviderGroup => ({ + type: "provider-groups", + id, + attributes: { name, inserted_at: "", updated_at: "" }, + relationships: { + providers: { meta: { count: 0 }, data: [] }, + roles: { meta: { count: 0 }, data: [] }, + }, + links: { self: "" }, +}); + +const groups = [ + makeGroup("group-1", "Production"), + makeGroup("group-2", "Dev"), +]; + +describe("ProviderGroupSelector", () => { + beforeEach(() => { + vi.clearAllMocks(); + currentSearchParams = new URLSearchParams(); + }); + + it("stays visible with the placeholder and empty message when there are no provider groups", () => { + render(); + + // Control is still rendered (visible even with zero groups)... + expect(screen.getByText("All Provider Groups")).toBeInTheDocument(); + // ...and the single empty state is the MultiSelect's own emptyMessage, + // not a duplicate custom message. + expect(multiSelectContentSpy).toHaveBeenCalledWith({ + placeholder: "Search Provider Groups...", + emptyMessage: "No Provider Groups found.", + }); + expect( + screen.queryByText("No Provider Groups available"), + ).not.toBeInTheDocument(); + }); + + it("passes searchable dropdown defaults to MultiSelectContent and lists groups", () => { + render(); + + expect(multiSelectContentSpy).toHaveBeenCalledWith({ + placeholder: "Search Provider Groups...", + emptyMessage: "No Provider Groups found.", + }); + expect(screen.getByText("Production")).toBeInTheDocument(); + expect(screen.getByText("Dev")).toBeInTheDocument(); + }); + + it("allows disabling search explicitly", () => { + render(); + + expect(multiSelectContentSpy).toHaveBeenLastCalledWith(false); + }); + + it("passes the group name as a search keyword", () => { + render(); + + expect( + screen.getByText("Production").closest("[data-value]"), + ).toHaveAttribute("data-keywords", expect.stringContaining("Production")); + }); + + it("disables select all when nothing is selected", () => { + render(); + + expect( + screen.getByRole("option", { name: /select all Provider Groups/i }), + ).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByText("All selected")).toBeInTheDocument(); + }); + + it("shows the selected count in the trigger when multiple groups are selected", () => { + render( + , + ); + + const trigger = screen.getByTestId("trigger"); + expect( + within(trigger).getByText("2 Provider Groups selected"), + ).toBeInTheDocument(); + }); + + it("shows the single group name in the trigger when one group is selected", () => { + render( + , + ); + + const trigger = screen.getByTestId("trigger"); + expect(within(trigger).getByText("Production")).toBeInTheDocument(); + }); + + it("instant mode: writes the selection to filter[provider_groups__in] in the URL", () => { + render(); + + fireEvent.click(screen.getByTestId("mock-select-group-2")); + + expect(navigateWithParamsMock).toHaveBeenCalledTimes(1); + const params = new URLSearchParams(); + navigateWithParamsMock.mock.calls[0][0](params); + expect(params.get("filter[provider_groups__in]")).toBe("group-2"); + }); + + it("instant mode: clearing deletes the filter key and the extra paramsToDeleteOnChange keys", () => { + currentSearchParams = new URLSearchParams( + "filter[provider_groups__in]=group-1&page=3&scanId=abc", + ); + render( + , + ); + + fireEvent.click( + screen.getByRole("option", { name: /select all Provider Groups/i }), + ); + + expect(navigateWithParamsMock).toHaveBeenCalledTimes(1); + const params = new URLSearchParams( + "filter[provider_groups__in]=group-1&page=3&scanId=abc", + ); + navigateWithParamsMock.mock.calls[0][0](params); + expect(params.has("filter[provider_groups__in]")).toBe(false); + expect(params.has("page")).toBe(false); + expect(params.has("scanId")).toBe(false); + }); + + it("does not navigate on clear when nothing is selected", () => { + render(); + + fireEvent.click( + screen.getByRole("option", { name: /select all Provider Groups/i }), + ); + + expect(navigateWithParamsMock).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/components/filters/provider-group-selector.tsx b/ui/components/filters/provider-group-selector.tsx new file mode 100644 index 00000000000..afa521f451c --- /dev/null +++ b/ui/components/filters/provider-group-selector.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; + +import { + MultiSelect, + MultiSelectContent, + MultiSelectItem, + type MultiSelectSearchProp, + MultiSelectTrigger, + MultiSelectValue, +} from "@/components/shadcn/select/multiselect"; +import { useUrlFilters } from "@/hooks/use-url-filters"; +import type { ProviderGroup } from "@/types/components"; + +const PROVIDER_GROUP_FILTER_KEY = "provider_groups__in"; +const URL_FILTER_KEY = `filter[${PROVIDER_GROUP_FILTER_KEY}]`; + +/** Common props shared by both batch and instant modes. */ +interface ProviderGroupSelectorBaseProps { + groups: ProviderGroup[]; + search?: MultiSelectSearchProp; + /** + * Instant mode only: extra URL params to delete when the selection changes + * (e.g. ["page", "scanId"]), mirroring ProviderAccountSelectors. Ignored in + * batch mode, where the parent owns URL updates. + */ + paramsToDeleteOnChange?: string[]; +} + +/** Batch mode: caller controls both pending state and notification callback (all-or-nothing). */ +interface ProviderGroupSelectorBatchProps + extends ProviderGroupSelectorBaseProps { + /** + * Called instead of navigating immediately. + * Use this on pages that batch filter changes (e.g. Findings). + * + * @param filterKey - The raw filter key without "filter[]" wrapper, e.g. "provider_groups__in" + * @param values - The selected values array + */ + onBatchChange: (filterKey: string, values: string[]) => void; + /** + * Pending selected values controlled by the parent. + * Reflects pending state before Apply is clicked. + */ + selectedValues: string[]; +} + +/** Instant mode: URL-driven — neither callback nor controlled value. */ +interface ProviderGroupSelectorInstantProps + extends ProviderGroupSelectorBaseProps { + onBatchChange?: never; + selectedValues?: never; +} + +type ProviderGroupSelectorProps = + | ProviderGroupSelectorBatchProps + | ProviderGroupSelectorInstantProps; + +export function ProviderGroupSelector({ + groups, + onBatchChange, + selectedValues, + search = { + placeholder: "Search Provider Groups...", + emptyMessage: "No Provider Groups found.", + }, + paramsToDeleteOnChange = [], +}: ProviderGroupSelectorProps) { + const searchParams = useSearchParams(); + const { navigateWithParams } = useUrlFilters(); + + const current = searchParams.get(URL_FILTER_KEY) || ""; + const urlSelectedIds = current ? current.split(",").filter(Boolean) : []; + + // In batch mode, use the parent-controlled pending values; otherwise, use URL state. + const selectedIds = onBatchChange ? selectedValues : urlSelectedIds; + + const handleMultiValueChange = (ids: string[]) => { + if (onBatchChange) { + onBatchChange(PROVIDER_GROUP_FILTER_KEY, ids); + return; + } + navigateWithParams((params) => { + if (ids.length > 0) { + params.set(URL_FILTER_KEY, ids.join(",")); + } else { + params.delete(URL_FILTER_KEY); + } + paramsToDeleteOnChange.forEach((key) => params.delete(key)); + }); + }; + + const selectedLabel = () => { + if (selectedIds.length === 0) return null; + if (selectedIds.length === 1) { + const group = groups.find((g) => g.id === selectedIds[0]); + return ( + + {group ? group.attributes.name : selectedIds[0]} + + ); + } + return ( + + {selectedIds.length} Provider Groups selected + + ); + }; + + return ( +
+ + + + {selectedLabel() || ( + + )} + + + {/* No items when empty: the MultiSelect's own emptyMessage is the + single empty state (avoids a duplicate "none" message). */} + {groups.length > 0 && ( + <> +
{ + if (selectedIds.length === 0) return; + handleMultiValueChange([]); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + if (selectedIds.length === 0) return; + handleMultiValueChange([]); + } + }} + > + {selectedIds.length === 0 ? "All selected" : "Select All"} +
+ {groups.map((group) => ( + + {group.attributes.name} + + ))} + + )} +
+
+
+ ); +} diff --git a/ui/components/findings/findings-filters.tsx b/ui/components/findings/findings-filters.tsx index 349769f3adb..cf25eb9529a 100644 --- a/ui/components/findings/findings-filters.tsx +++ b/ui/components/findings/findings-filters.tsx @@ -14,12 +14,14 @@ import { FilterSummaryStrip, } from "@/components/filters/filter-summary-strip"; import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors"; +import { ProviderGroupSelector } from "@/components/filters/provider-group-selector"; import { Button } from "@/components/shadcn"; import { ExpandableSection } from "@/components/ui/expandable-section"; import { DataTableFilterCustom } from "@/components/ui/table/data-table-filter-custom"; import { useFilterBatch } from "@/hooks/use-filter-batch"; import { getCategoryLabel, getGroupLabel } from "@/lib/categories"; -import { FilterType, ScanEntity } from "@/types"; +import { FILTER_FIELD, ScanEntity } from "@/types"; +import { ProviderGroup } from "@/types/components"; import { DATA_TABLE_FILTER_MODE } from "@/types/filters"; import { ProviderProps } from "@/types/providers"; @@ -31,6 +33,8 @@ import { interface FindingsFiltersProps { /** Provider data for provider/account filter controls. */ providers: ProviderProps[]; + /** Provider groups for the provider group filter control. */ + providerGroups?: ProviderGroup[]; completedScanIds: string[]; scanDetails: { [key: string]: ScanEntity }[]; uniqueRegions: string[]; @@ -70,6 +74,10 @@ const FILTER_GRID_ITEM_CLASS = "min-w-0"; export const FindingsFilterBatchControls = ({ providers, + // Undefined = caller opted out (the alert editor shares this component but + // loads no groups); an empty array still renders the control, so it stays + // visible even when a tenant has no groups yet. + providerGroups, completedScanIds, scanDetails, uniqueRegions, @@ -97,7 +105,7 @@ export const FindingsFilterBatchControls = ({ const customFilters = [ ...filterFindings - .filter((filter) => !isAlertsEdit || filter.key !== FilterType.STATUS) + .filter((filter) => !isAlertsEdit || filter.key !== FILTER_FIELD.STATUS) .map((filter) => ({ ...filter, labelFormatter: (value: string) => @@ -107,32 +115,32 @@ export const FindingsFilterBatchControls = ({ }), })), { - key: FilterType.REGION, + key: FILTER_FIELD.REGION, labelCheckboxGroup: "Regions", values: uniqueRegions, index: 3, }, { - key: FilterType.SERVICE, + key: FILTER_FIELD.SERVICE, labelCheckboxGroup: "Services", values: uniqueServices, index: 4, }, { - key: FilterType.RESOURCE_TYPE, + key: FILTER_FIELD.RESOURCE_TYPE, labelCheckboxGroup: "Resource Type", values: uniqueResourceTypes, index: 8, }, { - key: FilterType.CATEGORY, + key: FILTER_FIELD.CATEGORY, labelCheckboxGroup: "Category", values: uniqueCategories, labelFormatter: getCategoryLabel, index: 5, }, { - key: FilterType.RESOURCE_GROUPS, + key: FILTER_FIELD.RESOURCE_GROUPS, labelCheckboxGroup: "Resource Group", values: uniqueGroups, labelFormatter: getGroupLabel, @@ -142,14 +150,14 @@ export const FindingsFilterBatchControls = ({ ? [] : [ { - key: FilterType.SCAN, + key: FILTER_FIELD.SCAN, labelCheckboxGroup: "Scan ID", values: completedScanIds, width: "wide" as const, valueLabelMapping: scanDetails, labelFormatter: (value: string) => getFindingsFilterDisplayValue( - `filter[${FilterType.SCAN}]`, + `filter[${FILTER_FIELD.SCAN}]`, value, { providers, @@ -167,6 +175,7 @@ export const FindingsFilterBatchControls = ({ appliedFilters, { providers, + providerGroups, scans: scanDetails, }, ); @@ -174,6 +183,7 @@ export const FindingsFilterBatchControls = ({ changedFilters, { providers, + providerGroups, scans: scanDetails, }, ); @@ -199,15 +209,26 @@ export const FindingsFilterBatchControls = ({ : undefined; const providerAccountControls = (className: string) => ( - + <> + + {providerGroups !== undefined && ( +
+ +
+ )} + ); const alertEditFilterGrid = hasCustomFilters ? ( diff --git a/ui/components/findings/findings-filters.utils.test.ts b/ui/components/findings/findings-filters.utils.test.ts index aa376f360d3..69ce0605d22 100644 --- a/ui/components/findings/findings-filters.utils.test.ts +++ b/ui/components/findings/findings-filters.utils.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { ProviderGroup } from "@/types/components"; import { ProviderProps } from "@/types/providers"; import { ScanEntity } from "@/types/scans"; @@ -8,6 +9,19 @@ import { getFindingsFilterDisplayValue, } from "./findings-filters.utils"; +const providerGroups: ProviderGroup[] = [ + { + type: "provider-groups", + id: "group-1", + attributes: { name: "Production", inserted_at: "", updated_at: "" }, + relationships: { + providers: { meta: { count: 0 }, data: [] }, + roles: { meta: { count: 0 }, data: [] }, + }, + links: { self: "" }, + }, +]; + function makeProvider( overrides: Partial & { id: string }, ): ProviderProps { @@ -98,6 +112,24 @@ describe("getFindingsFilterDisplayValue", () => { ).toBe("missing-provider"); }); + it("shows the provider group name for provider_groups filters instead of the raw group id", () => { + expect( + getFindingsFilterDisplayValue("filter[provider_groups__in]", "group-1", { + providerGroups, + }), + ).toBe("Production"); + }); + + it("keeps the raw value when the provider group cannot be resolved", () => { + expect( + getFindingsFilterDisplayValue( + "filter[provider_groups__in]", + "missing-group", + { providerGroups }, + ), + ).toBe("missing-group"); + }); + it("shows the resolved scan badge label for scan filters instead of formatting the raw scan id", () => { expect( getFindingsFilterDisplayValue("filter[scan__in]", "scan-1", { scans }), @@ -230,6 +262,22 @@ describe("buildFindingsFilterChips", () => { ]); }); + it("labels provider group chips and resolves their names", () => { + const chips = buildFindingsFilterChips( + { "filter[provider_groups__in]": ["group-1"] }, + { providerGroups }, + ); + + expect(chips).toEqual([ + { + key: "filter[provider_groups__in]", + label: "Provider Group", + value: "group-1", + displayValue: "Production", + }, + ]); + }); + it("treats filter[delta] and filter[delta__in] identically", () => { // Given const chipsSingular = buildFindingsFilterChips({ diff --git a/ui/components/findings/findings-filters.utils.ts b/ui/components/findings/findings-filters.utils.ts index b2a935aec0d..4cb9eef3946 100644 --- a/ui/components/findings/findings-filters.utils.ts +++ b/ui/components/findings/findings-filters.utils.ts @@ -1,8 +1,12 @@ +import type { FindingsFilterParam } from "@/actions/findings/findings-filters"; import type { FilterChip } from "@/components/filters/filter-summary-strip"; import { formatLabel, getCategoryLabel, getGroupLabel } from "@/lib/categories"; -import { getScanEntityLabel } from "@/lib/helper-filters"; +import { + getProviderGroupDisplayValue, + getScanEntityLabel, +} from "@/lib/helper-filters"; import { FINDING_STATUS_DISPLAY_NAMES } from "@/types"; -import { FilterParam } from "@/types/filters"; +import { ProviderGroup } from "@/types/components"; import { getProviderDisplayName, ProviderProps } from "@/types/providers"; import { ScanEntity } from "@/types/scans"; import { SEVERITY_DISPLAY_NAMES } from "@/types/severities"; @@ -10,6 +14,7 @@ import { SEVERITY_DISPLAY_NAMES } from "@/types/severities"; interface GetFindingsFilterDisplayValueOptions { providers?: ProviderProps[]; scans?: Array<{ [scanId: string]: ScanEntity }>; + providerGroups?: ProviderGroup[]; } const FINDING_DELTA_DISPLAY_NAMES: Record = { @@ -42,7 +47,7 @@ function getScanDisplayValue( } export function getFindingsFilterDisplayValue( - filterKey: string, + filterKey: FindingsFilterParam, value: string, options: GetFindingsFilterDisplayValueOptions = {}, ): string { @@ -53,6 +58,9 @@ export function getFindingsFilterDisplayValue( if (filterKey === "filter[provider_id__in]") { return getProviderAccountDisplayValue(value, options.providers || []); } + if (filterKey === "filter[provider_groups__in]") { + return getProviderGroupDisplayValue(value, options.providerGroups || []); + } if (filterKey === "filter[scan__in]" || filterKey === "filter[scan]") { return getScanDisplayValue(value, options.scans || []); } @@ -95,12 +103,14 @@ export function getFindingsFilterDisplayValue( /** * Maps raw filter param keys (e.g. "filter[severity__in]") to human-readable labels. * Used to render chips in the FilterSummaryStrip. - * Typed as Record so TypeScript enforces exhaustiveness — any - * addition to FilterParam will cause a compile error here if the label is missing. + * Typed as Record so TypeScript enforces exhaustiveness + * — any addition to the findings filter set will cause a compile error here if the + * label is missing. */ -export const FILTER_KEY_LABELS: Record = { +export const FILTER_KEY_LABELS: Record = { "filter[provider_type__in]": "Provider", "filter[provider_id__in]": "Account", + "filter[provider_groups__in]": "Provider Group", "filter[severity__in]": "Severity", "filter[status__in]": "Status", "filter[delta__in]": "Delta", @@ -115,12 +125,15 @@ export const FILTER_KEY_LABELS: Record = { "filter[scan_id]": "Scan", "filter[scan_id__in]": "Scan", "filter[inserted_at]": "Date", + "filter[inserted_at__gte]": "Date", + "filter[inserted_at__lte]": "Date", "filter[muted]": "Muted", }; interface BuildFindingsFilterChipsOptions { providers?: ProviderProps[]; scans?: Array<{ [scanId: string]: ScanEntity }>; + providerGroups?: ProviderGroup[]; includeMuted?: boolean; } @@ -142,13 +155,13 @@ export function buildFindingsFilterChips( Object.entries(pendingFilters).forEach(([key, values]) => { if (!values || values.length === 0) return; if (key === "filter[muted]" && !options.includeMuted) return; - const label = FILTER_KEY_LABELS[key as FilterParam] ?? key; + const label = FILTER_KEY_LABELS[key as FindingsFilterParam] ?? key; const visibleValues = values; if (visibleValues.length === 0) return; const displayValues = visibleValues.map((value) => - getFindingsFilterDisplayValue(key, value, options), + getFindingsFilterDisplayValue(key as FindingsFilterParam, value, options), ); const chip: FilterChip = { diff --git a/ui/components/providers/providers-accounts-view.tsx b/ui/components/providers/providers-accounts-view.tsx index d1ff22a9581..9dcfc5e4774 100644 --- a/ui/components/providers/providers-accounts-view.tsx +++ b/ui/components/providers/providers-accounts-view.tsx @@ -28,6 +28,7 @@ import { getTourTargetSelector, } from "@/lib/tours/use-driver-tour"; import type { FilterOption, MetaDataProps, ProviderProps } from "@/types"; +import type { ProviderGroup } from "@/types/components"; import type { ProvidersTableRow } from "@/types/providers-table"; import type { ScanScheduleCapability } from "@/types/schedules"; @@ -51,6 +52,7 @@ interface ProvidersAccountsViewProps { filters: FilterOption[]; metadata?: MetaDataProps; providers: ProviderProps[]; + providerGroups?: ProviderGroup[]; rows: ProvidersTableRow[]; /** Cloud overlay seam for provider-creation scan launch. */ scanScheduleCapability?: ScanScheduleCapability; @@ -62,6 +64,7 @@ export function ProvidersAccountsView({ filters, metadata, providers, + providerGroups = [], rows, scanScheduleCapability, isScanLimitReached, @@ -141,6 +144,7 @@ export function ProvidersAccountsView({ diff --git a/ui/components/providers/providers-filters.test.tsx b/ui/components/providers/providers-filters.test.tsx index b6e6655247b..37080b3f77e 100644 --- a/ui/components/providers/providers-filters.test.tsx +++ b/ui/components/providers/providers-filters.test.tsx @@ -16,6 +16,10 @@ vi.mock("@/app/(prowler)/_overview/_components/provider-type-selector", () => ({ ProviderTypeSelector: () =>
Provider type selector
, })); +vi.mock("@/components/filters/provider-group-selector", () => ({ + ProviderGroupSelector: () =>
Provider group selector
, +})); + vi.mock("@/components/filters/clear-filters-button", () => ({ ClearFiltersButton: () => , })); diff --git a/ui/components/providers/providers-filters.tsx b/ui/components/providers/providers-filters.tsx index 285bae4c1e6..d488a5849c9 100644 --- a/ui/components/providers/providers-filters.tsx +++ b/ui/components/providers/providers-filters.tsx @@ -5,6 +5,7 @@ import type { ReactNode } from "react"; import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector"; import { ClearFiltersButton } from "@/components/filters/clear-filters-button"; +import { ProviderGroupSelector } from "@/components/filters/provider-group-selector"; import { MultiSelect, MultiSelectContent, @@ -18,6 +19,7 @@ import { EntityInfo } from "@/components/ui/entities/entity-info"; import { useUrlFilters } from "@/hooks/use-url-filters"; import { isConnectionStatus, isGroupFilterEntity } from "@/lib/helper-filters"; import { FilterEntity, FilterOption, ProviderEntity } from "@/types"; +import { ProviderGroup } from "@/types/components"; import { GroupFilterEntity, ProviderConnectionStatus, @@ -31,12 +33,14 @@ function isNonEmptyString(value: string | null | undefined): value is string { interface ProvidersFiltersProps { filters: FilterOption[]; providers: ProviderProps[]; + providerGroups?: ProviderGroup[]; actions?: ReactNode; } export const ProvidersFilters = ({ filters, providers, + providerGroups = [], actions, }: ProvidersFiltersProps) => { const { updateFilter } = useUrlFilters(); @@ -153,6 +157,9 @@ export const ProvidersFilters = ({
+
+ +
{sortedFilters.map((filter) => { const selectedValues = getSelectedValues(filter); return ( diff --git a/ui/components/resources/resources-filters.tsx b/ui/components/resources/resources-filters.tsx index d6b90a6c876..f493c2a3857 100644 --- a/ui/components/resources/resources-filters.tsx +++ b/ui/components/resources/resources-filters.tsx @@ -11,11 +11,13 @@ import { FilterSummaryStrip, } from "@/components/filters/filter-summary-strip"; import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors"; +import { ProviderGroupSelector } from "@/components/filters/provider-group-selector"; import { Button } from "@/components/shadcn"; import { ExpandableSection } from "@/components/ui/expandable-section"; import { DataTableFilterCustom } from "@/components/ui/table"; import { useFilterBatch } from "@/hooks/use-filter-batch"; import { getGroupLabel } from "@/lib/categories"; +import { ProviderGroup } from "@/types/components"; import { DATA_TABLE_FILTER_MODE } from "@/types/filters"; import { ProviderProps } from "@/types/providers"; @@ -26,6 +28,7 @@ import { interface ResourcesFiltersProps { providers: ProviderProps[]; + providerGroups?: ProviderGroup[]; uniqueRegions: string[]; uniqueServices: string[]; uniqueResourceTypes: string[]; @@ -40,6 +43,7 @@ const FILTER_CONTROL_COLUMN_CLASS = export const ResourcesFilters = ({ providers, + providerGroups = [], uniqueRegions, uniqueServices, uniqueResourceTypes, @@ -93,10 +97,12 @@ export const ResourcesFilters = ({ const appliedFilterChips: FilterChip[] = buildResourcesFilterChips( appliedFilters, providers, + providerGroups, ); const pendingFilterChips: FilterChip[] = buildResourcesFilterChips( changedFilters, providers, + providerGroups, ); const appliedCount = countVisibleFilterKeys(appliedFilters); const showAppliedRow = appliedFilterChips.length > 0; @@ -178,6 +184,13 @@ export const ResourcesFilters = ({ providerSelectorClassName={FILTER_CONTROL_COLUMN_CLASS} accountSelectorClassName={FILTER_CONTROL_COLUMN_CLASS} /> +
+ +
{hasCustomFilters && (