mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
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:
committed by
GitHub
parent
2b7db88694
commit
5b9824c379
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
+21
-11
@@ -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) {
|
||||
|
||||
+11
-4
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<div className="mb-6">
|
||||
<ResourcesFilters
|
||||
providers={providersData?.data || []}
|
||||
providerGroups={providerGroupsData?.data || []}
|
||||
uniqueRegions={uniqueRegions}
|
||||
uniqueServices={uniqueServices}
|
||||
uniqueResourceTypes={uniqueResourceTypes}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -14,12 +14,14 @@ import {
|
||||
FilterSummaryStrip,
|
||||
} from "@/components/filters/filter-summary-strip";
|
||||
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
|
||||
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { ExpandableSection } from "@/components/ui/expandable-section";
|
||||
import { DataTableFilterCustom } from "@/components/ui/table/data-table-filter-custom";
|
||||
import { useFilterBatch } from "@/hooks/use-filter-batch";
|
||||
import { getCategoryLabel, getGroupLabel } from "@/lib/categories";
|
||||
import { FilterType, ScanEntity } from "@/types";
|
||||
import { FILTER_FIELD, ScanEntity } from "@/types";
|
||||
import { ProviderGroup } from "@/types/components";
|
||||
import { DATA_TABLE_FILTER_MODE } from "@/types/filters";
|
||||
import { ProviderProps } from "@/types/providers";
|
||||
|
||||
@@ -31,6 +33,8 @@ import {
|
||||
interface FindingsFiltersProps {
|
||||
/** Provider data for provider/account filter controls. */
|
||||
providers: ProviderProps[];
|
||||
/** Provider groups for the provider group filter control. */
|
||||
providerGroups?: ProviderGroup[];
|
||||
completedScanIds: string[];
|
||||
scanDetails: { [key: string]: ScanEntity }[];
|
||||
uniqueRegions: string[];
|
||||
@@ -70,6 +74,10 @@ const FILTER_GRID_ITEM_CLASS = "min-w-0";
|
||||
|
||||
export const FindingsFilterBatchControls = ({
|
||||
providers,
|
||||
// Undefined = caller opted out (the alert editor shares this component but
|
||||
// loads no groups); an empty array still renders the control, so it stays
|
||||
// visible even when a tenant has no groups yet.
|
||||
providerGroups,
|
||||
completedScanIds,
|
||||
scanDetails,
|
||||
uniqueRegions,
|
||||
@@ -97,7 +105,7 @@ export const FindingsFilterBatchControls = ({
|
||||
|
||||
const customFilters = [
|
||||
...filterFindings
|
||||
.filter((filter) => !isAlertsEdit || filter.key !== FilterType.STATUS)
|
||||
.filter((filter) => !isAlertsEdit || filter.key !== FILTER_FIELD.STATUS)
|
||||
.map((filter) => ({
|
||||
...filter,
|
||||
labelFormatter: (value: string) =>
|
||||
@@ -107,32 +115,32 @@ export const FindingsFilterBatchControls = ({
|
||||
}),
|
||||
})),
|
||||
{
|
||||
key: FilterType.REGION,
|
||||
key: FILTER_FIELD.REGION,
|
||||
labelCheckboxGroup: "Regions",
|
||||
values: uniqueRegions,
|
||||
index: 3,
|
||||
},
|
||||
{
|
||||
key: FilterType.SERVICE,
|
||||
key: FILTER_FIELD.SERVICE,
|
||||
labelCheckboxGroup: "Services",
|
||||
values: uniqueServices,
|
||||
index: 4,
|
||||
},
|
||||
{
|
||||
key: FilterType.RESOURCE_TYPE,
|
||||
key: FILTER_FIELD.RESOURCE_TYPE,
|
||||
labelCheckboxGroup: "Resource Type",
|
||||
values: uniqueResourceTypes,
|
||||
index: 8,
|
||||
},
|
||||
{
|
||||
key: FilterType.CATEGORY,
|
||||
key: FILTER_FIELD.CATEGORY,
|
||||
labelCheckboxGroup: "Category",
|
||||
values: uniqueCategories,
|
||||
labelFormatter: getCategoryLabel,
|
||||
index: 5,
|
||||
},
|
||||
{
|
||||
key: FilterType.RESOURCE_GROUPS,
|
||||
key: FILTER_FIELD.RESOURCE_GROUPS,
|
||||
labelCheckboxGroup: "Resource Group",
|
||||
values: uniqueGroups,
|
||||
labelFormatter: getGroupLabel,
|
||||
@@ -142,14 +150,14 @@ export const FindingsFilterBatchControls = ({
|
||||
? []
|
||||
: [
|
||||
{
|
||||
key: FilterType.SCAN,
|
||||
key: FILTER_FIELD.SCAN,
|
||||
labelCheckboxGroup: "Scan ID",
|
||||
values: completedScanIds,
|
||||
width: "wide" as const,
|
||||
valueLabelMapping: scanDetails,
|
||||
labelFormatter: (value: string) =>
|
||||
getFindingsFilterDisplayValue(
|
||||
`filter[${FilterType.SCAN}]`,
|
||||
`filter[${FILTER_FIELD.SCAN}]`,
|
||||
value,
|
||||
{
|
||||
providers,
|
||||
@@ -167,6 +175,7 @@ export const FindingsFilterBatchControls = ({
|
||||
appliedFilters,
|
||||
{
|
||||
providers,
|
||||
providerGroups,
|
||||
scans: scanDetails,
|
||||
},
|
||||
);
|
||||
@@ -174,6 +183,7 @@ export const FindingsFilterBatchControls = ({
|
||||
changedFilters,
|
||||
{
|
||||
providers,
|
||||
providerGroups,
|
||||
scans: scanDetails,
|
||||
},
|
||||
);
|
||||
@@ -199,15 +209,26 @@ export const FindingsFilterBatchControls = ({
|
||||
: undefined;
|
||||
|
||||
const providerAccountControls = (className: string) => (
|
||||
<ProviderAccountSelectors
|
||||
providers={providers}
|
||||
mode="batch"
|
||||
selectedProviderTypes={getFilterValue("filter[provider_type__in]")}
|
||||
selectedAccounts={getFilterValue("filter[provider_id__in]")}
|
||||
onBatchChange={setPending}
|
||||
providerSelectorClassName={className}
|
||||
accountSelectorClassName={className}
|
||||
/>
|
||||
<>
|
||||
<ProviderAccountSelectors
|
||||
providers={providers}
|
||||
mode="batch"
|
||||
selectedProviderTypes={getFilterValue("filter[provider_type__in]")}
|
||||
selectedAccounts={getFilterValue("filter[provider_id__in]")}
|
||||
onBatchChange={setPending}
|
||||
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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(",") : [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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}]`;
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user