From 5b9824c37933d273ba94ec09895a8e29b857f05b Mon Sep 17 00:00:00 2001 From: "Pablo Fernandez Guerra (PFE)" <148432447+pfe-nazaries@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:32:00 +0200 Subject: [PATCH] feat(ui): filter by provider group across main views (#11659) Co-authored-by: Pablo F.G Co-authored-by: Claude Opus 4.8 (1M context) --- ui/CHANGELOG.md | 8 + ui/actions/finding-groups/finding-groups.ts | 6 +- ui/actions/findings/findings-filters.ts | 40 +++ .../manage-groups/manage-groups.test.ts | 138 ++++++++++ ui/actions/manage-groups/manage-groups.ts | 81 ++++++ ui/actions/overview/overview-filters.ts | 16 ++ ui/actions/providers/providers-filters.ts | 18 ++ ui/actions/resources/resources-filters.ts | 25 ++ ui/actions/scans/scans-filters.ts | 34 +++ .../_components/accounts-selector.test.tsx | 4 +- .../_components/accounts-selector.tsx | 6 +- .../_overview/_lib/provider-scope.test.ts | 162 ++++++++++++ .../_overview/_lib/provider-scope.ts | 71 ++++++ .../risk-pipeline-view.ssr.tsx | 32 ++- .../graphs-tabs/risk-plot/risk-plot.ssr.tsx | 35 ++- .../finding-severity-over-time.tsx | 15 +- ui/app/(prowler)/findings/page.tsx | 5 +- ui/app/(prowler)/page.tsx | 8 +- ui/app/(prowler)/providers/page.tsx | 1 + .../providers/providers-page.utils.test.ts | 5 + .../providers/providers-page.utils.ts | 13 +- ui/app/(prowler)/resources/page.tsx | 32 ++- ui/app/(prowler)/scans/page.tsx | 57 +++-- ui/components/filters/data-filters.ts | 8 +- .../provider-account-selectors.test.tsx | 6 +- .../filters/provider-account-selectors.tsx | 4 +- .../filters/provider-group-selector.test.tsx | 236 ++++++++++++++++++ .../filters/provider-group-selector.tsx | 171 +++++++++++++ ui/components/findings/findings-filters.tsx | 57 +++-- .../findings/findings-filters.utils.test.ts | 48 ++++ .../findings/findings-filters.utils.ts | 29 ++- .../providers/providers-accounts-view.tsx | 4 + .../providers/providers-filters.test.tsx | 4 + ui/components/providers/providers-filters.tsx | 7 + ui/components/resources/resources-filters.tsx | 13 + .../resources/resources-filters.utils.test.ts | 67 +++++ .../resources/resources-filters.utils.ts | 17 +- ui/components/scans/scans-filter-bar.test.tsx | 4 + ui/components/scans/scans-filter-bar.tsx | 15 +- ui/components/scans/scans-page-shell.tsx | 4 + ui/hooks/use-filter-batch.test.ts | 8 +- ui/hooks/use-related-filters.ts | 15 +- ui/lib/helper-filters.test.ts | 30 +++ ui/lib/helper-filters.ts | 14 ++ ui/types/filters.ts | 76 +++--- ui/types/providers-table.ts | 3 +- 46 files changed, 1475 insertions(+), 177 deletions(-) create mode 100644 ui/actions/findings/findings-filters.ts create mode 100644 ui/actions/manage-groups/manage-groups.test.ts create mode 100644 ui/actions/overview/overview-filters.ts create mode 100644 ui/actions/providers/providers-filters.ts create mode 100644 ui/actions/resources/resources-filters.ts create mode 100644 ui/actions/scans/scans-filters.ts create mode 100644 ui/app/(prowler)/_overview/_lib/provider-scope.test.ts create mode 100644 ui/app/(prowler)/_overview/_lib/provider-scope.ts create mode 100644 ui/components/filters/provider-group-selector.test.tsx create mode 100644 ui/components/filters/provider-group-selector.tsx create mode 100644 ui/components/resources/resources-filters.utils.test.ts diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 7d76c8a27f..275c8ef1d9 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to the **Prowler UI** are documented in this file. +## [1.32.0] (Prowler UNRELEASED) + +### 🚀 Added + +- Filter the Overview, Findings, Resources, Scans, and Providers views by provider group [(#11659)](https://github.com/prowler-cloud/prowler/pull/11659) + +--- + ## [1.31.1] (Prowler v5.31.1) ### 🔄 Changed diff --git a/ui/actions/finding-groups/finding-groups.ts b/ui/actions/finding-groups/finding-groups.ts index bf5df80aae..faa697cf32 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 0000000000..268d71e7cd --- /dev/null +++ b/ui/actions/findings/findings-filters.ts @@ -0,0 +1,40 @@ +import { FILTER_FIELD, FilterParam } from "@/types/filters"; + +/** Findings-only filter fields not shared with other views. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const FINDINGS_EXTRA_FIELD = { + DELTA_IN: "delta__in", + SCAN_EXACT: "scan", + SCAN_ID: "scan_id", + SCAN_ID_IN: "scan_id__in", + INSERTED_AT: "inserted_at", + INSERTED_AT_GTE: "inserted_at__gte", + INSERTED_AT_LTE: "inserted_at__lte", + MUTED: "muted", +} as const; + +type FindingsExtraField = + (typeof FINDINGS_EXTRA_FIELD)[keyof typeof FINDINGS_EXTRA_FIELD]; + +/** + * 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"] + | FindingsExtraField +>; 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 0000000000..c016832a1f --- /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 933dabbdbc..c916a89d39 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 0000000000..186977f9ce --- /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 0000000000..71184c0b81 --- /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 0000000000..01132943a6 --- /dev/null +++ b/ui/actions/resources/resources-filters.ts @@ -0,0 +1,25 @@ +import { FILTER_FIELD, FilterParam } from "@/types/filters"; + +/** Resources-only filter fields not shared with other views. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const RESOURCES_EXTRA_FIELD = { + TYPE: "type__in", + GROUPS: "groups__in", +} as const; + +type ResourcesExtraField = + (typeof RESOURCES_EXTRA_FIELD)[keyof typeof RESOURCES_EXTRA_FIELD]; + +/** + * 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"] + | ResourcesExtraField +>; diff --git a/ui/actions/scans/scans-filters.ts b/ui/actions/scans/scans-filters.ts new file mode 100644 index 0000000000..19958e9fa5 --- /dev/null +++ b/ui/actions/scans/scans-filters.ts @@ -0,0 +1,34 @@ +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_IN: FILTER_FIELD.PROVIDER, + PROVIDER: "provider", + PROVIDER_TYPE_IN: FILTER_FIELD.PROVIDER_TYPE, + PROVIDER_TYPE: "provider_type", + PROVIDER_GROUPS_IN: FILTER_FIELD.PROVIDER_GROUPS, +} as const; + +/** Scans-only filter fields not shared with other views. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const SCANS_EXTRA_FIELD = { + STATE: "state__in", + TRIGGER: "trigger", +} as const; + +type ScansExtraField = + (typeof SCANS_EXTRA_FIELD)[keyof typeof SCANS_EXTRA_FIELD]; + +/** + * URL filter param keys the scans view supports, e.g. `filter[state__in]`. + * Provider scope (scans filters accounts by 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] + | ScansExtraField +>; diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx index 13f37fad4f..e69d0427ee 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx @@ -2,7 +2,7 @@ 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 { FILTER_FIELD } from "@/types/filters"; import { AccountsSelector } from "./accounts-selector"; @@ -189,7 +189,7 @@ describe("AccountsSelector", () => { render( , ); diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx index 8a7c08b11c..ab5c27fd6c 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx @@ -17,7 +17,7 @@ import { MultiSelectValue, } from "@/components/shadcn/select/multiselect"; import { useUrlFilters } from "@/hooks/use-url-filters"; -import { type AccountFilterKey, FilterType } from "@/types/filters"; +import { type AccountFilterKey, FILTER_FIELD } from "@/types/filters"; import { getProviderDisplayName, type ProviderProps, @@ -68,7 +68,7 @@ export function AccountsSelector({ providers, onBatchChange, selectedValues, - filterKey = FilterType.PROVIDER_ID, + filterKey = FILTER_FIELD.PROVIDER_ID, id = "accounts-selector", disabledValues = [], search = { @@ -91,7 +91,7 @@ export function AccountsSelector({ const visibleProviders = providers; const getProviderValue = (provider: ProviderProps) => - filterKey === FilterType.PROVIDER_UID + filterKey === FILTER_FIELD.PROVIDER_UID ? provider.attributes.uid : provider.id; const disabledValuesSet = new Set(disabledValues); 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 0000000000..3071f84837 --- /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 0000000000..49973b201b --- /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 b8479432e6..2a4b100379 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 887eb7a5d5..1f4d3625d4 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 9e0802d4ff..b65b6a21f0 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)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx index 65e8eca9cd..96c1ca05dc 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 e6186df10a..72b0af9d7f 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 6cac891480..4c712ff081 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 0b589557bb..52a56c4fe0 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 { getSchedules } from "@/actions/schedules"; import { extractFiltersAndQuery, @@ -484,13 +486,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: [], @@ -502,6 +503,7 @@ export async function loadProvidersAccountsViewData({ const [ providersResponse, allProvidersResponse, + allProviderGroupsResponse, schedulesResponse, organizationsResponse, organizationUnitsResponse, @@ -518,6 +520,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 configured schedules as a fallback when provider scan_* fields are // absent (best-effort: typically empty in OSS). resolveActionResult(getSchedules()), @@ -546,6 +550,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 fb7e5ab63d..9c9227160c 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_IN}]`, + `filter[${SCANS_PROVIDER_FILTER_FIELD.PROVIDER}]`, +] 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, @@ -86,7 +83,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]; @@ -107,11 +104,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) => (ids.length === 0 || ids.includes(provider.id)) && - (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), + )), ); }; @@ -177,8 +181,12 @@ export default async function Scans({ const session = await auth(); const resolvedSearchParams = await searchParams; - const providersData = await getAllProviders(); + const [providersData, providerGroupsData] = await Promise.all([ + getAllProviders(), + getAllProviderGroups(), + ]); const providers = providersData?.data ?? []; + const providerGroups = providerGroupsData?.data ?? []; const connectedProviders = providers.filter( (provider: ProviderProps) => @@ -229,6 +237,7 @@ export default async function Scans({ ) : ( diff --git a/ui/components/filters/data-filters.ts b/ui/components/filters/data-filters.ts index 2457b1a8b2..83969d0362 100644 --- a/ui/components/filters/data-filters.ts +++ b/ui/components/filters/data-filters.ts @@ -1,5 +1,5 @@ import { CONNECTION_STATUS_MAPPING } from "@/lib/helper-filters"; -import { FilterOption, FilterType } from "@/types/filters"; +import { FILTER_FIELD, FilterOption } from "@/types/filters"; import { PROVIDER_DISPLAY_NAMES, PROVIDER_TYPES, @@ -64,19 +64,19 @@ export const filterScans = [ //Static filters for findings export const filterFindings = [ { - key: FilterType.SEVERITY, + key: FILTER_FIELD.SEVERITY, labelCheckboxGroup: "Severity", values: ["critical", "high", "medium", "low", "informational"], index: 0, }, { - key: FilterType.STATUS, + key: FILTER_FIELD.STATUS, labelCheckboxGroup: "Status", values: ["PASS", "FAIL", "MANUAL"], index: 1, }, { - key: FilterType.DELTA, + key: FILTER_FIELD.DELTA, labelCheckboxGroup: "Delta", values: ["new", "changed"], index: 2, diff --git a/ui/components/filters/provider-account-selectors.test.tsx b/ui/components/filters/provider-account-selectors.test.tsx index a8b8bd1689..01868d0e8c 100644 --- a/ui/components/filters/provider-account-selectors.test.tsx +++ b/ui/components/filters/provider-account-selectors.test.tsx @@ -1,7 +1,7 @@ import { render } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { FilterType } from "@/types/filters"; +import { FILTER_FIELD } from "@/types/filters"; import type { ProviderProps } from "@/types/providers"; import { ProviderAccountSelectors } from "./provider-account-selectors"; @@ -171,7 +171,7 @@ describe("ProviderAccountSelectors", () => { render( , @@ -230,7 +230,7 @@ describe("ProviderAccountSelectors", () => { ({ + navigateWithParamsMock: vi.fn(), +})); + +let currentSearchParams = new URLSearchParams(); + +vi.mock("next/navigation", () => ({ + useSearchParams: () => currentSearchParams, +})); + +vi.mock("@/hooks/use-url-filters", () => ({ + useUrlFilters: () => ({ + navigateWithParams: navigateWithParamsMock, + }), +})); + +vi.mock("@/components/shadcn/select/multiselect", () => ({ + MultiSelect: ({ + children, + onValuesChange, + }: { + children: React.ReactNode; + onValuesChange: (values: 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("defaults the control id and links the sr-only label to it", () => { + render(); + + const label = screen.getByText(/Filter by Provider Group/i); + expect(label).toHaveAttribute("for", "provider-group-selector"); + expect(label).toHaveAttribute("id", "provider-group-selector-label"); + }); + + it("applies a custom id so multiple instances don't collide", () => { + render( + , + ); + + const label = screen.getByText(/Filter by Provider Group/i); + expect(label).toHaveAttribute("for", "resources-provider-group"); + expect(label).toHaveAttribute("id", "resources-provider-group-label"); + }); + + 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 0000000000..e7194d694b --- /dev/null +++ b/ui/components/filters/provider-group-selector.tsx @@ -0,0 +1,171 @@ +"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"; +import { FILTER_FIELD } from "@/types/filters"; + +const PROVIDER_GROUP_FILTER_KEY = FILTER_FIELD.PROVIDER_GROUPS; +const URL_FILTER_KEY = `filter[${PROVIDER_GROUP_FILTER_KEY}]`; + +/** Common props shared by both batch and instant modes. */ +interface ProviderGroupSelectorBaseProps { + groups: ProviderGroup[]; + search?: MultiSelectSearchProp; + /** DOM id for the control; pass a unique one when rendering more than one. */ + id?: string; + /** + * 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, + id = "provider-group-selector", + search = { + placeholder: "Search Provider Groups...", + emptyMessage: "No Provider Groups found.", + }, + paramsToDeleteOnChange = [], +}: ProviderGroupSelectorProps) { + const searchParams = useSearchParams(); + const { navigateWithParams } = useUrlFilters(); + const labelId = `${id}-label`; + + 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 349769f3ad..cf25eb9529 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 aa376f360d..69ce0605d2 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 b2a935aec0..4cb9eef394 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 8a5d8f93fc..edeb58a2cb 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 b6e6655247..37080b3f77 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 285bae4c1e..d488a5849c 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 d6b90a6c87..f493c2a385 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 && (