feat(ui): filter by provider group across main views (#11659)

Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pablo Fernandez Guerra (PFE)
2026-06-25 15:32:00 +02:00
committed by GitHub
parent 2b7db88694
commit 5b9824c379
46 changed files with 1475 additions and 177 deletions
+8
View File
@@ -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
+3 -3
View File
@@ -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,
),
),
);
+40
View File
@@ -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
>;
@@ -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<typeof makeGroup>[],
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);
});
});
+81
View File
@@ -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}`);
+16
View File
@@ -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<string, OverviewFilterParam>;
+18
View File
@@ -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<string, ProvidersFilterParam>;
+25
View File
@@ -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
>;
+34
View File
@@ -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
>;
@@ -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(
<AccountsSelector
providers={providers}
filterKey={FilterType.PROVIDER_UID}
filterKey={FILTER_FIELD.PROVIDER_UID}
/>,
);
@@ -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);
@@ -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"]);
});
});
@@ -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;
});
};
@@ -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<string, string[]> = {};
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) => {
@@ -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) {
@@ -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<string, string> => {
const filters: Record<string, string> = {};
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;
};
+4 -1
View File
@@ -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({
<div className="mb-6">
<FindingsFilters
providers={providersData?.data || []}
providerGroups={providerGroupsData?.data || []}
completedScanIds={completedScanIds}
scanDetails={scanDetails}
uniqueRegions={uniqueRegions}
+7 -1
View File
@@ -1,7 +1,9 @@
import { Suspense } from "react";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
import { getAllProviders } from "@/actions/providers";
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
import { ContentLayout } from "@/components/ui";
import { SearchParamsProps } from "@/types";
@@ -38,12 +40,16 @@ export default async function Home({
searchParams: Promise<SearchParamsProps>;
}) {
const resolvedSearchParams = await searchParams;
const providersData = await getAllProviders();
const [providersData, providerGroupsData] = await Promise.all([
getAllProviders(),
getAllProviderGroups(),
]);
return (
<ContentLayout title="Overview" icon="lucide:square-chart-gantt">
<div className="xxl:grid-cols-4 mb-6 grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<ProviderAccountSelectors providers={providersData?.data ?? []} />
<ProviderGroupSelector groups={providerGroupsData?.data ?? []} />
</div>
<div className="flex flex-col gap-6 xl:flex-row xl:flex-wrap xl:items-stretch">
+1
View File
@@ -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}
/>
@@ -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";
@@ -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,
};
}
+10 -4
View File
@@ -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(
[
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({
<div className="mb-6">
<ResourcesFilters
providers={providersData?.data || []}
providerGroups={providerGroupsData?.data || []}
uniqueRegions={uniqueRegions}
uniqueServices={uniqueServices}
uniqueResourceTypes={uniqueResourceTypes}
+33 -24
View File
@@ -1,8 +1,13 @@
import { redirect } from "next/navigation";
import { Suspense } from "react";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
import { getAllProviders } from "@/actions/providers";
import { getScans } from "@/actions/scans";
import {
SCANS_PROVIDER_FILTER_FIELD,
type ScansFilterParam,
} from "@/actions/scans/scans-filters";
import { getSchedules, getSchedulesPage } from "@/actions/schedules";
import { auth } from "@/auth.config";
import { PageReady } from "@/components/onboarding";
@@ -28,7 +33,6 @@ import {
} from "@/lib/schedules";
import { isCloud } from "@/lib/shared/env";
import {
FilterType,
ProviderProps,
SCAN_JOBS_TAB,
SCAN_TRIGGER,
@@ -41,29 +45,22 @@ import {
} from "@/types/schedules";
const ACTIVE_SCAN_COUNT_PAGE_SIZE = 1;
// Pending schedule rows must honor the same provider filters as real scan rows.
// The `__in` keys reuse the shared FilterType; the singular variants have no
// FilterType equivalent, so they stay as literals.
const PENDING_ROW_PROVIDER_FILTER = {
PROVIDER_IN: FilterType.PROVIDER,
PROVIDER: "provider",
PROVIDER_TYPE_IN: FilterType.PROVIDER_TYPE,
PROVIDER_TYPE: "provider_type",
} as const;
type PendingRowProviderFilter =
(typeof PENDING_ROW_PROVIDER_FILTER)[keyof typeof PENDING_ROW_PROVIDER_FILTER];
type PendingRowProviderFilterParam = `filter[${PendingRowProviderFilter}]`;
// Pending schedule rows are derived from provider schedules, but must honor the
// same provider filters as real scan rows. The filter keys live with the scans
// action (SCANS_PROVIDER_FILTER_FIELD) so they stay in sync with ScansFilterParam.
const PROVIDER_ID_FILTER_KEYS = [
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_IN}]`,
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER}]`,
] as const satisfies ReadonlyArray<PendingRowProviderFilterParam>;
`filter[${SCANS_PROVIDER_FILTER_FIELD.PROVIDER_IN}]`,
`filter[${SCANS_PROVIDER_FILTER_FIELD.PROVIDER}]`,
] as const satisfies ReadonlyArray<ScansFilterParam>;
const PROVIDER_TYPE_FILTER_KEYS = [
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_TYPE_IN}]`,
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_TYPE}]`,
] as const satisfies ReadonlyArray<PendingRowProviderFilterParam>;
`filter[${SCANS_PROVIDER_FILTER_FIELD.PROVIDER_TYPE_IN}]`,
`filter[${SCANS_PROVIDER_FILTER_FIELD.PROVIDER_TYPE}]`,
] as const satisfies ReadonlyArray<ScansFilterParam>;
const PROVIDER_GROUP_FILTER_KEYS = [
`filter[${SCANS_PROVIDER_FILTER_FIELD.PROVIDER_GROUPS_IN}]`,
] as const satisfies ReadonlyArray<ScansFilterParam>;
const getFilterSearchQuery = (
filters: Record<string, string | string[]>,
@@ -86,7 +83,7 @@ const parseCsvParam = (value?: string | string[]): string[] => {
const getFirstSearchParam = (
searchParams: SearchParamsProps,
keys: ReadonlyArray<PendingRowProviderFilterParam>,
keys: ReadonlyArray<ScansFilterParam>,
): 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({
) : (
<ScansPageShell
providers={providers}
providerGroups={providerGroups}
hasManageScansPermission={hasManageScansPermission}
activeScanCount={activeScanCount}
>
+4 -4
View File
@@ -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,
@@ -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(
<ProviderAccountSelectors
providers={providers}
accountFilterKey={FilterType.PROVIDER_UID}
accountFilterKey={FILTER_FIELD.PROVIDER_UID}
accountValue="uid"
paramsToDeleteOnChange={["page", "scanId"]}
/>,
@@ -230,7 +230,7 @@ describe("ProviderAccountSelectors", () => {
<ProviderAccountSelectors
providers={providers}
mode="batch"
accountFilterKey={FilterType.PROVIDER_UID}
accountFilterKey={FILTER_FIELD.PROVIDER_UID}
accountValue="uid"
selectedProviderTypes={["aws"]}
selectedAccounts={["123456789012", "prowler-project"]}
@@ -5,7 +5,7 @@ import { useSearchParams } from "next/navigation";
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector";
import { useUrlFilters } from "@/hooks/use-url-filters";
import { type AccountFilterKey, FilterType } from "@/types/filters";
import { type AccountFilterKey, FILTER_FIELD } from "@/types/filters";
import type { ProviderProps } from "@/types/providers";
const ACCOUNT_VALUE = {
@@ -91,7 +91,7 @@ const getCompatibleAccounts = ({
export function ProviderAccountSelectors({
providers,
accountFilterKey = FilterType.PROVIDER_ID,
accountFilterKey = FILTER_FIELD.PROVIDER_ID,
accountValue = ACCOUNT_VALUE.ID,
providerSelectorClassName,
accountSelectorClassName,
@@ -0,0 +1,236 @@
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;
}) => (
<div>
<button
data-testid="mock-select-group-2"
onClick={() => onValuesChange(["group-2"])}
/>
{children}
</div>
),
MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
<div data-testid="trigger">{children}</div>
),
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
<span>{placeholder}</span>
),
MultiSelectContent: ({
children,
search,
}: {
children: React.ReactNode;
search?: unknown;
}) => {
multiSelectContentSpy(search);
return <div>{children}</div>;
},
MultiSelectItem: ({
children,
value,
keywords,
}: {
children: React.ReactNode;
value: string;
keywords?: string[];
}) => (
<div data-value={value} data-keywords={keywords?.join("|")}>
{children}
</div>
),
}));
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(<ProviderGroupSelector groups={[]} />);
// 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(<ProviderGroupSelector groups={groups} />);
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(<ProviderGroupSelector groups={groups} search={false} />);
expect(multiSelectContentSpy).toHaveBeenLastCalledWith(false);
});
it("passes the group name as a search keyword", () => {
render(<ProviderGroupSelector groups={groups} />);
expect(
screen.getByText("Production").closest("[data-value]"),
).toHaveAttribute("data-keywords", expect.stringContaining("Production"));
});
it("disables select all when nothing is selected", () => {
render(<ProviderGroupSelector groups={groups} />);
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(
<ProviderGroupSelector
groups={groups}
onBatchChange={vi.fn()}
selectedValues={["group-1", "group-2"]}
/>,
);
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(
<ProviderGroupSelector
groups={groups}
onBatchChange={vi.fn()}
selectedValues={["group-1"]}
/>,
);
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(<ProviderGroupSelector groups={groups} />);
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(
<ProviderGroupSelector
groups={groups}
paramsToDeleteOnChange={["page", "scanId"]}
/>,
);
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(<ProviderGroupSelector groups={groups} />);
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(
<ProviderGroupSelector groups={groups} id="resources-provider-group" />,
);
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(<ProviderGroupSelector groups={groups} />);
fireEvent.click(
screen.getByRole("option", { name: /select all Provider Groups/i }),
);
expect(navigateWithParamsMock).not.toHaveBeenCalled();
});
});
@@ -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 (
<span className="truncate">
{group ? group.attributes.name : selectedIds[0]}
</span>
);
}
return (
<span className="truncate">
{selectedIds.length} Provider Groups selected
</span>
);
};
return (
<div className="relative">
<label htmlFor={id} className="sr-only" id={labelId}>
Filter by Provider Group. Select one or more Provider Groups to filter
results.
</label>
<MultiSelect values={selectedIds} onValuesChange={handleMultiValueChange}>
<MultiSelectTrigger id={id} aria-labelledby={labelId}>
{selectedLabel() || (
<MultiSelectValue placeholder="All Provider Groups" />
)}
</MultiSelectTrigger>
<MultiSelectContent search={search}>
{/* No items when empty: the MultiSelect's own emptyMessage is the
single empty state (avoids a duplicate "none" message). */}
{groups.length > 0 && (
<>
<div
role="option"
aria-selected={selectedIds.length === 0}
aria-disabled={selectedIds.length === 0}
aria-label="Select all Provider Groups (clears current selection to show all)"
tabIndex={0}
className="text-text-neutral-secondary flex w-full cursor-pointer items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold hover:bg-slate-200 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 dark:hover:bg-slate-700/50"
onClick={() => {
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"}
</div>
{groups.map((group) => (
<MultiSelectItem
key={group.id}
value={group.id}
badgeLabel={group.attributes.name}
keywords={[group.attributes.name]}
aria-label={`${group.attributes.name} Provider Group`}
>
<span className="truncate">{group.attributes.name}</span>
</MultiSelectItem>
))}
</>
)}
</MultiSelectContent>
</MultiSelect>
</div>
);
}
+30 -9
View File
@@ -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,6 +209,7 @@ export const FindingsFilterBatchControls = ({
: undefined;
const providerAccountControls = (className: string) => (
<>
<ProviderAccountSelectors
providers={providers}
mode="batch"
@@ -208,6 +219,16 @@ export const FindingsFilterBatchControls = ({
providerSelectorClassName={className}
accountSelectorClassName={className}
/>
{providerGroups !== undefined && (
<div className={className}>
<ProviderGroupSelector
groups={providerGroups}
selectedValues={getFilterValue("filter[provider_groups__in]")}
onBatchChange={setPending}
/>
</div>
)}
</>
);
const alertEditFilterGrid = hasCustomFilters ? (
@@ -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<ProviderProps> & { 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({
@@ -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<string, string> = {
@@ -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<FilterParam, string> so TypeScript enforces exhaustiveness — any
* addition to FilterParam will cause a compile error here if the label is missing.
* Typed as Record<FindingsFilterParam, string> 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<FilterParam, string> = {
export const FILTER_KEY_LABELS: Record<FindingsFilterParam, string> = {
"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<FilterParam, string> = {
"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 = {
@@ -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({
<ProvidersFilters
filters={filters}
providers={providers}
providerGroups={providerGroups}
actions={
<>
<MutedFindingsConfigButton />
@@ -16,6 +16,10 @@ vi.mock("@/app/(prowler)/_overview/_components/provider-type-selector", () => ({
ProviderTypeSelector: () => <div>Provider type selector</div>,
}));
vi.mock("@/components/filters/provider-group-selector", () => ({
ProviderGroupSelector: () => <div>Provider group selector</div>,
}));
vi.mock("@/components/filters/clear-filters-button", () => ({
ClearFiltersButton: () => <button type="button">Clear</button>,
}));
@@ -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 = ({
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
<ProviderTypeSelector providers={providers} />
</div>
<div className="max-w-[240px] min-w-[180px] flex-1">
<ProviderGroupSelector groups={providerGroups} />
</div>
{sortedFilters.map((filter) => {
const selectedValues = getSelectedValues(filter);
return (
@@ -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}
/>
<div className={FILTER_CONTROL_COLUMN_CLASS}>
<ProviderGroupSelector
groups={providerGroups}
selectedValues={getFilterValue("filter[provider_groups__in]")}
onBatchChange={setPending}
/>
</div>
{hasCustomFilters && (
<Button
variant="outline"
@@ -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",
},
]);
});
});
@@ -1,11 +1,15 @@
import type { ResourcesFilterParam } from "@/actions/resources/resources-filters";
import type { FilterChip } from "@/components/filters/filter-summary-strip";
import { formatLabel, getGroupLabel } from "@/lib/categories";
import { getProviderGroupDisplayValue } from "@/lib/helper-filters";
import type { ProviderGroup } from "@/types/components";
import type { ProviderProps } from "@/types/providers";
import { getProviderDisplayName } from "@/types/providers";
const RESOURCE_FILTER_KEY_LABELS: Record<string, string> = {
const RESOURCE_FILTER_KEY_LABELS: Record<ResourcesFilterParam, string> = {
"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",
@@ -28,6 +32,7 @@ export function getResourcesFilterDisplayValue(
filterKey: string,
value: string,
providers: ProviderProps[],
providerGroups: ProviderGroup[] = [],
): string {
if (!value) return value;
@@ -39,6 +44,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,15 +62,17 @@ export function getResourcesFilterDisplayValue(
export function buildResourcesFilterChips(
pendingFilters: Record<string, string[]>,
providers: ProviderProps[],
providerGroups: ProviderGroup[] = [],
): FilterChip[] {
const chips: FilterChip[] = [];
Object.entries(pendingFilters).forEach(([key, values]) => {
if (!values || values.length === 0) return;
const label = RESOURCE_FILTER_KEY_LABELS[key] ?? key;
const label =
RESOURCE_FILTER_KEY_LABELS[key as ResourcesFilterParam] ?? key;
const displayValues = values.map((value) =>
getResourcesFilterDisplayValue(key, value, providers),
getResourcesFilterDisplayValue(key, value, providers, providerGroups),
);
const chip: FilterChip = {
@@ -9,6 +9,10 @@ vi.mock("@/components/filters/provider-account-selectors", () => ({
ProviderAccountSelectors: () => <div>Provider account selectors</div>,
}));
vi.mock("@/components/filters/provider-group-selector", () => ({
ProviderGroupSelector: () => <div>Provider group selector</div>,
}));
vi.mock("@/components/shadcn", () => ({
Select: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
+13 -2
View File
@@ -1,6 +1,7 @@
"use client";
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
import {
Select,
SelectContent,
@@ -9,7 +10,8 @@ import {
SelectValue,
} from "@/components/shadcn";
import { SCAN_JOBS_TAB, type ScanJobsTab } from "@/types";
import { FilterType } from "@/types/filters";
import type { ProviderGroup } from "@/types/components";
import { FILTER_FIELD } from "@/types/filters";
import type { ProviderProps } from "@/types/providers";
import {
@@ -19,6 +21,7 @@ import {
interface ScansFilterBarProps {
providers: ProviderProps[];
providerGroups?: ProviderGroup[];
activeTab: ScanJobsTab;
scheduleType: string;
scanStatus: string;
@@ -31,6 +34,7 @@ const filterItemClass = "w-full md:w-[calc(50%-0.375rem)] xl:w-60";
export function ScansFilterBar({
providers,
providerGroups = [],
activeTab,
scheduleType,
scanStatus,
@@ -47,13 +51,20 @@ export function ScansFilterBar({
<>
<ProviderAccountSelectors
providers={providers}
accountFilterKey={FilterType.PROVIDER}
accountFilterKey={FILTER_FIELD.PROVIDER}
accountValue="id"
paramsToDeleteOnChange={["page", "scanId"]}
providerSelectorClassName={filterItemClass}
accountSelectorClassName={filterItemClass}
/>
<div className={filterItemClass}>
<ProviderGroupSelector
groups={providerGroups}
paramsToDeleteOnChange={["page", "scanId"]}
/>
</div>
{showScheduleTypeFilter && (
<Select value={scheduleType} onValueChange={onScheduleTypeChange}>
<SelectTrigger aria-label="All Types" className={filterItemClass}>
+4
View File
@@ -20,6 +20,7 @@ import {
import { buildViewFirstScanTour } from "@/lib/tours/view-first-scan.tour";
import { useScansStore } from "@/store";
import { SCAN_JOBS_TAB, SCAN_TAB_LABELS, type ScanJobsTab } from "@/types";
import type { ProviderGroup } from "@/types/components";
import type { ProviderProps } from "@/types/providers";
import type { ScanScheduleCapability } from "@/types/schedules";
@@ -32,6 +33,7 @@ import { useScansFilters } from "./use-scans-filters";
interface ScansPageShellProps {
providers: ProviderProps[];
providerGroups?: ProviderGroup[];
hasManageScansPermission: boolean;
activeScanCount?: number;
children: ReactNode;
@@ -42,6 +44,7 @@ interface ScansPageShellProps {
export function ScansPageShell({
providers,
providerGroups = [],
hasManageScansPermission,
activeScanCount = 0,
children,
@@ -116,6 +119,7 @@ export function ScansPageShell({
>
<ScansFilterBar
providers={providers}
providerGroups={providerGroups}
activeTab={filters.activeTab}
scheduleType={filters.scheduleType}
scanStatus={filters.scanStatus}
+4 -4
View File
@@ -62,22 +62,22 @@ describe("useFilterBatch", () => {
expect(result.current.hasChanges).toBe(false);
});
it("should expose filter[delta]=new under the FilterType.DELTA key so the dropdown shows it selected", async () => {
it("should expose filter[delta]=new under the FILTER_FIELD.DELTA key so the dropdown shows it selected", async () => {
// Given — URL from LinkToFindings uses `filter[delta]` (singular), matching the API.
setSearchParams({
"filter[status__in]": "FAIL",
"filter[delta]": "new",
});
const { FilterType } = await import("@/types/filters");
const { FILTER_FIELD } = await import("@/types/filters");
// When
const { result } = renderHook(() => useFilterBatch());
// Then — the Delta dropdown reads via getFilterValue(`filter[${FilterType.DELTA}]`).
// Then — the Delta dropdown reads via getFilterValue(`filter[${FILTER_FIELD.DELTA}]`).
// For the checkbox of "new" to appear checked, that lookup must return ["new"].
expect(
result.current.getFilterValue(`filter[${FilterType.DELTA}]`),
result.current.getFilterValue(`filter[${FILTER_FIELD.DELTA}]`),
).toEqual(["new"]);
});
+10 -5
View File
@@ -2,8 +2,9 @@ import { useSearchParams } from "next/navigation";
import { isScanEntity } from "@/lib/helper-filters";
import {
FILTER_FIELD,
FilterEntity,
FilterType,
FilterParam,
ProviderEntity,
ProviderType,
ScanEntity,
@@ -16,7 +17,9 @@ interface UseRelatedFiltersProps {
completedScanIds?: string[];
scanDetails?: { [key: string]: ScanEntity }[];
enableScanRelation?: boolean;
providerFilterType?: FilterType.PROVIDER | FilterType.PROVIDER_UID;
providerFilterType?:
| typeof FILTER_FIELD.PROVIDER
| typeof FILTER_FIELD.PROVIDER_UID;
}
/**
@@ -38,15 +41,17 @@ export const useRelatedFilters = ({
completedScanIds = [],
scanDetails = [],
enableScanRelation = false,
providerFilterType = FilterType.PROVIDER,
providerFilterType = FILTER_FIELD.PROVIDER,
}: UseRelatedFiltersProps) => {
const searchParams = useSearchParams();
const providers = providerIds.length > 0 ? providerIds : providerUIDs;
const providerParam = searchParams.get(`filter[${providerFilterType}]`);
const providerParam = searchParams.get(
`filter[${providerFilterType}]` satisfies FilterParam,
);
const providerTypeParam = searchParams.get(
`filter[${FilterType.PROVIDER_TYPE}]`,
`filter[${FILTER_FIELD.PROVIDER_TYPE}]` satisfies FilterParam,
);
const currentProviders = providerParam ? providerParam.split(",") : [];
+30
View File
@@ -1,14 +1,23 @@
import { describe, expect, it } from "vitest";
import type { ProviderGroup } from "@/types/components";
import type { ScanEntity } from "@/types/scans";
import {
getProviderGroupDisplayValue,
getScanEntityLabel,
hasDateFilter,
hasDateOrScanFilter,
hasHistoricalFindingFilter,
} from "./helper-filters";
const makeProviderGroup = (id: string, name: string): ProviderGroup =>
({
type: "provider-groups",
id,
attributes: { name, inserted_at: "", updated_at: "" },
}) as ProviderGroup;
function makeScan(overrides: Partial<ScanEntity> = {}): ScanEntity {
return {
id: "scan-1",
@@ -25,6 +34,27 @@ function makeScan(overrides: Partial<ScanEntity> = {}): ScanEntity {
};
}
describe("getProviderGroupDisplayValue", () => {
const groups = [
makeProviderGroup("g1", "Production"),
makeProviderGroup("g2", "Staging"),
];
it("resolves the group name when the id matches", () => {
expect(getProviderGroupDisplayValue("g1", groups)).toBe("Production");
});
it("falls back to the raw id when the group is not found", () => {
expect(getProviderGroupDisplayValue("unknown", groups)).toBe("unknown");
});
it("falls back to the raw id when the group name is empty", () => {
expect(
getProviderGroupDisplayValue("g3", [makeProviderGroup("g3", "")]),
).toBe("g3");
});
});
describe("hasDateOrScanFilter", () => {
it("returns true for scan filters", () => {
expect(hasDateOrScanFilter({ "filter[scan__in]": "scan-1" })).toBe(true);
+14
View File
@@ -1,4 +1,5 @@
import { ProviderProps, ProvidersApiResponse, ScanProps } from "@/types";
import { ProviderGroup } from "@/types/components";
import { FilterEntity } from "@/types/filters";
import {
getProviderDisplayName,
@@ -119,6 +120,19 @@ export function getScanEntityLabel(scan: ScanEntity): string {
return providerLabel || scanName;
}
/**
* Resolves the display name for a provider group filter value, falling back to
* the raw id when the group can't be resolved. Shared by the findings and
* resources filter utils so their chips stay in sync.
*/
export function getProviderGroupDisplayValue(
groupId: string,
groups: ProviderGroup[],
): string {
const group = groups.find((item) => item.id === groupId);
return group?.attributes.name || groupId;
}
/**
* Creates a scan details mapping for filters from completed scans.
* Used to provide detailed information for scan filters in the UI.
+36 -40
View File
@@ -29,32 +29,44 @@ export interface CustomDropdownFilterProps {
onFilterChange: (key: string, values: string[]) => void;
}
export enum FilterType {
SCAN = "scan__in",
PROVIDER = "provider__in",
PROVIDER_ID = "provider_id__in",
PROVIDER_UID = "provider_uid__in",
PROVIDER_TYPE = "provider_type__in",
REGION = "region__in",
SERVICE = "service__in",
RESOURCE_TYPE = "resource_type__in",
SEVERITY = "severity__in",
STATUS = "status__in",
/**
* Filter field names — the inner part of a `filter[...]` URL param key, and the
* `key` values used to build `FilterOption` dropdown configs. Single source of
* truth for the `FilterParam` template; per-view modules compose their own field
* set from these plus their own extras.
*/
export const FILTER_FIELD = {
// core — provider scope + shared resource dimensions (used across views)
PROVIDER_TYPE: "provider_type__in",
PROVIDER_ID: "provider_id__in",
PROVIDER_UID: "provider_uid__in",
PROVIDER_GROUPS: "provider_groups__in",
REGION: "region__in",
SERVICE: "service__in",
// view dimensions — dropdown configs (mostly findings; `provider__in` is the
// providers-list type filter)
PROVIDER: "provider__in",
SCAN: "scan__in",
RESOURCE_TYPE: "resource_type__in",
SEVERITY: "severity__in",
STATUS: "status__in",
// The API only registers `delta` (exact, singular). `delta__in` is silently
// dropped, so the dropdown, URL, and backend must all use `delta`.
DELTA = "delta",
CATEGORY = "category__in",
RESOURCE_GROUPS = "resource_groups__in",
}
DELTA: "delta",
CATEGORY: "category__in",
RESOURCE_GROUPS: "resource_groups__in",
} as const;
export type FilterField = (typeof FILTER_FIELD)[keyof typeof FILTER_FIELD];
/**
* Filter keys the account selectors accept: a provider id (`provider__in` /
* `provider_id__in`) or the cloud account uid (`provider_uid__in`).
*/
export type AccountFilterKey =
| FilterType.PROVIDER
| FilterType.PROVIDER_ID
| FilterType.PROVIDER_UID;
export type AccountFilterKey = (typeof FILTER_FIELD)[
| "PROVIDER"
| "PROVIDER_ID"
| "PROVIDER_UID"];
/**
* Controls the filter dispatch behavior of DataTableFilterCustom.
@@ -70,25 +82,9 @@ export type DataTableFilterMode =
(typeof DATA_TABLE_FILTER_MODE)[keyof typeof DATA_TABLE_FILTER_MODE];
/**
* Exhaustive union of all URL filter param keys used in Findings filters.
* Use this instead of `string` to ensure FILTER_KEY_LABELS and other
* param-keyed records stay in sync with the actual filter surface.
* URL filter param key template — wraps a field name in `filter[...]`.
* Parameterize with a view's own field union (e.g. `FilterParam<FindingsFilterField>`)
* so each view's param-keyed records stay in sync with the filters it supports.
*/
export type FilterParam =
| "filter[provider_type__in]"
| "filter[provider_id__in]"
| "filter[severity__in]"
| "filter[status__in]"
| "filter[delta__in]"
| "filter[delta]"
| "filter[region__in]"
| "filter[service__in]"
| "filter[resource_type__in]"
| "filter[category__in]"
| "filter[resource_groups__in]"
| "filter[scan]"
| "filter[scan__in]"
| "filter[scan_id]"
| "filter[scan_id__in]"
| "filter[inserted_at]"
| "filter[muted]";
export type FilterParam<Field extends string = FilterField> =
`filter[${Field}]`;
+2 -1
View File
@@ -1,4 +1,4 @@
import { MetaDataProps } from "./components";
import { MetaDataProps, ProviderGroup } from "./components";
import { FilterOption } from "./filters";
import {
OrganizationResource,
@@ -86,6 +86,7 @@ export interface ProvidersAccountsViewData {
filters: FilterOption[];
metadata?: MetaDataProps;
providers: ProviderProps[];
providerGroups: ProviderGroup[];
rows: ProvidersTableRow[];
}