From c5f86da87969e8bcb71e5288f5fdbc5796ffa70a Mon Sep 17 00:00:00 2001 From: "Pablo F.G" Date: Fri, 19 Jun 2026 11:34:19 +0200 Subject: [PATCH 01/18] feat(ui): add provider group action, selector, and filter helpers - Add getAllProviderGroups to fetch every group for filter dropdowns - Add reusable ProviderGroupSelector with batch and instant modes - Add provider group chip/label resolution and the provider_groups filter param Co-Authored-By: Claude Opus 4.8 (1M context) --- .../manage-groups/manage-groups.test.ts | 113 +++++++++ ui/actions/manage-groups/manage-groups.ts | 66 ++++++ .../provider-group-selector.test.tsx | 218 ++++++++++++++++++ .../_components/provider-group-selector.tsx | 173 ++++++++++++++ .../findings/findings-filters.utils.test.ts | 48 ++++ .../findings/findings-filters.utils.ts | 15 ++ .../resources/resources-filters.utils.test.ts | 67 ++++++ .../resources/resources-filters.utils.ts | 18 +- ui/types/filters.ts | 1 + 9 files changed, 718 insertions(+), 1 deletion(-) create mode 100644 ui/actions/manage-groups/manage-groups.test.ts create mode 100644 ui/app/(prowler)/_overview/_components/provider-group-selector.test.tsx create mode 100644 ui/app/(prowler)/_overview/_components/provider-group-selector.tsx create mode 100644 ui/components/resources/resources-filters.utils.test.ts 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..21c404bc070 --- /dev/null +++ b/ui/actions/manage-groups/manage-groups.test.ts @@ -0,0 +1,113 @@ +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(); + }); +}); diff --git a/ui/actions/manage-groups/manage-groups.ts b/ui/actions/manage-groups/manage-groups.ts index 933dabbdbcc..4d0bf2b12f1 100644 --- a/ui/actions/manage-groups/manage-groups.ts +++ b/ui/actions/manage-groups/manage-groups.ts @@ -51,6 +51,72 @@ 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 headers = await getAuthHeaders({ contentType: false }); + 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 { + 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 + | 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 (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/app/(prowler)/_overview/_components/provider-group-selector.test.tsx b/ui/app/(prowler)/_overview/_components/provider-group-selector.test.tsx new file mode 100644 index 00000000000..55247753a1e --- /dev/null +++ b/ui/app/(prowler)/_overview/_components/provider-group-selector.test.tsx @@ -0,0 +1,218 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ProviderGroup } from "@/types/components"; + +import { ProviderGroupSelector } from "./provider-group-selector"; + +const multiSelectContentSpy = vi.fn(); + +const { navigateWithParamsMock } = vi.hoisted(() => ({ + 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("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/app/(prowler)/_overview/_components/provider-group-selector.tsx b/ui/app/(prowler)/_overview/_components/provider-group-selector.tsx new file mode 100644 index 00000000000..afa521f451c --- /dev/null +++ b/ui/app/(prowler)/_overview/_components/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.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..bf16560b99a 100644 --- a/ui/components/findings/findings-filters.utils.ts +++ b/ui/components/findings/findings-filters.utils.ts @@ -2,6 +2,7 @@ import type { FilterChip } from "@/components/filters/filter-summary-strip"; import { formatLabel, getCategoryLabel, getGroupLabel } from "@/lib/categories"; import { getScanEntityLabel } from "@/lib/helper-filters"; import { FINDING_STATUS_DISPLAY_NAMES } from "@/types"; +import { ProviderGroup } from "@/types/components"; import { FilterParam } from "@/types/filters"; import { getProviderDisplayName, ProviderProps } from "@/types/providers"; import { ScanEntity } from "@/types/scans"; @@ -10,6 +11,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 = { @@ -29,6 +31,14 @@ function getProviderAccountDisplayValue( return provider.attributes.alias || provider.attributes.uid || providerId; } +function getProviderGroupDisplayValue( + groupId: string, + groups: ProviderGroup[], +): string { + const group = groups.find((item) => item.id === groupId); + return group?.attributes.name || groupId; +} + function getScanDisplayValue( scanId: string, scans: Array<{ [scanId: string]: ScanEntity }>, @@ -53,6 +63,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 || []); } @@ -101,6 +114,7 @@ export function getFindingsFilterDisplayValue( 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", @@ -121,6 +135,7 @@ export const FILTER_KEY_LABELS: Record = { interface BuildFindingsFilterChipsOptions { providers?: ProviderProps[]; scans?: Array<{ [scanId: string]: ScanEntity }>; + providerGroups?: ProviderGroup[]; includeMuted?: boolean; } diff --git a/ui/components/resources/resources-filters.utils.test.ts b/ui/components/resources/resources-filters.utils.test.ts new file mode 100644 index 00000000000..f7abda54100 --- /dev/null +++ b/ui/components/resources/resources-filters.utils.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import type { ProviderGroup } from "@/types/components"; +import type { ProviderProps } from "@/types/providers"; + +import { + buildResourcesFilterChips, + getResourcesFilterDisplayValue, +} from "./resources-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: "" }, + }, +]; + +const providers: ProviderProps[] = []; + +describe("getResourcesFilterDisplayValue", () => { + it("shows the provider group name for provider_groups filters", () => { + expect( + getResourcesFilterDisplayValue( + "filter[provider_groups__in]", + "group-1", + providers, + providerGroups, + ), + ).toBe("Production"); + }); + + it("keeps the raw value when the provider group cannot be resolved", () => { + expect( + getResourcesFilterDisplayValue( + "filter[provider_groups__in]", + "missing-group", + providers, + providerGroups, + ), + ).toBe("missing-group"); + }); +}); + +describe("buildResourcesFilterChips", () => { + it("labels provider group chips and resolves their names", () => { + const chips = buildResourcesFilterChips( + { "filter[provider_groups__in]": ["group-1"] }, + providers, + providerGroups, + ); + + expect(chips).toEqual([ + { + key: "filter[provider_groups__in]", + label: "Provider Group", + value: "group-1", + displayValue: "Production", + }, + ]); + }); +}); diff --git a/ui/components/resources/resources-filters.utils.ts b/ui/components/resources/resources-filters.utils.ts index cbfdf4441a7..ceda1550ec4 100644 --- a/ui/components/resources/resources-filters.utils.ts +++ b/ui/components/resources/resources-filters.utils.ts @@ -1,11 +1,13 @@ import type { FilterChip } from "@/components/filters/filter-summary-strip"; import { formatLabel, getGroupLabel } from "@/lib/categories"; +import type { ProviderGroup } from "@/types/components"; import type { ProviderProps } from "@/types/providers"; import { getProviderDisplayName } from "@/types/providers"; const RESOURCE_FILTER_KEY_LABELS: Record = { "filter[provider_type__in]": "Provider", "filter[provider_id__in]": "Account", + "filter[provider_groups__in]": "Provider Group", "filter[region__in]": "Region", "filter[service__in]": "Service", "filter[type__in]": "Type", @@ -24,10 +26,19 @@ function getProviderAccountDisplayValue( return provider.attributes.alias || provider.attributes.uid || providerId; } +function getProviderGroupDisplayValue( + groupId: string, + groups: ProviderGroup[], +): string { + const group = groups.find((item) => item.id === groupId); + return group?.attributes.name || groupId; +} + export function getResourcesFilterDisplayValue( filterKey: string, value: string, providers: ProviderProps[], + providerGroups: ProviderGroup[] = [], ): string { if (!value) return value; @@ -39,6 +50,10 @@ export function getResourcesFilterDisplayValue( return getProviderAccountDisplayValue(value, providers); } + if (filterKey === "filter[provider_groups__in]") { + return getProviderGroupDisplayValue(value, providerGroups); + } + if (filterKey === "filter[groups__in]") { return getGroupLabel(value); } @@ -53,6 +68,7 @@ export function getResourcesFilterDisplayValue( export function buildResourcesFilterChips( pendingFilters: Record, providers: ProviderProps[], + providerGroups: ProviderGroup[] = [], ): FilterChip[] { const chips: FilterChip[] = []; @@ -61,7 +77,7 @@ export function buildResourcesFilterChips( const label = RESOURCE_FILTER_KEY_LABELS[key] ?? key; const displayValues = values.map((value) => - getResourcesFilterDisplayValue(key, value, providers), + getResourcesFilterDisplayValue(key, value, providers, providerGroups), ); const chip: FilterChip = { diff --git a/ui/types/filters.ts b/ui/types/filters.ts index 96303187121..10339451012 100644 --- a/ui/types/filters.ts +++ b/ui/types/filters.ts @@ -67,6 +67,7 @@ export type DataTableFilterMode = export type FilterParam = | "filter[provider_type__in]" | "filter[provider_id__in]" + | "filter[provider_groups__in]" | "filter[severity__in]" | "filter[status__in]" | "filter[delta__in]" From fa904f84eef9c1353192dfd4bdc0b06a5b32c72e Mon Sep 17 00:00:00 2001 From: "Pablo F.G" Date: Fri, 19 Jun 2026 11:36:29 +0200 Subject: [PATCH 02/18] feat(ui): filter findings by provider group - Add the provider group selector to the Findings filters Co-Authored-By: Claude Opus 4.8 (1M context) --- ui/app/(prowler)/findings/page.tsx | 5 ++- ui/components/findings/findings-filters.tsx | 39 ++++++++++++++++----- 2 files changed, 34 insertions(+), 10 deletions(-) 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({
( - + <> + + {providerGroups !== undefined && ( +
+ +
+ )} + ); const alertEditFilterGrid = hasCustomFilters ? ( From 11bab48003234b5c3b1f94ac105855e29e0e5ea3 Mon Sep 17 00:00:00 2001 From: "Pablo F.G" Date: Fri, 19 Jun 2026 11:36:46 +0200 Subject: [PATCH 03/18] feat(ui): filter resources by provider group - Add the provider group selector to the Resources filters Co-Authored-By: Claude Opus 4.8 (1M context) --- ui/app/(prowler)/resources/page.tsx | 32 +++++++++++-------- ui/components/resources/resources-filters.tsx | 13 ++++++++ 2 files changed, 32 insertions(+), 13 deletions(-) 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({
0; @@ -178,6 +184,13 @@ export const ResourcesFilters = ({ providerSelectorClassName={FILTER_CONTROL_COLUMN_CLASS} accountSelectorClassName={FILTER_CONTROL_COLUMN_CLASS} /> +
+ +
{hasCustomFilters && (