Compare commits

...
Author SHA1 Message Date
Pablo F.G 542a6e458b fix(ui): scope aggregated compliance findings to the latest scan
Aggregated mode sent provider filters with no scan/date to /findings, which
the API rejects with 400, so requirement findings always rendered empty. Use
/findings/latest in aggregated mode (needs neither; scopes to the latest scan
per matching provider) and keep /findings for single-scan mode.

Also tidies the provider-filter helpers (review nits):
- Derive the filter keys/type from a FILTER_FIELD subset const
- Accept ReadonlyURLSearchParams so call sites drop the search-params wrap

Adds a regression test covering both aggregated and single-scan modes.
2026-06-23 15:00:33 +02:00
Pablo F.G 0a2abbe4a9 docs(ui): changelog for compliance provider filter 2026-06-23 11:53:24 +02:00
Pablo F.G 1a60dc478a feat(ui): sync ts types 2026-06-23 11:43:08 +02:00
Pablo F.GandClaude Opus 4.8 c3a09cdb14 feat(ui): aggregate the compliance detail page by provider
- Detail page resolves requirements/metadata across provider filters when
  present; per-scan affordances (PDF, scan header, ThreatScore) degrade
- Requirement findings scope by provider filters instead of a single scan
  in aggregated mode

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 10:01:24 +02:00
Pablo F.GandClaude Opus 4.8 fd0c875065 feat(ui): add provider filtering to the compliance overview
- Mount provider type/account and provider group selectors in the
  compliance filter bar; selecting a scan or provider filter clears the
  other (XOR)
- Aggregate frameworks across the latest scan per matching provider;
  hide the ThreatScore badge while provider filters are active
- Hide the per-scan CIS PDF and carry provider filters into the
  card drill-down in aggregated mode

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 10:01:24 +02:00
Pablo F.GandClaude Opus 4.8 9b4d5b01ed feat(ui): carry provider filters into the compliance detail URL
- buildComplianceDetailPath carries provider filters instead of scanId
  in aggregated mode, preserving the scan vs provider XOR on drill-down

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 10:01:24 +02:00
Pablo F.GandClaude Opus 4.8 eab9eebacf feat(ui): forward provider filters in compliance server actions
- getCompliancesOverview and getComplianceRequirements forward provider
  filters and drop scan_id when present (backend XOR)
- getComplianceRequirements now accepts an optional scanId

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 10:01:24 +02:00
Pablo F.GandClaude Opus 4.8 7fd281cf02 feat(ui): add compliance provider-filter mode helper
- Detect and extract the provider-scope filter keys from search params
- Single source of truth for the scan vs provider-filter XOR

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 10:01:24 +02:00
Pablo F.G d90e4449f9 feat(ui): address alan feedback 2026-06-23 09:24:31 +02:00
Pablo F.G f08d7c9f15 feat(ui): address coderabbit issues 2026-06-22 09:58:40 +02:00
Pablo F.GandClaude Opus 4.8 170507a35b docs(ui): changelog for provider group filter
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 09:04:09 +02:00
Pablo F.GandClaude Opus 4.8 9d6ea860b9 refactor(ui): split FilterParam into core fields + per-view types
- Add FILTER_FIELD field-name source and a generic FilterParam<Field> template
- Replace the FilterType enum with the same const dictionary
- Add per-view filter param types co-located with each view's action
- Tighten findings/resources filter maps and the findings display fn to their params

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 08:51:56 +02:00
Pablo F.GandClaude Opus 4.8 5440733d50 feat(ui): filter providers list by provider group
- Add the provider group selector to the Providers page filters
- Mock getAllProviderGroups in the providers page data tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 08:51:55 +02:00
Pablo F.GandClaude Opus 4.8 4649032ec9 feat(ui): filter scans by provider group
- Add the provider group selector to the Scan Jobs filter bar

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 08:51:55 +02:00
Pablo F.GandClaude Opus 4.8 1bc9fc2782 feat(ui): filter overview by provider group
- Add the provider group selector to the Overview filters
- Scope risk plot, risk pipeline, and severity-over-time to the selected group

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 08:51:55 +02:00
Pablo F.GandClaude Opus 4.8 11bab48003 feat(ui): filter resources by provider group
- Add the provider group selector to the Resources filters

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 08:51:55 +02:00
Pablo F.GandClaude Opus 4.8 fa904f84ee feat(ui): filter findings by provider group
- Add the provider group selector to the Findings filters

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 08:51:55 +02:00
Pablo F.GandClaude Opus 4.8 c5f86da879 feat(ui): add provider group action, selector, and filter helpers
- Add getAllProviderGroups to fetch every group for filter dropdowns
- Add reusable ProviderGroupSelector with batch and instant modes
- Add provider group chip/label resolution and the provider_groups filter param

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 08:51:55 +02:00
58 changed files with 2249 additions and 234 deletions
+2
View File
@@ -7,6 +7,8 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🚀 Added
- Controlled `402` and `403` Server Action error messages for alert seed and mutation flows [(#11629)](https://github.com/prowler-cloud/prowler/pull/11629)
- Filter the Overview, Findings, Resources, Scans, and Providers views by provider group [(#11659)](https://github.com/prowler-cloud/prowler/pull/11659)
- Filter the Compliance overview and detail pages by provider [(#11668)](https://github.com/prowler-cloud/prowler/pull/11668)
### 🐞 Fixed
+100
View File
@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted(
() => ({
fetchMock: vi.fn(),
getAuthHeadersMock: vi.fn(),
handleApiResponseMock: vi.fn(),
}),
);
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: getAuthHeadersMock,
}));
vi.mock("@/lib/server-actions-helper", () => ({
handleApiResponse: handleApiResponseMock,
}));
import {
getComplianceOverviewMetadataInfo,
getComplianceRequirements,
getCompliancesOverview,
} from "./compliances";
const calledUrl = () => new URL(fetchMock.mock.calls[0][0] as string);
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
fetchMock.mockResolvedValue(new Response(null, { status: 200 }));
handleApiResponseMock.mockResolvedValue({ data: [] });
});
describe("getCompliancesOverview", () => {
it("sends scan_id and region in scan mode", async () => {
await getCompliancesOverview({ scanId: "scan-1", region: "eu-west-1" });
const url = calledUrl();
expect(url.searchParams.get("filter[scan_id]")).toBe("scan-1");
expect(url.searchParams.get("filter[region__in]")).toBe("eu-west-1");
});
it("forwards provider filters and omits scan_id in aggregated mode", async () => {
await getCompliancesOverview({
scanId: "scan-1",
filters: { "filter[provider_type__in]": "aws,gcp" },
});
const url = calledUrl();
expect(url.searchParams.get("filter[provider_type__in]")).toBe("aws,gcp");
// XOR: provider filters present -> never send scan_id (avoids backend 400)
expect(url.searchParams.get("filter[scan_id]")).toBeNull();
});
});
describe("getComplianceOverviewMetadataInfo", () => {
it("forwards provider filters", async () => {
await getComplianceOverviewMetadataInfo({
filters: { "filter[provider_groups__in]": "g1,g2" },
});
expect(calledUrl().searchParams.get("filter[provider_groups__in]")).toBe(
"g1,g2",
);
});
});
describe("getComplianceRequirements", () => {
it("appends compliance_id and scan_id in scan mode", async () => {
await getComplianceRequirements({
complianceId: "cis_2.0_aws",
scanId: "scan-1",
});
const url = calledUrl();
expect(url.searchParams.get("filter[compliance_id]")).toBe("cis_2.0_aws");
expect(url.searchParams.get("filter[scan_id]")).toBe("scan-1");
});
it("forwards provider filters and omits scan_id in aggregated mode", async () => {
await getComplianceRequirements({
complianceId: "cis_2.0_aws",
scanId: "scan-1",
filters: { "filter[provider_id__in]": "p1,p2" },
});
const url = calledUrl();
expect(url.searchParams.get("filter[compliance_id]")).toBe("cis_2.0_aws");
expect(url.searchParams.get("filter[provider_id__in]")).toBe("p1,p2");
expect(url.searchParams.get("filter[scan_id]")).toBeNull();
});
it("omits scan_id when no scan is provided", async () => {
await getComplianceRequirements({ complianceId: "cis_2.0_aws" });
expect(calledUrl().searchParams.get("filter[scan_id]")).toBeNull();
});
});
+23 -6
View File
@@ -1,6 +1,11 @@
"use server";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import {
type ComplianceFilters,
type ComplianceProviderFilters,
hasComplianceProviderFilters,
} from "@/lib/compliance/compliance-provider-filters";
import { handleApiResponse } from "@/lib/server-actions-helper";
export const getCompliancesOverview = async ({
@@ -10,7 +15,7 @@ export const getCompliancesOverview = async ({
}: {
scanId?: string;
region?: string | string[];
filters?: Record<string, string | string[] | undefined>;
filters?: ComplianceProviderFilters;
} = {}) => {
const headers = await getAuthHeaders({ contentType: false });
@@ -27,7 +32,10 @@ export const getCompliancesOverview = async ({
Object.entries(filters).forEach(([key, value]) => setParam(key, value));
setParam("filter[scan_id]", scanId);
// XOR: the backend rejects filter[scan_id] together with provider filters.
if (!hasComplianceProviderFilters(filters)) {
setParam("filter[scan_id]", scanId);
}
setParam("filter[region__in]", region);
try {
const response = await fetch(url.toString(), {
@@ -46,7 +54,7 @@ export const getComplianceOverviewMetadataInfo = async ({
filters = {},
}: {
sort?: string;
filters?: Record<string, string | string[] | undefined>;
filters?: ComplianceFilters;
} = {}) => {
const headers = await getAuthHeaders({ contentType: false });
@@ -111,22 +119,31 @@ export const getComplianceRequirements = async ({
complianceId,
scanId,
region,
filters = {},
}: {
complianceId: string;
scanId: string;
scanId?: string;
region?: string | string[];
filters?: ComplianceProviderFilters;
}) => {
const headers = await getAuthHeaders({ contentType: false });
try {
const url = new URL(`${apiBaseUrl}/compliance-overviews/requirements`);
url.searchParams.append("filter[compliance_id]", complianceId);
url.searchParams.append("filter[scan_id]", scanId);
// Forward provider-scope filters (aggregated mode); XOR with scan_id.
Object.entries(filters).forEach(([key, value]) => {
if (value && value.trim() !== "") url.searchParams.append(key, value);
});
if (scanId && !hasComplianceProviderFilters(filters)) {
url.searchParams.append("filter[scan_id]", scanId);
}
if (region) {
const regionValue = Array.isArray(region) ? region.join(",") : region;
url.searchParams.append("filter[region__in]", regionValue);
//remove page param
}
url.searchParams.delete("page");
+3 -3
View File
@@ -2,6 +2,7 @@
import { redirect } from "next/navigation";
import type { FindingsFilterParam } from "@/actions/findings/findings-filters";
import {
apiBaseUrl,
composeSort,
@@ -15,7 +16,6 @@ import {
} from "@/lib";
import { appendSanitizedProviderFilters } from "@/lib/provider-filters";
import { handleApiResponse } from "@/lib/server-actions-helper";
import { FilterParam } from "@/types/filters";
/**
* Maps filter[search] to filter[check_title__icontains] for finding-groups.
@@ -39,7 +39,7 @@ function mapSearchFilter(
* finding-group resources sub-endpoint. These must be stripped before
* calling the resources API to avoid empty results.
*/
const FINDING_GROUP_RESOURCE_UNSUPPORTED_FILTERS: FilterParam[] = [
const FINDING_GROUP_RESOURCE_UNSUPPORTED_FILTERS: FindingsFilterParam[] = [
"filter[service__in]",
"filter[scan__in]",
"filter[scan_id]",
@@ -53,7 +53,7 @@ function normalizeFindingGroupResourceFilters(
Object.entries(filters).filter(
([key]) =>
!FINDING_GROUP_RESOURCE_UNSUPPORTED_FILTERS.includes(
key as FilterParam,
key as FindingsFilterParam,
),
),
);
+32
View File
@@ -0,0 +1,32 @@
import { FILTER_FIELD, FilterParam } from "@/types/filters";
/**
* URL filter param keys the findings view supports, e.g. `filter[severity__in]`.
* Composed from the shared fields it uses plus a few findings-only extras
* (alternate scan/date/delta forms not used by other views).
*/
export type FindingsFilterParam = FilterParam<
// findings uses provider_id, not provider_uid
| (typeof FILTER_FIELD)[
| "PROVIDER_TYPE"
| "PROVIDER_ID"
| "PROVIDER_GROUPS"
| "REGION"
| "SERVICE"
| "SEVERITY"
| "STATUS"
| "DELTA"
| "RESOURCE_TYPE"
| "CATEGORY"
| "RESOURCE_GROUPS"
| "SCAN"]
// findings-only extras
| "delta__in"
| "scan"
| "scan_id"
| "scan_id__in"
| "inserted_at"
| "inserted_at__gte"
| "inserted_at__lte"
| "muted"
>;
@@ -0,0 +1,138 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
fetchMock,
getAuthHeadersMock,
handleApiErrorMock,
handleApiResponseMock,
} = vi.hoisted(() => ({
fetchMock: vi.fn(),
getAuthHeadersMock: vi.fn(),
handleApiErrorMock: vi.fn(),
handleApiResponseMock: vi.fn(),
}));
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: getAuthHeadersMock,
getErrorMessage: vi.fn(),
}));
vi.mock("@/lib/server-actions-helper", () => ({
handleApiError: handleApiErrorMock,
handleApiResponse: handleApiResponseMock,
}));
import { getAllProviderGroups } from "./manage-groups";
const makeGroup = (id: string, name: string) => ({
type: "provider-groups" as const,
id,
attributes: { name, inserted_at: "", updated_at: "" },
relationships: {
providers: { meta: { count: 0 }, data: [] },
roles: { meta: { count: 0 }, data: [] },
},
links: { self: "" },
});
const makePage = (
data: ReturnType<typeof makeGroup>[],
page: number,
pages: number,
) => ({
links: { first: "", last: "", next: null, prev: null },
data,
meta: { pagination: { page, pages, count: data.length } },
});
describe("getAllProviderGroups", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
fetchMock.mockResolvedValue(new Response(null, { status: 200 }));
});
it("merges every page into a single response with collapsed pagination", async () => {
handleApiResponseMock
.mockResolvedValueOnce(
makePage(
[makeGroup("g1", "Group 1"), makeGroup("g2", "Group 2")],
1,
2,
),
)
.mockResolvedValueOnce(makePage([makeGroup("g3", "Group 3")], 2, 2));
const result = await getAllProviderGroups();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(result?.data.map((group) => group.id)).toEqual(["g1", "g2", "g3"]);
expect(result?.meta.pagination).toMatchObject({
page: 1,
pages: 1,
count: 3,
});
});
it("stops after the first page when there is only one page", async () => {
handleApiResponseMock.mockResolvedValueOnce(
makePage([makeGroup("g1", "Group 1")], 1, 1),
);
const result = await getAllProviderGroups();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(result?.data).toHaveLength(1);
});
it("returns undefined when the first page has no data", async () => {
handleApiResponseMock.mockResolvedValueOnce(makePage([], 1, 1));
const result = await getAllProviderGroups();
expect(result).toBeUndefined();
});
it("returns undefined when the request throws", async () => {
fetchMock.mockRejectedValueOnce(new Error("network down"));
const result = await getAllProviderGroups();
expect(result).toBeUndefined();
});
it("returns undefined when a later page resolves to an error payload", async () => {
handleApiResponseMock
.mockResolvedValueOnce(makePage([makeGroup("g1", "Group 1")], 1, 2))
.mockResolvedValueOnce({ error: "Forbidden", status: 403 });
const result = await getAllProviderGroups();
expect(result).toBeUndefined();
});
it("returns undefined instead of a truncated list when the max-page cap is hit", async () => {
// Given an API that always reports more pages than the 50-page safety cap
handleApiResponseMock.mockImplementation((response: Response) => {
void response;
return Promise.resolve(makePage([makeGroup("g", "Group")], 1, 9999));
});
// When fetching every page
const result = await getAllProviderGroups();
// Then it must not return a partial/truncated list; bail out instead
expect(result).toBeUndefined();
expect(fetchMock).toHaveBeenCalledTimes(50);
});
});
+81
View File
@@ -51,6 +51,87 @@ export const getProviderGroups = async ({
}
};
/**
* Fetches all provider groups by iterating through every page.
* Used to populate filter dropdowns (e.g. the Provider Group selector) without
* the pagination cap that `getProviderGroups` applies for the management table.
*/
export const getAllProviderGroups = async (): Promise<
ProviderGroupsResponse | undefined
> => {
const pageSize = 100; // Larger page size to minimize API calls
const maxPages = 50; // Safety limit: 50 pages × 100 = 5000 groups max
let currentPage = 1;
const allGroups: ProviderGroupsResponse["data"] = [];
let lastResponse: ProviderGroupsResponse | undefined;
let hasMorePages = true;
try {
const headers = await getAuthHeaders({ contentType: false });
while (hasMorePages && currentPage <= maxPages) {
const url = new URL(`${apiBaseUrl}/provider-groups`);
url.searchParams.append("page[number]", currentPage.toString());
url.searchParams.append("page[size]", pageSize.toString());
const response = await fetch(url.toString(), { headers });
const data = (await handleApiResponse(response)) as
| ProviderGroupsResponse
| { error: string; status?: number }
| undefined;
// A later page resolving to an API error payload must abort rather than
// be treated as "no more pages", which would silently truncate groups.
if (data && "error" in data) {
console.error("Error fetching all provider groups:", data.error);
return undefined;
}
if (!data?.data || data.data.length === 0) {
hasMorePages = false;
continue;
}
allGroups.push(...data.data);
lastResponse = data;
const totalPages = data.meta?.pagination?.pages || 1;
if (currentPage >= totalPages) {
hasMorePages = false;
} else {
currentPage++;
}
}
if (hasMorePages && currentPage > maxPages) {
console.error(
`Error fetching all provider groups: exceeded max page limit (${maxPages})`,
);
return undefined;
}
if (lastResponse) {
return {
...lastResponse,
data: allGroups,
meta: {
...lastResponse.meta,
pagination: {
...lastResponse.meta?.pagination,
page: 1,
pages: 1,
count: allGroups.length,
},
},
};
}
return undefined;
} catch (error) {
console.error("Error fetching all provider groups:", error);
return undefined;
}
};
export const getProviderGroupInfoById = async (providerGroupId: string) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/provider-groups/${providerGroupId}`);
+16
View File
@@ -0,0 +1,16 @@
import { FILTER_FIELD, FilterParam } from "@/types/filters";
/**
* URL filter param keys the overview dashboard scopes its widgets by. Overview has
* no single action; its widgets read these keys from the URL filters.
*/
export type OverviewFilterParam = FilterParam<
(typeof FILTER_FIELD)["PROVIDER_TYPE" | "PROVIDER_ID" | "PROVIDER_GROUPS"]
>;
/** The `filter[...]` keys overview widgets read from the URL. */
export const OVERVIEW_FILTER_PARAM = {
PROVIDER_TYPE: `filter[${FILTER_FIELD.PROVIDER_TYPE}]`,
PROVIDER_ID: `filter[${FILTER_FIELD.PROVIDER_ID}]`,
PROVIDER_GROUPS: `filter[${FILTER_FIELD.PROVIDER_GROUPS}]`,
} as const satisfies Record<string, OverviewFilterParam>;
+18
View File
@@ -0,0 +1,18 @@
import { FILTER_FIELD, FilterParam } from "@/types/filters";
import { PROVIDERS_PAGE_FILTER } from "@/types/providers-table";
/**
* URL filter param keys the providers list supports, e.g. `filter[provider__in]`.
* Provider scope plus its providers-only extras (`provider__in` API param,
* `connected` status).
*/
export type ProvidersFilterParam = FilterParam<
| (typeof FILTER_FIELD)["PROVIDER_TYPE" | "PROVIDER_GROUPS" | "PROVIDER_UID"]
| (typeof PROVIDERS_PAGE_FILTER)["PROVIDER" | "STATUS"]
>;
/** `filter[...]` keys used when mapping the provider-type filter to the API param. */
export const PROVIDERS_FILTER_PARAM = {
PROVIDER: `filter[${PROVIDERS_PAGE_FILTER.PROVIDER}]`,
PROVIDER_TYPE: `filter[${PROVIDERS_PAGE_FILTER.PROVIDER_TYPE}]`,
} as const satisfies Record<string, ProvidersFilterParam>;
+16
View File
@@ -0,0 +1,16 @@
import { FILTER_FIELD, FilterParam } from "@/types/filters";
/**
* URL filter param keys the resources view supports, e.g. `filter[type__in]`.
* The shared core plus its resources-only dimensions (`type__in`, `groups__in`).
*/
export type ResourcesFilterParam = FilterParam<
| (typeof FILTER_FIELD)[
| "PROVIDER_TYPE"
| "PROVIDER_ID"
| "PROVIDER_GROUPS"
| "REGION"
| "SERVICE"]
| "type__in"
| "groups__in"
>;
+25
View File
@@ -0,0 +1,25 @@
import { FILTER_FIELD, FilterParam } from "@/types/filters";
/**
* Provider filter fields used to match/clear synthetic pending scan rows — the
* `__in` forms (shared with real scan rows) plus the exact forms, and the
* provider-group `__in` form so pending rows honor the group filter too.
*/
export const SCANS_PROVIDER_FILTER_FIELD = {
PROVIDER_UID_IN: FILTER_FIELD.PROVIDER_UID,
PROVIDER_UID: "provider_uid",
PROVIDER_TYPE_IN: FILTER_FIELD.PROVIDER_TYPE,
PROVIDER_TYPE: "provider_type",
PROVIDER_GROUPS_IN: FILTER_FIELD.PROVIDER_GROUPS,
} as const;
/**
* URL filter param keys the scans view supports, e.g. `filter[state__in]`.
* Provider scope (scans uses provider_uid, not provider_id) including provider
* groups and the exact pending-row provider forms, plus the scans-only dimensions.
*/
export type ScansFilterParam = FilterParam<
| (typeof SCANS_PROVIDER_FILTER_FIELD)[keyof typeof SCANS_PROVIDER_FILTER_FIELD]
| "state__in"
| "trigger"
>;
@@ -0,0 +1,162 @@
import { describe, expect, it } from "vitest";
import { ProviderProps } from "@/types/providers";
import {
filterProvidersByScope,
parseFilterIds,
scopeProvidersByGroup,
} from "./provider-scope";
const makeProvider = (
id: string,
provider: string,
groupIds: string[] = [],
): ProviderProps =>
({
id,
attributes: { provider },
relationships: {
provider_groups: {
data: groupIds.map((gid) => ({ type: "provider-groups", id: gid })),
},
},
}) as unknown as ProviderProps;
describe("parseFilterIds", () => {
it("returns an empty array for undefined", () => {
// Given / When / Then
expect(parseFilterIds(undefined)).toEqual([]);
});
it("returns an empty array for an empty string", () => {
// Given an empty param value (e.g. "filter[provider_groups__in]=")
// When / Then it must not produce a [""] match
expect(parseFilterIds("")).toEqual([]);
});
it("drops whitespace-only and empty segments", () => {
// Given a blank/whitespace value
// When / Then
expect(parseFilterIds(" ")).toEqual([]);
expect(parseFilterIds(",")).toEqual([]);
expect(parseFilterIds("a,,b")).toEqual(["a", "b"]);
});
it("splits and trims comma-separated ids", () => {
expect(parseFilterIds(" a , b ")).toEqual(["a", "b"]);
});
it("normalizes array param values", () => {
expect(parseFilterIds(["a", "", "b"])).toEqual(["a", "b"]);
});
});
describe("scopeProvidersByGroup", () => {
const providers = [
makeProvider("p1", "aws", ["g1"]),
makeProvider("p2", "gcp", ["g2"]),
makeProvider("p3", "azure", []),
];
it("returns every provider when no group is selected", () => {
expect(scopeProvidersByGroup(providers, [])).toEqual(providers);
});
it("keeps only providers that belong to a selected group", () => {
// When scoping to g1
const result = scopeProvidersByGroup(providers, ["g1"]);
// Then only the g1 member remains
expect(result.map((p) => p.id)).toEqual(["p1"]);
});
it("excludes providers with no group memberships", () => {
expect(scopeProvidersByGroup(providers, ["g2"]).map((p) => p.id)).toEqual([
"p2",
]);
});
});
describe("filterProvidersByScope", () => {
const providers = [
makeProvider("p1", "aws", ["g1"]),
makeProvider("p2", "gcp", ["g1"]),
makeProvider("p3", "aws", ["g2"]),
makeProvider("p4", "azure", []),
];
it("returns every provider when no dimension is set", () => {
const result = filterProvidersByScope(providers, {
providerIds: [],
providerTypes: [],
providerGroupIds: [],
});
expect(result).toEqual(providers);
});
it("filters by provider id", () => {
const result = filterProvidersByScope(providers, {
providerIds: ["p2"],
providerTypes: [],
providerGroupIds: [],
});
expect(result.map((p) => p.id)).toEqual(["p2"]);
});
it("filters by provider type case-insensitively", () => {
const result = filterProvidersByScope(providers, {
providerIds: [],
providerTypes: ["AWS"],
providerGroupIds: [],
});
expect(result.map((p) => p.id)).toEqual(["p1", "p3"]);
});
it("filters by provider group", () => {
const result = filterProvidersByScope(providers, {
providerIds: [],
providerTypes: [],
providerGroupIds: ["g1"],
});
expect(result.map((p) => p.id)).toEqual(["p1", "p2"]);
});
it("composes group AND type (the risk-plot regression)", () => {
// Given both a group and a type filter are active
// When combining group g1 with type aws
const result = filterProvidersByScope(providers, {
providerIds: [],
providerTypes: ["aws"],
providerGroupIds: ["g1"],
});
// Then only providers matching BOTH survive (p1), not all aws or all g1
expect(result.map((p) => p.id)).toEqual(["p1"]);
});
it("composes id AND group", () => {
// p3 is aws/g2; selecting it together with group g1 yields nothing
const result = filterProvidersByScope(providers, {
providerIds: ["p3"],
providerTypes: [],
providerGroupIds: ["g1"],
});
expect(result).toEqual([]);
});
it("composes all three dimensions", () => {
const result = filterProvidersByScope(providers, {
providerIds: ["p1", "p2"],
providerTypes: ["aws"],
providerGroupIds: ["g1"],
});
expect(result.map((p) => p.id)).toEqual(["p1"]);
});
});
@@ -0,0 +1,71 @@
import { ProviderProps } from "@/types/providers";
export interface ProviderScopeFilters {
providerIds: string[];
providerTypes: string[];
providerGroupIds: string[];
}
/**
* Normalize a comma-separated filter param into trimmed, non-empty ids.
* Guards against blank values (e.g. an empty "filter[...]=" param) so they are
* treated as "no filter" instead of matching against an empty-string id.
*/
export const parseFilterIds = (
value: string | string[] | undefined,
): string[] => {
if (value === undefined) return [];
const raw = Array.isArray(value) ? value.join(",") : value;
return raw
.split(",")
.map((id) => id.trim())
.filter((id) => id.length > 0);
};
const belongsToGroup = (provider: ProviderProps, groupIds: string[]): boolean =>
provider.relationships.provider_groups?.data?.some((group) =>
groupIds.includes(group.id),
) ?? false;
/**
* Keep only providers belonging to one of the selected groups. An empty group
* list means "no group filter" and returns every provider unchanged.
*/
export const scopeProvidersByGroup = (
providers: ProviderProps[],
groupIds: string[],
): ProviderProps[] =>
groupIds.length === 0
? providers
: providers.filter((p) => belongsToGroup(p, groupIds));
/**
* Filter providers by every active scope dimension (id, type, group) combined
* with AND. Each empty dimension is skipped, so a provider is kept only when it
* satisfies all the filters that are actually set.
*/
export const filterProvidersByScope = (
providers: ProviderProps[],
{ providerIds, providerTypes, providerGroupIds }: ProviderScopeFilters,
): ProviderProps[] => {
const normalizedTypes = providerTypes.map((type) => type.toLowerCase());
return providers.filter((provider) => {
if (providerIds.length > 0 && !providerIds.includes(provider.id)) {
return false;
}
if (
normalizedTypes.length > 0 &&
!normalizedTypes.includes(provider.attributes.provider.toLowerCase())
) {
return false;
}
if (
providerGroupIds.length > 0 &&
!belongsToGroup(provider, providerGroupIds)
) {
return false;
}
return true;
});
};
@@ -3,11 +3,16 @@ import {
getFindingsBySeverity,
SeverityByProviderType,
} from "@/actions/overview";
import { OVERVIEW_FILTER_PARAM } from "@/actions/overview/overview-filters";
import { getAllProviders } from "@/actions/providers";
import { SankeyChart } from "@/components/graphs/sankey-chart";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../_lib/filter-params";
import {
parseFilterIds,
scopeProvidersByGroup,
} from "../../_lib/provider-scope";
export async function RiskPipelineViewSSR({
searchParams,
@@ -16,27 +21,31 @@ export async function RiskPipelineViewSSR({
}) {
const filters = pickFilterParams(searchParams);
const providerTypeFilter = filters["filter[provider_type__in]"];
const providerIdFilter = filters["filter[provider_id__in]"];
const providerTypeFilter = filters[OVERVIEW_FILTER_PARAM.PROVIDER_TYPE];
const providerIdFilter = filters[OVERVIEW_FILTER_PARAM.PROVIDER_ID];
const providerGroupsFilter = filters[OVERVIEW_FILTER_PARAM.PROVIDER_GROUPS];
// Fetch providers list to know account types
const providersListResponse = await getAllProviders();
const allProviders = providersListResponse?.data || [];
// Scope the provider set to the selected groups so we enumerate only their
// provider types below (the per-type API calls also carry the group filter).
const selectedGroupIds = parseFilterIds(providerGroupsFilter);
const scopedProviders = scopeProvidersByGroup(allProviders, selectedGroupIds);
// Build severityByProviderType based on filters
const severityByProviderType: SeverityByProviderType = {};
let selectedProviderTypes: string[] | undefined;
if (providerIdFilter) {
// Case: Accounts are selected - group by provider type and make parallel calls
const selectedAccountIds = String(providerIdFilter)
.split(",")
.map((id) => id.trim());
const selectedAccountIds = parseFilterIds(providerIdFilter);
// Group selected accounts by provider type
const accountsByType: Record<string, string[]> = {};
for (const accountId of selectedAccountIds) {
const provider = allProviders.find((p) => p.id === accountId);
const provider = scopedProviders.find((p) => p.id === accountId);
if (provider) {
const type = provider.attributes.provider.toLowerCase();
if (!accountsByType[type]) {
@@ -70,9 +79,9 @@ export async function RiskPipelineViewSSR({
}
} else if (providerTypeFilter) {
// Case: Provider types are selected - make parallel calls for each type
selectedProviderTypes = String(providerTypeFilter)
.split(",")
.map((t) => t.trim().toLowerCase());
selectedProviderTypes = parseFilterIds(providerTypeFilter).map((type) =>
type.toLowerCase(),
);
const severityPromises = selectedProviderTypes.map(async (providerType) => {
const response = await getFindingsBySeverity({
@@ -93,9 +102,10 @@ export async function RiskPipelineViewSSR({
}
}
} else {
// Case: No filters - get all provider types and make parallel calls
// Case: No account/type filter - enumerate provider types (scoped to the
// selected groups when a group filter is active) and make parallel calls.
const allProviderTypes = Array.from(
new Set(allProviders.map((p) => p.attributes.provider.toLowerCase())),
new Set(scopedProviders.map((p) => p.attributes.provider.toLowerCase())),
);
const severityPromises = allProviderTypes.map(async (providerType) => {
@@ -1,5 +1,6 @@
import { Info } from "lucide-react";
import { OVERVIEW_FILTER_PARAM } from "@/actions/overview/overview-filters";
import {
adaptToRiskPlotData,
getProvidersRiskData,
@@ -8,6 +9,10 @@ import { getAllProviders } from "@/actions/providers";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../_lib/filter-params";
import {
filterProvidersByScope,
parseFilterIds,
} from "../../_lib/provider-scope";
import { RiskPlotClient } from "./risk-plot-client";
export async function RiskPlotSSR({
@@ -17,31 +22,19 @@ export async function RiskPlotSSR({
}) {
const filters = pickFilterParams(searchParams);
const providerTypeFilter = filters["filter[provider_type__in]"];
const providerIdFilter = filters["filter[provider_id__in]"];
// Fetch all providers
const providersListResponse = await getAllProviders();
const allProviders = providersListResponse?.data || [];
// Filter providers based on search params
let filteredProviders = allProviders;
if (providerIdFilter) {
// Filter by specific provider IDs
const selectedIds = String(providerIdFilter)
.split(",")
.map((id) => id.trim());
filteredProviders = allProviders.filter((p) => selectedIds.includes(p.id));
} else if (providerTypeFilter) {
// Filter by provider types
const selectedTypes = String(providerTypeFilter)
.split(",")
.map((t) => t.trim().toLowerCase());
filteredProviders = allProviders.filter((p) =>
selectedTypes.includes(p.attributes.provider.toLowerCase()),
);
}
// Compose every active provider-scope filter with AND so combining e.g. a
// group and a type narrows to providers matching both.
const filteredProviders = filterProvidersByScope(allProviders, {
providerIds: parseFilterIds(filters[OVERVIEW_FILTER_PARAM.PROVIDER_ID]),
providerTypes: parseFilterIds(filters[OVERVIEW_FILTER_PARAM.PROVIDER_TYPE]),
providerGroupIds: parseFilterIds(
filters[OVERVIEW_FILTER_PARAM.PROVIDER_GROUPS],
),
});
// No providers to show
if (filteredProviders.length === 0) {
@@ -3,6 +3,7 @@
import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { OVERVIEW_FILTER_PARAM } from "@/actions/overview/overview-filters";
import { getSeverityTrendsByTimeRange } from "@/actions/overview/severity-trends";
import { LineChart } from "@/components/graphs/line-chart";
import { LineConfig, LineDataPoint } from "@/components/graphs/types";
@@ -42,10 +43,16 @@ export const FindingSeverityOverTime = ({
const getActiveProviderFilters = (): Record<string, string> => {
const filters: Record<string, string> = {};
const providerType = searchParams.get("filter[provider_type__in]");
const providerId = searchParams.get("filter[provider_id__in]");
if (providerType) filters["filter[provider_type__in]"] = providerType;
if (providerId) filters["filter[provider_id__in]"] = providerId;
const providerType = searchParams.get(OVERVIEW_FILTER_PARAM.PROVIDER_TYPE);
const providerId = searchParams.get(OVERVIEW_FILTER_PARAM.PROVIDER_ID);
const providerGroups = searchParams.get(
OVERVIEW_FILTER_PARAM.PROVIDER_GROUPS,
);
if (providerType)
filters[OVERVIEW_FILTER_PARAM.PROVIDER_TYPE] = providerType;
if (providerId) filters[OVERVIEW_FILTER_PARAM.PROVIDER_ID] = providerId;
if (providerGroups)
filters[OVERVIEW_FILTER_PARAM.PROVIDER_GROUPS] = providerGroups;
return filters;
};
@@ -27,6 +27,11 @@ import {
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
import { ContentLayout } from "@/components/ui";
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
import {
type ComplianceProviderFilters,
extractComplianceProviderFilters,
hasComplianceProviderFilters,
} from "@/lib/compliance/compliance-provider-filters";
import {
getReportTypeForCompliance,
pickLatestCisPerProvider,
@@ -37,15 +42,19 @@ import {
Framework,
RequirementsTotals,
} from "@/types/compliance";
import { SearchParamsProps } from "@/types/components";
import { ScanEntity } from "@/types/scans";
interface ComplianceDetailSearchParams {
interface ComplianceDetailSearchParams extends SearchParamsProps {
complianceId: string;
version?: string;
scanId?: string;
section?: string;
"filter[region__in]"?: string;
"filter[cis_profile_level]"?: string;
"filter[provider_type__in]"?: string;
"filter[provider_id__in]"?: string;
"filter[provider_groups__in]"?: string;
page?: string;
pageSize?: string;
}
@@ -64,6 +73,10 @@ export default async function ComplianceDetail({
const cisProfileFilter = resolvedSearchParams["filter[cis_profile_level]"];
const logoPath = getComplianceIcon(compliancetitle);
const hasProviderFilters = hasComplianceProviderFilters(resolvedSearchParams);
const providerFilters =
extractComplianceProviderFilters(resolvedSearchParams);
// Create a key that excludes pagination parameters to preserve accordion state avoiding reloads with pagination
const paramsForKey = Object.fromEntries(
Object.entries(resolvedSearchParams).filter(
@@ -78,14 +91,15 @@ export default async function ComplianceDetail({
: `${formattedTitle}`;
let selectedScan: ScanEntity | null = null;
const selectedScanId = scanId || null;
// Aggregated mode ignores scanId entirely (backend XOR); provider filters drive scope.
const selectedScanId = hasProviderFilters ? null : scanId || null;
const [metadataInfoData, attributesData, selectedScanResponse] =
await Promise.all([
getComplianceOverviewMetadataInfo({
filters: {
"filter[scan_id]": selectedScanId ?? undefined,
},
filters: hasProviderFilters
? providerFilters
: { "filter[scan_id]": selectedScanId ?? undefined },
}),
getComplianceAttributes(complianceId, selectedScanId ?? undefined),
selectedScanId
@@ -238,6 +252,8 @@ export default async function ComplianceDetail({
attributesData={attributesData}
threatScoreData={threatScoreData}
targetSection={section}
hasProviderFilters={hasProviderFilters}
providerFilters={providerFilters}
/>
</Suspense>
</ContentLayout>
@@ -252,6 +268,8 @@ const SSRComplianceContent = async ({
attributesData,
threatScoreData,
targetSection,
hasProviderFilters,
providerFilters,
}: {
complianceId: string;
scanId: string;
@@ -263,15 +281,18 @@ const SSRComplianceContent = async ({
sectionScores: Record<string, number>;
} | null;
targetSection?: string;
hasProviderFilters: boolean;
providerFilters: ComplianceProviderFilters;
}) => {
const requirementsData = await getComplianceRequirements({
complianceId,
scanId,
scanId: scanId || undefined,
region,
filters: hasProviderFilters ? providerFilters : undefined,
});
const type = requirementsData?.data?.[0]?.type;
if (!scanId || type === "tasks") {
if ((!scanId && !hasProviderFilters) || type === "tasks") {
return (
<div className="flex flex-col gap-8">
<div className="grid grid-cols-1 gap-6 md:grid-cols-[minmax(280px,400px)_1fr]">
+11
View File
@@ -13,4 +13,15 @@ describe("Compliance overview page", () => {
expect(source).toContain("ComplianceOverviewGrid");
expect(source).not.toContain("filter[search]");
});
it("switches to aggregated mode when provider filters are present", () => {
expect(source).toContain("hasComplianceProviderFilters");
expect(source).toContain("extractComplianceProviderFilters");
});
it("feeds the provider and provider-group selectors", () => {
expect(source).toContain("getAllProviders");
expect(source).toContain("getAllProviderGroups");
expect(source).toContain("providerGroups={");
});
});
+58 -25
View File
@@ -5,7 +5,9 @@ import {
getComplianceOverviewMetadataInfo,
getCompliancesOverview,
} from "@/actions/compliances";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
import { getThreatScore } from "@/actions/overview";
import { getAllProviders } from "@/actions/providers/providers";
import { getScans } from "@/actions/scans";
import {
ComplianceSkeletonGrid,
@@ -17,6 +19,11 @@ import { ComplianceOverviewGrid } from "@/components/compliance/compliance-overv
import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { Card, CardContent } from "@/components/shadcn/card/card";
import { ContentLayout } from "@/components/ui";
import {
type ComplianceProviderFilters,
extractComplianceProviderFilters,
hasComplianceProviderFilters,
} from "@/lib/compliance/compliance-provider-filters";
import { pickLatestCisPerProvider } from "@/lib/compliance/compliance-report-types";
import {
ExpandedScanData,
@@ -34,16 +41,24 @@ export default async function Compliance({
const resolvedSearchParams = await searchParams;
const searchParamsKey = JSON.stringify(resolvedSearchParams || {});
const scansData = await getScans({
filters: {
"filter[state]": "completed",
},
pageSize: 50,
fields: {
scans: "name,completed_at,provider",
},
include: "provider",
});
const hasProviderFilters = hasComplianceProviderFilters(resolvedSearchParams);
const providerFilters =
extractComplianceProviderFilters(resolvedSearchParams);
const [scansData, providersData, providerGroupsData] = await Promise.all([
getScans({
filters: {
"filter[state]": "completed",
},
pageSize: 50,
fields: {
scans: "name,completed_at,provider",
},
include: "provider",
}),
getAllProviders(),
getAllProviderGroups(),
]);
if (!scansData?.data) {
return (
@@ -90,9 +105,12 @@ export default async function Compliance({
const scanIdFromUrl = Array.isArray(scanIdParam)
? scanIdParam[0]
: scanIdParam;
const selectedScanId: string | null =
scanIdFromUrl || expandedScansData[0]?.id || null;
const onboardingAction = selectedScanId
// Aggregated mode ignores scanId entirely (backend XOR); provider filters drive scope.
const selectedScanId: string | null = hasProviderFilters
? null
: scanIdFromUrl || expandedScansData[0]?.id || null;
const hasScope = hasProviderFilters || Boolean(selectedScanId);
const onboardingAction = hasScope
? { flowId: "view-compliance" }
: {
flowId: "view-compliance",
@@ -115,13 +133,15 @@ export default async function Compliance({
}
: undefined;
const metadataInfoData = selectedScanId
? await getComplianceOverviewMetadataInfo({
filters: {
"filter[scan_id]": selectedScanId,
},
})
: { data: { attributes: { regions: [] } } };
const metadataInfoData = hasProviderFilters
? await getComplianceOverviewMetadataInfo({ filters: providerFilters })
: selectedScanId
? await getComplianceOverviewMetadataInfo({
filters: {
"filter[scan_id]": selectedScanId,
},
})
: { data: { attributes: { regions: [] } } };
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
@@ -146,13 +166,15 @@ export default async function Compliance({
icon="lucide:shield-check"
onboardingAction={onboardingAction}
>
{selectedScanId ? (
{hasScope ? (
<>
<div className="mb-6">
<ComplianceFilters
scans={expandedScansData}
uniqueRegions={uniqueRegions}
selectedScanId={selectedScanId}
providers={providersData?.data ?? []}
providerGroups={providerGroupsData?.data ?? []}
/>
</div>
@@ -182,6 +204,8 @@ export default async function Compliance({
searchParams={resolvedSearchParams}
scanId={selectedScanId}
selectedScan={selectedScanData}
hasProviderFilters={hasProviderFilters}
providerFilters={providerFilters}
/>
</Suspense>
</>
@@ -196,15 +220,23 @@ const SSRComplianceGrid = async ({
searchParams,
scanId,
selectedScan,
hasProviderFilters,
providerFilters,
}: {
searchParams: SearchParamsProps;
scanId: string | null;
selectedScan?: ScanEntity;
hasProviderFilters: boolean;
providerFilters: ComplianceProviderFilters;
}) => {
const regionFilter = searchParams["filter[region__in]"]?.toString() || "";
const compliancesData =
scanId && scanId.trim() !== ""
const compliancesData = hasProviderFilters
? await getCompliancesOverview({
region: regionFilter,
filters: providerFilters,
})
: scanId && scanId.trim() !== ""
? await getCompliancesOverview({
scanId,
region: regionFilter,
@@ -230,8 +262,9 @@ const SSRComplianceGrid = async ({
<Alert variant="info">
<Info className="size-4" />
<AlertDescription>
This scan has no compliance data available yet, please select a
different one.
{hasProviderFilters
? "No completed scans match the selected providers."
: "This scan has no compliance data available yet, please select a different one."}
</AlertDescription>
</Alert>
);
+4 -1
View File
@@ -6,6 +6,7 @@ import {
getLatestFindingGroups,
} from "@/actions/finding-groups";
import { getLatestMetadataInfo, getMetadataInfo } from "@/actions/findings";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
import { getAllProviders } from "@/actions/providers";
import { getScan, getScans } from "@/actions/scans";
import { SeedFromFindingsButton } from "@/app/(prowler)/alerts/_components";
@@ -36,8 +37,9 @@ export default async function Findings({
const { encodedSort } = extractSortAndKey(resolvedSearchParams);
const { filters, query } = extractFiltersAndQuery(resolvedSearchParams);
const [providersData, scansData] = await Promise.all([
const [providersData, providerGroupsData, scansData] = await Promise.all([
getAllProviders(),
getAllProviderGroups(),
getScans({ pageSize: 50 }),
]);
@@ -99,6 +101,7 @@ export default async function Findings({
<div className="mb-6">
<FindingsFilters
providers={providersData?.data || []}
providerGroups={providerGroupsData?.data || []}
completedScanIds={completedScanIds}
scanDetails={scanDetails}
uniqueRegions={uniqueRegions}
+7 -1
View File
@@ -1,7 +1,9 @@
import { Suspense } from "react";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
import { getAllProviders } from "@/actions/providers";
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
import { ContentLayout } from "@/components/ui";
import { SearchParamsProps } from "@/types";
@@ -38,12 +40,16 @@ export default async function Home({
searchParams: Promise<SearchParamsProps>;
}) {
const resolvedSearchParams = await searchParams;
const providersData = await getAllProviders();
const [providersData, providerGroupsData] = await Promise.all([
getAllProviders(),
getAllProviderGroups(),
]);
return (
<ContentLayout title="Overview" icon="lucide:square-chart-gantt">
<div className="xxl:grid-cols-4 mb-6 grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<ProviderAccountSelectors providers={providersData?.data ?? []} />
<ProviderGroupSelector groups={providerGroupsData?.data ?? []} />
</div>
<div className="flex flex-col gap-6 xl:flex-row xl:flex-wrap xl:items-stretch">
+1
View File
@@ -118,6 +118,7 @@ const ProvidersTabContent = async ({
isCloud={process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"}
filters={providersView.filters}
providers={providersView.providers}
providerGroups={providersView.providerGroups}
metadata={providersView.metadata}
rows={providersView.rows}
/>
@@ -18,6 +18,10 @@ const schedulesActionsMock = vi.hoisted(() => ({
getSchedules: vi.fn(),
}));
const manageGroupsActionsMock = vi.hoisted(() => ({
getAllProviderGroups: vi.fn(),
}));
vi.mock("@/actions/providers", () => providersActionsMock);
vi.mock(
"@/actions/organizations/organizations",
@@ -25,6 +29,7 @@ vi.mock(
);
vi.mock("@/actions/scans", () => scansActionsMock);
vi.mock("@/actions/schedules", () => schedulesActionsMock);
vi.mock("@/actions/manage-groups/manage-groups", () => manageGroupsActionsMock);
import { SearchParamsProps } from "@/types";
import { ProvidersApiResponse } from "@/types/providers";
@@ -1,8 +1,10 @@
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
import {
listOrganizationsSafe,
listOrganizationUnitsSafe,
} from "@/actions/organizations/organizations";
import { getAllProviders, getProviders } from "@/actions/providers";
import { PROVIDERS_FILTER_PARAM } from "@/actions/providers/providers-filters";
import { getScans } from "@/actions/scans";
import { getSchedules } from "@/actions/schedules";
import {
@@ -465,13 +467,12 @@ export async function loadProvidersAccountsViewData({
// Map provider_type__in (used by ProviderTypeSelector) to provider__in (API param)
const providerTypeFilter =
providerFilters[`filter[${PROVIDERS_PAGE_FILTER.PROVIDER_TYPE}]`];
providerFilters[PROVIDERS_FILTER_PARAM.PROVIDER_TYPE];
if (providerTypeFilter) {
providerFilters[`filter[${PROVIDERS_PAGE_FILTER.PROVIDER}]`] =
providerTypeFilter;
providerFilters[PROVIDERS_FILTER_PARAM.PROVIDER] = providerTypeFilter;
}
delete providerFilters[`filter[${PROVIDERS_PAGE_FILTER.PROVIDER_TYPE}]`];
delete providerFilters[PROVIDERS_FILTER_PARAM.PROVIDER_TYPE];
const emptyOrganizationsResponse: OrganizationListResponse = {
data: [],
@@ -483,6 +484,7 @@ export async function loadProvidersAccountsViewData({
const [
providersResponse,
allProvidersResponse,
allProviderGroupsResponse,
scansResponse,
schedulesResponse,
organizationsResponse,
@@ -500,6 +502,8 @@ export async function loadProvidersAccountsViewData({
// Unfiltered fetch for ProviderTypeSelector — only needs distinct types;
// TODO: Replace with a dedicated lightweight endpoint when available.
resolveActionResult(getAllProviders()),
// Unfiltered fetch for the Provider Group selector dropdown.
resolveActionResult(getAllProviderGroups()),
// Fetch active scheduled scans to flag providers whose schedule has fired.
resolveActionResult(
getScans({
@@ -545,6 +549,7 @@ export async function loadProvidersAccountsViewData({
filters: createProvidersFilters(),
metadata: providersResponse?.meta,
providers: allProvidersResponse?.data ?? [],
providerGroups: allProviderGroupsResponse?.data ?? [],
rows,
};
}
+19 -13
View File
@@ -1,5 +1,6 @@
import { Suspense } from "react";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
import { getAllProviders } from "@/actions/providers";
import {
getLatestMetadataInfo,
@@ -37,19 +38,23 @@ export default async function Resources({
const initialResourceId = resolvedSearchParams.resourceId?.toString();
const [metadataInfoData, providersData, resourceByIdData] = await Promise.all(
[
(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}
+39 -23
View File
@@ -1,8 +1,13 @@
import { redirect } from "next/navigation";
import { Suspense } from "react";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
import { getAllProviders } from "@/actions/providers";
import { getScans } from "@/actions/scans";
import {
SCANS_PROVIDER_FILTER_FIELD,
type ScansFilterParam,
} from "@/actions/scans/scans-filters";
import { getSchedules } from "@/actions/schedules";
import { auth } from "@/auth.config";
import { PageReady } from "@/components/onboarding";
@@ -33,28 +38,21 @@ import {
const ACTIVE_SCAN_COUNT_PAGE_SIZE = 1;
// Pending schedule rows are derived from provider schedules, but must honor the
// same provider filters as real scan rows. Keep these filter keys typed locally
// without narrowing the global SearchParamsProps shape used by Next pages.
const PENDING_ROW_PROVIDER_FILTER = {
PROVIDER_UID_IN: "provider_uid__in",
PROVIDER_UID: "provider_uid",
PROVIDER_TYPE_IN: "provider_type__in",
PROVIDER_TYPE: "provider_type",
} as const;
type PendingRowProviderFilter =
(typeof PENDING_ROW_PROVIDER_FILTER)[keyof typeof PENDING_ROW_PROVIDER_FILTER];
type PendingRowProviderFilterParam = `filter[${PendingRowProviderFilter}]`;
// 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_UID_FILTER_KEYS = [
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_UID_IN}]`,
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_UID}]`,
] as const satisfies ReadonlyArray<PendingRowProviderFilterParam>;
`filter[${SCANS_PROVIDER_FILTER_FIELD.PROVIDER_UID_IN}]`,
`filter[${SCANS_PROVIDER_FILTER_FIELD.PROVIDER_UID}]`,
] 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[]>,
@@ -77,7 +75,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];
@@ -98,11 +96,18 @@ const filterProvidersForPendingRows = (
const types = parseCsvParam(
getFirstSearchParam(searchParams, PROVIDER_TYPE_FILTER_KEYS),
);
const groups = parseCsvParam(
getFirstSearchParam(searchParams, PROVIDER_GROUP_FILTER_KEYS),
);
return providers.filter(
(provider) =>
(uids.length === 0 || uids.includes(provider.attributes.uid)) &&
(types.length === 0 || types.includes(provider.attributes.provider)),
(types.length === 0 || types.includes(provider.attributes.provider)) &&
(groups.length === 0 ||
(provider.relationships?.provider_groups?.data ?? []).some((group) =>
groups.includes(group.id),
)),
);
};
@@ -168,8 +173,18 @@ export default async function Scans({
const session = await auth();
const resolvedSearchParams = await searchParams;
const providersData = await getAllProviders();
const providers = providersData?.data ?? [];
const [providersResult, providerGroupsResult] = await Promise.allSettled([
getAllProviders(),
getAllProviderGroups(),
]);
const providers =
providersResult.status === "fulfilled"
? (providersResult.value?.data ?? [])
: [];
const providerGroups =
providerGroupsResult.status === "fulfilled"
? (providerGroupsResult.value?.data ?? [])
: [];
const connectedProviders = providers.filter(
(provider: ProviderProps) =>
@@ -220,6 +235,7 @@ export default async function Scans({
) : (
<ScansPageShell
providers={providers}
providerGroups={providerGroups}
hasManageScansPermission={hasManageScansPermission}
activeScanCount={activeScanCount}
>
@@ -0,0 +1,111 @@
import { render, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Requirement } from "@/types/compliance";
import { ClientAccordionContent } from "./client-accordion-content";
const { getFindingsMock, getLatestFindingsMock } = vi.hoisted(() => ({
getFindingsMock: vi.fn(),
getLatestFindingsMock: vi.fn(),
}));
let currentSearchParams = new URLSearchParams();
vi.mock("next/navigation", () => ({
useSearchParams: () => currentSearchParams,
}));
vi.mock("@/actions/findings/findings", () => ({
getFindings: getFindingsMock,
getLatestFindings: getLatestFindingsMock,
}));
vi.mock("@/components/findings/table", () => ({
getStandaloneFindingColumns: () => [],
SkeletonTableFindings: () => <div data-testid="skeleton" />,
}));
vi.mock("@/components/ui/accordion/Accordion", () => ({
Accordion: () => <div data-testid="accordion" />,
}));
vi.mock("@/components/ui/table", () => ({
DataTable: () => <div data-testid="data-table" />,
}));
vi.mock("@/lib/compliance/compliance-mapper", () => ({
getComplianceMapper: () => ({ getDetailsComponent: () => null }),
}));
vi.mock("@/lib", () => ({
createDict: () => ({}),
FINDINGS_DEFAULT_SORT: "severity",
MUTED_FILTER: { EXCLUDE: "false" },
}));
const requirement = {
check_ids: ["check-1"],
status: "FAIL",
} as unknown as Requirement;
describe("ClientAccordionContent findings drill-down", () => {
beforeEach(() => {
vi.clearAllMocks();
getFindingsMock.mockResolvedValue({ data: [], meta: {} });
getLatestFindingsMock.mockResolvedValue({ data: [], meta: {} });
});
describe("when provider filters drive aggregated mode", () => {
it("loads findings from the latest endpoint, not the scan-scoped one", async () => {
// Given - the URL carries a provider-scope filter and no scanId
currentSearchParams = new URLSearchParams({
complianceId: "cis_2.0_aws",
"filter[provider_type__in]": "aws",
});
// When
render(
<ClientAccordionContent
requirement={requirement}
framework="cis_aws"
scanId=""
/>,
);
// Then - /findings 400s without a scan or date filter, so aggregated mode
// must use /findings/latest, forwarding the provider filters and no scan
await waitFor(() =>
expect(getLatestFindingsMock).toHaveBeenCalledTimes(1),
);
expect(getFindingsMock).not.toHaveBeenCalled();
const { filters } = getLatestFindingsMock.mock.calls[0][0];
expect(filters).toMatchObject({ "filter[provider_type__in]": "aws" });
expect(filters).not.toHaveProperty("filter[scan]");
});
});
describe("when a single scan drives the scope", () => {
it("loads findings from the scan-scoped endpoint", async () => {
// Given - no provider filters, a concrete scanId
currentSearchParams = new URLSearchParams({
complianceId: "cis_2.0_aws",
});
// When
render(
<ClientAccordionContent
requirement={requirement}
framework="cis_aws"
scanId="scan-1"
/>,
);
// Then
await waitFor(() => expect(getFindingsMock).toHaveBeenCalledTimes(1));
expect(getLatestFindingsMock).not.toHaveBeenCalled();
const { filters } = getFindingsMock.mock.calls[0][0];
expect(filters).toMatchObject({ "filter[scan]": "scan-1" });
});
});
});
@@ -3,7 +3,7 @@
import { useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { getFindings } from "@/actions/findings/findings";
import { getFindings, getLatestFindings } from "@/actions/findings/findings";
import {
getStandaloneFindingColumns,
SkeletonTableFindings,
@@ -12,6 +12,7 @@ import { Accordion } from "@/components/ui/accordion/Accordion";
import { DataTable } from "@/components/ui/table";
import { createDict, FINDINGS_DEFAULT_SORT, MUTED_FILTER } from "@/lib";
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
import { extractComplianceProviderFilters } from "@/lib/compliance/compliance-provider-filters";
import { Requirement } from "@/types/compliance";
import { FindingProps, FindingsResponse } from "@/types/components";
@@ -46,6 +47,12 @@ export const ClientAccordionContent = ({
// so the requirement view stays consistent with every other findings
// surface in the app (findings page, resource drawer, overview widgets).
const mutedFilter = searchParams.get("filter[muted]") || MUTED_FILTER.EXCLUDE;
// Aggregated mode: the detail page carries provider filters instead of a scanId,
// so scope this requirement's findings by those providers rather than one scan.
// Stable string key keeps the effect deps free of a per-render object.
const providerScopeKey = new URLSearchParams(
extractComplianceProviderFilters(searchParams),
).toString();
useEffect(() => {
async function loadFindings() {
@@ -68,10 +75,18 @@ export const ClientAccordionContent = ({
try {
const checkIds = requirement.check_ids;
const encodedSort = sort.replace(/^\+/, "");
const findingsData = await getFindings({
// Aggregated mode carries provider filters but no scan/date, which the
// /findings endpoint rejects (400). Use /findings/latest there — it
// needs neither and scopes to the latest scan per matching provider.
const isAggregated = providerScopeKey.length > 0;
const scopeFilters = isAggregated
? Object.fromEntries(new URLSearchParams(providerScopeKey))
: { "filter[scan]": scanId };
const loadFindings = isAggregated ? getLatestFindings : getFindings;
const findingsData = await loadFindings({
filters: {
"filter[check_id__in]": checkIds.join(","),
"filter[scan]": scanId,
...scopeFilters,
"filter[muted]": mutedFilter,
...(region && { "filter[region__in]": region }),
},
@@ -115,6 +130,7 @@ export const ClientAccordionContent = ({
}, [
requirement,
scanId,
providerScopeKey,
pageNumber,
pageSize,
sort,
@@ -27,4 +27,21 @@ describe("ComplianceCard", () => {
expect(source).toContain('orientation="column"');
expect(source).toContain('buttonWidth="icon"');
});
it("derives aggregated mode from the provider filters in the URL", () => {
expect(source).toContain("extractComplianceProviderFilters");
expect(source).toContain("const isAggregated =");
});
it("hides the per-scan PDF download in aggregated mode", () => {
expect(source).toContain("{!isAggregated && (");
expect(source).toContain("<ComplianceDownloadContainer");
});
it("carries provider filters (not scanId) into the drill-down when aggregated", () => {
expect(source).toContain(
"providerFilters: isAggregated ? providerFilters : undefined",
);
expect(source).toContain("scanId: isAggregated ? null : scanId");
});
});
+36 -27
View File
@@ -11,6 +11,7 @@ import {
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { buildComplianceDetailPath } from "@/lib/compliance/compliance-detail-url";
import { extractComplianceProviderFilters } from "@/lib/compliance/compliance-provider-filters";
import { getReportTypeForCompliance } from "@/lib/compliance/compliance-report-types";
import {
getScoreIndicatorClass,
@@ -55,6 +56,11 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
const router = useRouter();
const hasRegionFilter = searchParams.has("filter[region__in]");
// Aggregated mode: provider filters replace the single-scan scope, so per-scan
// affordances (CIS PDF) are hidden and the drill-down carries provider filters.
const providerFilters = extractComplianceProviderFilters(searchParams);
const isAggregated = Object.keys(providerFilters).length > 0;
const formatTitle = (title: string) => {
return title.split("-").join(" ");
};
@@ -75,8 +81,9 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
title,
complianceId: id,
version,
scanId,
scanId: isAggregated ? null : scanId,
regionFilter: searchParams.get("filter[region__in]"),
providerFilters: isAggregated ? providerFilters : undefined,
}),
);
};
@@ -88,32 +95,34 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
className="relative cursor-pointer transition-shadow hover:shadow-md"
onClick={navigateToDetail}
>
<div
className="absolute top-2 right-2 z-10"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
}
}}
role="group"
tabIndex={0}
>
<ComplianceDownloadContainer
compact
orientation="column"
buttonWidth="icon"
presentation="dropdown"
scanId={scanId}
complianceId={complianceId}
reportType={getReportTypeForCompliance(
title,
complianceId,
isLatestCisForProvider,
)}
disabled={hasRegionFilter}
/>
</div>
{!isAggregated && (
<div
className="absolute top-2 right-2 z-10"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
}
}}
role="group"
tabIndex={0}
>
<ComplianceDownloadContainer
compact
orientation="column"
buttonWidth="icon"
presentation="dropdown"
scanId={scanId}
complianceId={complianceId}
reportType={getReportTypeForCompliance(
title,
complianceId,
isLatestCisForProvider,
)}
disabled={hasRegionFilter}
/>
</div>
)}
<CardContent className="p-0">
<div className="flex w-full flex-col gap-3">
<div className="flex items-center gap-3 pr-9">
@@ -0,0 +1,148 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ComplianceFilters } from "./compliance-filters";
const { pushMock, updateFilterMock } = vi.hoisted(() => ({
pushMock: vi.fn(),
updateFilterMock: vi.fn(),
}));
let currentSearchParams = new URLSearchParams();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock }),
useSearchParams: () => currentSearchParams,
}));
vi.mock("@/hooks/use-url-filters", () => ({
useUrlFilters: () => ({ updateFilter: updateFilterMock }),
}));
vi.mock("./scan-selector", () => ({
ScanSelector: ({
onSelectionChange,
}: {
onSelectionChange: (key: string) => void;
}) => (
<button
data-testid="scan-selector"
onClick={() => onSelectionChange("scan-2")}
/>
),
}));
vi.mock("@/components/filters/provider-account-selectors", () => ({
ProviderAccountSelectors: ({
paramsToDeleteOnChange,
}: {
paramsToDeleteOnChange?: string[];
}) => (
<div
data-testid="provider-account-selectors"
data-params={(paramsToDeleteOnChange ?? []).join(",")}
/>
),
}));
vi.mock("@/components/filters/provider-group-selector", () => ({
ProviderGroupSelector: ({
paramsToDeleteOnChange,
}: {
paramsToDeleteOnChange?: string[];
}) => (
<div
data-testid="provider-group-selector"
data-params={(paramsToDeleteOnChange ?? []).join(",")}
/>
),
}));
vi.mock("@/components/filters/clear-filters-button", () => ({
ClearFiltersButton: () => <div data-testid="clear-filters" />,
}));
vi.mock("@/components/shadcn/select/multiselect", () => ({
MultiSelect: ({ children }: { children: React.ReactNode }) => (
<div data-testid="region-multiselect">{children}</div>
),
MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
<span>{placeholder}</span>
),
MultiSelectContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
MultiSelectItem: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
MultiSelectSelectAll: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
MultiSelectSeparator: () => <hr />,
}));
const defaultProps = {
scans: [],
uniqueRegions: ["eu-west-1"],
selectedScanId: "scan-1",
providers: [],
providerGroups: [],
};
beforeEach(() => {
vi.clearAllMocks();
currentSearchParams = new URLSearchParams();
});
describe("ComplianceFilters", () => {
it("renders the scan, provider type/account, provider group and region selectors", () => {
render(<ComplianceFilters {...defaultProps} />);
expect(screen.getByTestId("scan-selector")).toBeInTheDocument();
expect(
screen.getByTestId("provider-account-selectors"),
).toBeInTheDocument();
expect(screen.getByTestId("provider-group-selector")).toBeInTheDocument();
expect(screen.getByTestId("region-multiselect")).toBeInTheDocument();
});
it("wires the provider selectors to clear scanId + page on change (reverse XOR)", () => {
render(<ComplianceFilters {...defaultProps} />);
for (const testId of [
"provider-account-selectors",
"provider-group-selector",
]) {
const params = screen.getByTestId(testId).getAttribute("data-params");
expect(params).toContain("scanId");
expect(params).toContain("page");
}
});
it("clears provider-scope filters and page when a scan is selected", () => {
currentSearchParams = new URLSearchParams(
"scanId=scan-1&filter[provider_type__in]=aws&filter[provider_id__in]=p1&filter[provider_groups__in]=g1&filter[region__in]=eu-west-1&page=2",
);
render(<ComplianceFilters {...defaultProps} />);
fireEvent.click(screen.getByTestId("scan-selector"));
expect(pushMock).toHaveBeenCalledTimes(1);
const pushedUrl = new URL(
pushMock.mock.calls[0][0] as string,
"https://example.com",
);
const params = pushedUrl.searchParams;
expect(params.get("scanId")).toBe("scan-2");
expect(params.get("filter[provider_type__in]")).toBeNull();
expect(params.get("filter[provider_id__in]")).toBeNull();
expect(params.get("filter[provider_groups__in]")).toBeNull();
expect(params.get("page")).toBeNull();
// region is independent of the scan/provider XOR and must survive
expect(params.get("filter[region__in]")).toBe("eu-west-1");
});
});
@@ -3,6 +3,8 @@
import { useRouter, useSearchParams } from "next/navigation";
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
import {
MultiSelect,
MultiSelectContent,
@@ -13,27 +15,43 @@ import {
MultiSelectValue,
} from "@/components/shadcn/select/multiselect";
import { useUrlFilters } from "@/hooks/use-url-filters";
import { COMPLIANCE_PROVIDER_FILTER_KEYS } from "@/lib/compliance/compliance-provider-filters";
import type { ProviderGroup } from "@/types/components";
import type { ProviderProps } from "@/types/providers";
import { ScanSelector, SelectScanComplianceDataProps } from "./scan-selector";
// Clearing scanId/page is the inverse of the provider selectors'
// paramsToDeleteOnChange — together they enforce the backend scan_id ⊕ provider XOR.
const PROVIDER_PARAMS_TO_DELETE = ["scanId", "page"];
const SELECTOR_WIDTH = "w-full sm:max-w-[280px] sm:min-w-[200px] sm:flex-1";
interface ComplianceFiltersProps {
scans: SelectScanComplianceDataProps["scans"];
uniqueRegions: string[];
selectedScanId: string;
/** Null in aggregated mode (provider filters drive the scope, no single scan). */
selectedScanId: string | null;
providers: ProviderProps[];
providerGroups: ProviderGroup[];
}
export const ComplianceFilters = ({
scans,
uniqueRegions,
selectedScanId,
providers,
providerGroups,
}: ComplianceFiltersProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const { updateFilter } = useUrlFilters();
// XOR: choosing a single scan clears any active provider-scope filters.
const handleScanChange = (selectedKey: string) => {
const params = new URLSearchParams(searchParams);
params.set("scanId", selectedKey);
COMPLIANCE_PROVIDER_FILTER_KEYS.forEach((key) => params.delete(key));
params.delete("page");
router.push(`?${params.toString()}`, { scroll: false });
};
@@ -41,16 +59,31 @@ export const ComplianceFilters = ({
searchParams.get("filter[region__in]")?.split(",").filter(Boolean) ?? [];
return (
<div className="flex max-w-4xl flex-wrap items-center gap-4">
<div className="flex flex-wrap items-center gap-4">
<div className="w-full sm:max-w-[380px] sm:min-w-[200px] sm:flex-1">
<ScanSelector
scans={scans}
selectedScanId={selectedScanId}
selectedScanId={selectedScanId ?? ""}
onSelectionChange={handleScanChange}
/>
</div>
{/* Provider-scope filters: selecting any switches to aggregated mode and clears scanId. */}
<ProviderAccountSelectors
providers={providers}
accountFilterKey="provider_id__in"
accountValue="id"
paramsToDeleteOnChange={PROVIDER_PARAMS_TO_DELETE}
providerSelectorClassName={SELECTOR_WIDTH}
accountSelectorClassName={SELECTOR_WIDTH}
/>
<div className={SELECTOR_WIDTH}>
<ProviderGroupSelector
groups={providerGroups}
paramsToDeleteOnChange={PROVIDER_PARAMS_TO_DELETE}
/>
</div>
{uniqueRegions.length > 0 && (
<div className="w-full sm:max-w-[280px] sm:min-w-[200px] sm:flex-1">
<div className={SELECTOR_WIDTH}>
<MultiSelect
values={regionValues}
onValuesChange={(values) => updateFilter("region__in", values)}
@@ -7,6 +7,7 @@ import { ComplianceCard } from "@/components/compliance/compliance-card";
import { OnboardingTrigger, PageReady } from "@/components/onboarding";
import { DataTableSearch } from "@/components/ui/table/data-table-search";
import { buildComplianceDetailPath } from "@/lib/compliance/compliance-detail-url";
import { extractComplianceProviderFilters } from "@/lib/compliance/compliance-provider-filters";
import { getFlowById } from "@/lib/onboarding";
import { createViewComplianceTourStepHandlers } from "@/lib/tours/view-compliance.tour";
import type { ComplianceOverviewData } from "@/types/compliance";
@@ -43,6 +44,10 @@ export const ComplianceOverviewGrid = ({
const searchParams = useSearchParams();
const [searchTerm, setSearchTerm] = useState("");
// Aggregated mode: provider filters in the URL replace the single-scan scope.
const providerFilters = extractComplianceProviderFilters(searchParams);
const isAggregated = Object.keys(providerFilters).length > 0;
const filteredFrameworks = frameworks.filter((compliance) =>
compliance.attributes.framework
.toLowerCase()
@@ -62,8 +67,9 @@ export const ComplianceOverviewGrid = ({
title: first.attributes.framework,
complianceId: first.id,
version: first.attributes.version,
scanId,
scanId: isAggregated ? null : scanId,
regionFilter: searchParams.get("filter[region__in]"),
providerFilters: isAggregated ? providerFilters : undefined,
}),
);
};
+4 -4
View File
@@ -1,5 +1,5 @@
import { CONNECTION_STATUS_MAPPING } from "@/lib/helper-filters";
import { FilterOption, FilterType } from "@/types/filters";
import { FILTER_FIELD, FilterOption } from "@/types/filters";
import {
PROVIDER_DISPLAY_NAMES,
PROVIDER_TYPES,
@@ -64,19 +64,19 @@ export const filterScans = [
//Static filters for findings
export const filterFindings = [
{
key: FilterType.SEVERITY,
key: FILTER_FIELD.SEVERITY,
labelCheckboxGroup: "Severity",
values: ["critical", "high", "medium", "low", "informational"],
index: 0,
},
{
key: FilterType.STATUS,
key: FILTER_FIELD.STATUS,
labelCheckboxGroup: "Status",
values: ["PASS", "FAIL", "MANUAL"],
index: 1,
},
{
key: FilterType.DELTA,
key: FILTER_FIELD.DELTA,
labelCheckboxGroup: "Delta",
values: ["new", "changed"],
index: 2,
@@ -0,0 +1,218 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ProviderGroup } from "@/types/components";
import { ProviderGroupSelector } from "./provider-group-selector";
const multiSelectContentSpy = vi.fn();
const { navigateWithParamsMock } = vi.hoisted(() => ({
navigateWithParamsMock: vi.fn(),
}));
let currentSearchParams = new URLSearchParams();
vi.mock("next/navigation", () => ({
useSearchParams: () => currentSearchParams,
}));
vi.mock("@/hooks/use-url-filters", () => ({
useUrlFilters: () => ({
navigateWithParams: navigateWithParamsMock,
}),
}));
vi.mock("@/components/shadcn/select/multiselect", () => ({
MultiSelect: ({
children,
onValuesChange,
}: {
children: React.ReactNode;
onValuesChange: (values: string[]) => void;
}) => (
<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("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,173 @@
"use client";
import { useSearchParams } from "next/navigation";
import {
MultiSelect,
MultiSelectContent,
MultiSelectItem,
type MultiSelectSearchProp,
MultiSelectTrigger,
MultiSelectValue,
} from "@/components/shadcn/select/multiselect";
import { useUrlFilters } from "@/hooks/use-url-filters";
import type { ProviderGroup } from "@/types/components";
const PROVIDER_GROUP_FILTER_KEY = "provider_groups__in";
const URL_FILTER_KEY = `filter[${PROVIDER_GROUP_FILTER_KEY}]`;
/** Common props shared by both batch and instant modes. */
interface ProviderGroupSelectorBaseProps {
groups: ProviderGroup[];
search?: MultiSelectSearchProp;
/**
* Instant mode only: extra URL params to delete when the selection changes
* (e.g. ["page", "scanId"]), mirroring ProviderAccountSelectors. Ignored in
* batch mode, where the parent owns URL updates.
*/
paramsToDeleteOnChange?: string[];
}
/** Batch mode: caller controls both pending state and notification callback (all-or-nothing). */
interface ProviderGroupSelectorBatchProps
extends ProviderGroupSelectorBaseProps {
/**
* Called instead of navigating immediately.
* Use this on pages that batch filter changes (e.g. Findings).
*
* @param filterKey - The raw filter key without "filter[]" wrapper, e.g. "provider_groups__in"
* @param values - The selected values array
*/
onBatchChange: (filterKey: string, values: string[]) => void;
/**
* Pending selected values controlled by the parent.
* Reflects pending state before Apply is clicked.
*/
selectedValues: string[];
}
/** Instant mode: URL-driven — neither callback nor controlled value. */
interface ProviderGroupSelectorInstantProps
extends ProviderGroupSelectorBaseProps {
onBatchChange?: never;
selectedValues?: never;
}
type ProviderGroupSelectorProps =
| ProviderGroupSelectorBatchProps
| ProviderGroupSelectorInstantProps;
export function ProviderGroupSelector({
groups,
onBatchChange,
selectedValues,
search = {
placeholder: "Search Provider Groups...",
emptyMessage: "No Provider Groups found.",
},
paramsToDeleteOnChange = [],
}: ProviderGroupSelectorProps) {
const searchParams = useSearchParams();
const { navigateWithParams } = useUrlFilters();
const current = searchParams.get(URL_FILTER_KEY) || "";
const urlSelectedIds = current ? current.split(",").filter(Boolean) : [];
// In batch mode, use the parent-controlled pending values; otherwise, use URL state.
const selectedIds = onBatchChange ? selectedValues : urlSelectedIds;
const handleMultiValueChange = (ids: string[]) => {
if (onBatchChange) {
onBatchChange(PROVIDER_GROUP_FILTER_KEY, ids);
return;
}
navigateWithParams((params) => {
if (ids.length > 0) {
params.set(URL_FILTER_KEY, ids.join(","));
} else {
params.delete(URL_FILTER_KEY);
}
paramsToDeleteOnChange.forEach((key) => params.delete(key));
});
};
const selectedLabel = () => {
if (selectedIds.length === 0) return null;
if (selectedIds.length === 1) {
const group = groups.find((g) => g.id === selectedIds[0]);
return (
<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="provider-group-selector"
className="sr-only"
id="provider-group-label"
>
Filter by Provider Group. Select one or more Provider Groups to filter
results.
</label>
<MultiSelect values={selectedIds} onValuesChange={handleMultiValueChange}>
<MultiSelectTrigger
id="provider-group-selector"
aria-labelledby="provider-group-label"
>
{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>
);
}
+39 -18
View File
@@ -14,12 +14,14 @@ import {
FilterSummaryStrip,
} from "@/components/filters/filter-summary-strip";
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
import { Button } from "@/components/shadcn";
import { ExpandableSection } from "@/components/ui/expandable-section";
import { DataTableFilterCustom } from "@/components/ui/table/data-table-filter-custom";
import { useFilterBatch } from "@/hooks/use-filter-batch";
import { getCategoryLabel, getGroupLabel } from "@/lib/categories";
import { FilterType, ScanEntity } from "@/types";
import { FILTER_FIELD, ScanEntity } from "@/types";
import { ProviderGroup } from "@/types/components";
import { DATA_TABLE_FILTER_MODE } from "@/types/filters";
import { ProviderProps } from "@/types/providers";
@@ -31,6 +33,8 @@ import {
interface FindingsFiltersProps {
/** Provider data for provider/account filter controls. */
providers: ProviderProps[];
/** Provider groups for the provider group filter control. */
providerGroups?: ProviderGroup[];
completedScanIds: string[];
scanDetails: { [key: string]: ScanEntity }[];
uniqueRegions: string[];
@@ -70,6 +74,10 @@ const FILTER_GRID_ITEM_CLASS = "min-w-0";
export const FindingsFilterBatchControls = ({
providers,
// Undefined = caller opted out (the alert editor shares this component but
// loads no groups); an empty array still renders the control, so it stays
// visible even when a tenant has no groups yet.
providerGroups,
completedScanIds,
scanDetails,
uniqueRegions,
@@ -97,7 +105,7 @@ export const FindingsFilterBatchControls = ({
const customFilters = [
...filterFindings
.filter((filter) => !isAlertsEdit || filter.key !== FilterType.STATUS)
.filter((filter) => !isAlertsEdit || filter.key !== FILTER_FIELD.STATUS)
.map((filter) => ({
...filter,
labelFormatter: (value: string) =>
@@ -107,32 +115,32 @@ export const FindingsFilterBatchControls = ({
}),
})),
{
key: FilterType.REGION,
key: FILTER_FIELD.REGION,
labelCheckboxGroup: "Regions",
values: uniqueRegions,
index: 3,
},
{
key: FilterType.SERVICE,
key: FILTER_FIELD.SERVICE,
labelCheckboxGroup: "Services",
values: uniqueServices,
index: 4,
},
{
key: FilterType.RESOURCE_TYPE,
key: FILTER_FIELD.RESOURCE_TYPE,
labelCheckboxGroup: "Resource Type",
values: uniqueResourceTypes,
index: 8,
},
{
key: FilterType.CATEGORY,
key: FILTER_FIELD.CATEGORY,
labelCheckboxGroup: "Category",
values: uniqueCategories,
labelFormatter: getCategoryLabel,
index: 5,
},
{
key: FilterType.RESOURCE_GROUPS,
key: FILTER_FIELD.RESOURCE_GROUPS,
labelCheckboxGroup: "Resource Group",
values: uniqueGroups,
labelFormatter: getGroupLabel,
@@ -142,14 +150,14 @@ export const FindingsFilterBatchControls = ({
? []
: [
{
key: FilterType.SCAN,
key: FILTER_FIELD.SCAN,
labelCheckboxGroup: "Scan ID",
values: completedScanIds,
width: "wide" as const,
valueLabelMapping: scanDetails,
labelFormatter: (value: string) =>
getFindingsFilterDisplayValue(
`filter[${FilterType.SCAN}]`,
`filter[${FILTER_FIELD.SCAN}]`,
value,
{
providers,
@@ -167,6 +175,7 @@ export const FindingsFilterBatchControls = ({
appliedFilters,
{
providers,
providerGroups,
scans: scanDetails,
},
);
@@ -174,6 +183,7 @@ export const FindingsFilterBatchControls = ({
changedFilters,
{
providers,
providerGroups,
scans: scanDetails,
},
);
@@ -199,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>
+11
View File
@@ -1,6 +1,7 @@
"use client";
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
import {
Select,
SelectContent,
@@ -9,6 +10,7 @@ import {
SelectValue,
} from "@/components/shadcn";
import { SCAN_JOBS_TAB, type ScanJobsTab } from "@/types";
import type { ProviderGroup } from "@/types/components";
import type { ProviderProps } from "@/types/providers";
import {
@@ -18,6 +20,7 @@ import {
interface ScansFilterBarProps {
providers: ProviderProps[];
providerGroups?: ProviderGroup[];
activeTab: ScanJobsTab;
scheduleType: string;
scanStatus: string;
@@ -30,6 +33,7 @@ const filterItemClass = "w-full md:w-[calc(50%-0.375rem)] xl:w-60";
export function ScansFilterBar({
providers,
providerGroups = [],
activeTab,
scheduleType,
scanStatus,
@@ -53,6 +57,13 @@ export function ScansFilterBar({
accountSelectorClassName={filterItemClass}
/>
<div className={filterItemClass}>
<ProviderGroupSelector
groups={providerGroups}
paramsToDeleteOnChange={["page", "scanId"]}
/>
</div>
{showScheduleTypeFilter && (
<Select value={scheduleType} onValueChange={onScheduleTypeChange}>
<SelectTrigger aria-label="All Types" className={filterItemClass}>
+4
View File
@@ -20,6 +20,7 @@ import {
import { buildViewFirstScanTour } from "@/lib/tours/view-first-scan.tour";
import { useScansStore } from "@/store";
import { SCAN_JOBS_TAB, SCAN_TAB_LABELS, type ScanJobsTab } from "@/types";
import type { ProviderGroup } from "@/types/components";
import type { ProviderProps } from "@/types/providers";
import type { ScanScheduleCapability } from "@/types/schedules";
@@ -32,6 +33,7 @@ import { useScansFilters } from "./use-scans-filters";
interface ScansPageShellProps {
providers: ProviderProps[];
providerGroups?: ProviderGroup[];
hasManageScansPermission: boolean;
activeScanCount?: number;
children: ReactNode;
@@ -42,6 +44,7 @@ interface ScansPageShellProps {
export function ScansPageShell({
providers,
providerGroups = [],
hasManageScansPermission,
activeScanCount = 0,
children,
@@ -116,6 +119,7 @@ export function ScansPageShell({
>
<ScansFilterBar
providers={providers}
providerGroups={providerGroups}
activeTab={filters.activeTab}
scheduleType={filters.scheduleType}
scanStatus={filters.scanStatus}
+4 -4
View File
@@ -62,22 +62,22 @@ describe("useFilterBatch", () => {
expect(result.current.hasChanges).toBe(false);
});
it("should expose filter[delta]=new under the FilterType.DELTA key so the dropdown shows it selected", async () => {
it("should expose filter[delta]=new under the FILTER_FIELD.DELTA key so the dropdown shows it selected", async () => {
// Given — URL from LinkToFindings uses `filter[delta]` (singular), matching the API.
setSearchParams({
"filter[status__in]": "FAIL",
"filter[delta]": "new",
});
const { FilterType } = await import("@/types/filters");
const { FILTER_FIELD } = await import("@/types/filters");
// When
const { result } = renderHook(() => useFilterBatch());
// Then — the Delta dropdown reads via getFilterValue(`filter[${FilterType.DELTA}]`).
// Then — the Delta dropdown reads via getFilterValue(`filter[${FILTER_FIELD.DELTA}]`).
// For the checkbox of "new" to appear checked, that lookup must return ["new"].
expect(
result.current.getFilterValue(`filter[${FilterType.DELTA}]`),
result.current.getFilterValue(`filter[${FILTER_FIELD.DELTA}]`),
).toEqual(["new"]);
});
+10 -5
View File
@@ -2,8 +2,9 @@ import { useSearchParams } from "next/navigation";
import { isScanEntity } from "@/lib/helper-filters";
import {
FILTER_FIELD,
FilterEntity,
FilterType,
FilterParam,
ProviderEntity,
ProviderType,
ScanEntity,
@@ -16,7 +17,9 @@ interface UseRelatedFiltersProps {
completedScanIds?: string[];
scanDetails?: { [key: string]: ScanEntity }[];
enableScanRelation?: boolean;
providerFilterType?: FilterType.PROVIDER | FilterType.PROVIDER_UID;
providerFilterType?:
| typeof FILTER_FIELD.PROVIDER
| typeof FILTER_FIELD.PROVIDER_UID;
}
/**
@@ -38,15 +41,17 @@ export const useRelatedFilters = ({
completedScanIds = [],
scanDetails = [],
enableScanRelation = false,
providerFilterType = FilterType.PROVIDER,
providerFilterType = FILTER_FIELD.PROVIDER,
}: UseRelatedFiltersProps) => {
const searchParams = useSearchParams();
const providers = providerIds.length > 0 ? providerIds : providerUIDs;
const providerParam = searchParams.get(`filter[${providerFilterType}]`);
const providerParam = searchParams.get(
`filter[${providerFilterType}]` satisfies FilterParam,
);
const providerTypeParam = searchParams.get(
`filter[${FilterType.PROVIDER_TYPE}]`,
`filter[${FILTER_FIELD.PROVIDER_TYPE}]` satisfies FilterParam,
);
const currentProviders = providerParam ? providerParam.split(",") : [];
@@ -35,4 +35,45 @@ describe("buildComplianceDetailPath", () => {
expect(withRegion).toContain("filter%5Bregion__in%5D=eu-west-1");
expect(withoutRegion).not.toContain("region__in");
});
it("carries provider filters instead of scanId in aggregated mode", () => {
const path = buildComplianceDetailPath({
title: "ens",
complianceId: "ens_rd2022_aws",
version: "RD2022",
providerFilters: {
"filter[provider_type__in]": "aws",
"filter[provider_groups__in]": "g1,g2",
},
});
expect(path).toContain("filter%5Bprovider_type__in%5D=aws");
expect(path).toContain("filter%5Bprovider_groups__in%5D=g1%2Cg2");
expect(path).not.toContain("scanId");
});
it("omits scanId when provider filters are present (XOR)", () => {
const path = buildComplianceDetailPath({
title: "ens",
complianceId: "ens_rd2022_aws",
version: "RD2022",
scanId: "scan-1",
providerFilters: { "filter[provider_type__in]": "aws" },
});
expect(path).not.toContain("scanId");
expect(path).toContain("filter%5Bprovider_type__in%5D=aws");
});
it("propagates region alongside provider filters", () => {
const path = buildComplianceDetailPath({
title: "ens",
complianceId: "ens_rd2022_aws",
version: "RD2022",
providerFilters: { "filter[provider_type__in]": "aws" },
regionFilter: "eu-west-1",
});
expect(path).toContain("filter%5Bregion__in%5D=eu-west-1");
});
});
+24 -2
View File
@@ -1,10 +1,18 @@
import type { ComplianceProviderFilters } from "./compliance-provider-filters";
interface ComplianceDetailPathParams {
/** Framework title as shown on the card (URL-encoded into the path). */
title: string;
complianceId: string;
version: string;
scanId: string;
/** Single-scan scope. Omitted when provider filters drive aggregated mode. */
scanId?: string | null;
regionFilter?: string | null;
/**
* Aggregated-mode scope: provider-filter keys (e.g. filter[provider_type__in])
* carried instead of scanId. XOR with scanId — when present, scanId is dropped.
*/
providerFilters?: ComplianceProviderFilters;
}
/** Builds the `/compliance/[compliancetitle]` detail URL used by the overview cards. */
@@ -14,11 +22,25 @@ export function buildComplianceDetailPath({
version,
scanId,
regionFilter,
providerFilters,
}: ComplianceDetailPathParams): string {
const params = new URLSearchParams();
params.set("complianceId", complianceId);
params.set("version", version);
params.set("scanId", scanId);
const providerEntries = providerFilters
? Object.entries(providerFilters).filter(
(entry): entry is [string, string] => Boolean(entry[1]),
)
: [];
if (providerEntries.length > 0) {
for (const [key, value] of providerEntries) {
params.set(key, value);
}
} else if (scanId) {
params.set("scanId", scanId);
}
if (regionFilter) {
params.set("filter[region__in]", regionFilter);
}
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import {
COMPLIANCE_PROVIDER_FILTER_KEYS,
extractComplianceProviderFilters,
hasComplianceProviderFilters,
} from "./compliance-provider-filters";
describe("COMPLIANCE_PROVIDER_FILTER_KEYS", () => {
it("contains the three provider scope keys", () => {
expect(COMPLIANCE_PROVIDER_FILTER_KEYS).toEqual([
"filter[provider_type__in]",
"filter[provider_id__in]",
"filter[provider_groups__in]",
]);
});
});
describe("hasComplianceProviderFilters", () => {
for (const key of COMPLIANCE_PROVIDER_FILTER_KEYS) {
it(`is true when ${key} is present (plain object)`, () => {
expect(hasComplianceProviderFilters({ [key]: "abc" })).toBe(true);
});
}
it("is true when reading from URLSearchParams", () => {
const params = new URLSearchParams();
params.set("filter[provider_groups__in]", "g1,g2");
expect(hasComplianceProviderFilters(params)).toBe(true);
});
it("is false for scanId / region / unrelated params", () => {
expect(
hasComplianceProviderFilters({
scanId: "scan-1",
"filter[region__in]": "eu-west-1",
}),
).toBe(false);
});
it("treats empty / whitespace values as absent", () => {
expect(
hasComplianceProviderFilters({
"filter[provider_type__in]": "",
"filter[provider_id__in]": " ",
}),
).toBe(false);
});
it("is false for an empty object", () => {
expect(hasComplianceProviderFilters({})).toBe(false);
});
});
describe("extractComplianceProviderFilters", () => {
it("returns only the present, non-empty provider keys", () => {
expect(
extractComplianceProviderFilters({
"filter[provider_type__in]": "aws,gcp",
"filter[provider_id__in]": "",
scanId: "scan-1",
"filter[region__in]": "eu-west-1",
}),
).toEqual({ "filter[provider_type__in]": "aws,gcp" });
});
it("joins array values with commas", () => {
expect(
extractComplianceProviderFilters({
"filter[provider_groups__in]": ["g1", "g2"],
}),
).toEqual({ "filter[provider_groups__in]": "g1,g2" });
});
it("reads from URLSearchParams", () => {
const params = new URLSearchParams();
params.set("filter[provider_id__in]", "p1,p2");
expect(extractComplianceProviderFilters(params)).toEqual({
"filter[provider_id__in]": "p1,p2",
});
});
it("returns an empty object when no provider filters are present", () => {
expect(extractComplianceProviderFilters({ scanId: "scan-1" })).toEqual({});
});
});
@@ -0,0 +1,86 @@
import type { ReadonlyURLSearchParams } from "next/navigation";
import type { SearchParamsProps } from "@/types/components";
import { FILTER_FIELD, FilterParam } from "@/types/filters";
/**
* Provider-scope filter fields the compliance UI sets — a subset of the shared
* `FILTER_FIELD` source of truth, the same three the overview dashboard uses.
*/
const COMPLIANCE_PROVIDER_FILTER_FIELD = {
PROVIDER_TYPE: FILTER_FIELD.PROVIDER_TYPE,
PROVIDER_ID: FILTER_FIELD.PROVIDER_ID,
PROVIDER_GROUPS: FILTER_FIELD.PROVIDER_GROUPS,
} as const;
type ComplianceProviderFilterField =
(typeof COMPLIANCE_PROVIDER_FILTER_FIELD)[keyof typeof COMPLIANCE_PROVIDER_FILTER_FIELD];
/**
* Provider-scope filter param keys (e.g. `filter[provider_type__in]`). The
* backend (`ComplianceOverviewViewSet`) treats these as an alternative to
* `filter[scan_id]` (XOR) and aggregates compliance across the latest completed
* scan of each matching provider.
*/
export type ComplianceProviderFilterParam =
FilterParam<ComplianceProviderFilterField>;
/** Present, CSV-joined provider-scope filters (aggregated mode). */
export type ComplianceProviderFilters = Partial<
Record<ComplianceProviderFilterParam, string>
>;
/**
* Filters the compliance server actions accept: the provider-scope keys above
* (aggregated mode) or `filter[scan_id]` (single-scan mode) — XOR.
*/
export type ComplianceFilters = Partial<
Record<ComplianceProviderFilterParam | FilterParam<"scan_id">, string>
>;
export const COMPLIANCE_PROVIDER_FILTER_KEYS = [
`filter[${COMPLIANCE_PROVIDER_FILTER_FIELD.PROVIDER_TYPE}]`,
`filter[${COMPLIANCE_PROVIDER_FILTER_FIELD.PROVIDER_ID}]`,
`filter[${COMPLIANCE_PROVIDER_FILTER_FIELD.PROVIDER_GROUPS}]`,
] as const satisfies ReadonlyArray<ComplianceProviderFilterParam>;
/**
* Accepts either an SSR plain search-params object or the client
* `useSearchParams()` result (`ReadonlyURLSearchParams`), so callers don't need
* to wrap the latter in a fresh `URLSearchParams`.
*/
type SearchParamsLike =
| SearchParamsProps
| URLSearchParams
| ReadonlyURLSearchParams;
const readParam = (params: SearchParamsLike, key: string): string => {
if (params instanceof URLSearchParams) {
return params.get(key) ?? "";
}
const value = params[key];
if (Array.isArray(value)) return value.join(",");
return value ?? "";
};
/** True when any compliance provider-scope filter is present and non-empty. */
export const hasComplianceProviderFilters = (
params: SearchParamsLike,
): boolean =>
COMPLIANCE_PROVIDER_FILTER_KEYS.some(
(key) => readParam(params, key).trim().length > 0,
);
/** Returns only the present, non-empty provider-scope filters (CSV-joined). */
export const extractComplianceProviderFilters = (
params: SearchParamsLike,
): ComplianceProviderFilters => {
const result: ComplianceProviderFilters = {};
for (const key of COMPLIANCE_PROVIDER_FILTER_KEYS) {
const value = readParam(params, key).trim();
if (value.length > 0) {
result[key] = value;
}
}
return result;
};
+30
View File
@@ -1,14 +1,23 @@
import { describe, expect, it } from "vitest";
import type { ProviderGroup } from "@/types/components";
import type { ScanEntity } from "@/types/scans";
import {
getProviderGroupDisplayValue,
getScanEntityLabel,
hasDateFilter,
hasDateOrScanFilter,
hasHistoricalFindingFilter,
} from "./helper-filters";
const makeProviderGroup = (id: string, name: string): ProviderGroup =>
({
type: "provider-groups",
id,
attributes: { name, inserted_at: "", updated_at: "" },
}) as ProviderGroup;
function makeScan(overrides: Partial<ScanEntity> = {}): ScanEntity {
return {
id: "scan-1",
@@ -25,6 +34,27 @@ function makeScan(overrides: Partial<ScanEntity> = {}): ScanEntity {
};
}
describe("getProviderGroupDisplayValue", () => {
const groups = [
makeProviderGroup("g1", "Production"),
makeProviderGroup("g2", "Staging"),
];
it("resolves the group name when the id matches", () => {
expect(getProviderGroupDisplayValue("g1", groups)).toBe("Production");
});
it("falls back to the raw id when the group is not found", () => {
expect(getProviderGroupDisplayValue("unknown", groups)).toBe("unknown");
});
it("falls back to the raw id when the group name is empty", () => {
expect(
getProviderGroupDisplayValue("g3", [makeProviderGroup("g3", "")]),
).toBe("g3");
});
});
describe("hasDateOrScanFilter", () => {
it("returns true for scan filters", () => {
expect(hasDateOrScanFilter({ "filter[scan__in]": "scan-1" })).toBe(true);
+14
View File
@@ -1,4 +1,5 @@
import { ProviderProps, ProvidersApiResponse, ScanProps } from "@/types";
import { ProviderGroup } from "@/types/components";
import { FilterEntity } from "@/types/filters";
import {
getProviderDisplayName,
@@ -119,6 +120,19 @@ export function getScanEntityLabel(scan: ScanEntity): string {
return providerLabel || scanName;
}
/**
* Resolves the display name for a provider group filter value, falling back to
* the raw id when the group can't be resolved. Shared by the findings and
* resources filter utils so their chips stay in sync.
*/
export function getProviderGroupDisplayValue(
groupId: string,
groups: ProviderGroup[],
): string {
const group = groups.find((item) => item.id === groupId);
return group?.attributes.name || groupId;
}
/**
* Creates a scan details mapping for filters from completed scans.
* Used to provide detailed information for scan filters in the UI.
+32 -35
View File
@@ -29,22 +29,35 @@ export interface CustomDropdownFilterProps {
onFilterChange: (key: string, values: string[]) => void;
}
export enum FilterType {
SCAN = "scan__in",
PROVIDER = "provider__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];
/**
* Controls the filter dispatch behavior of DataTableFilterCustom.
@@ -60,25 +73,9 @@ export type DataTableFilterMode =
(typeof DATA_TABLE_FILTER_MODE)[keyof typeof DATA_TABLE_FILTER_MODE];
/**
* Exhaustive union of all URL filter param keys used in Findings filters.
* Use this instead of `string` to ensure FILTER_KEY_LABELS and other
* param-keyed records stay in sync with the actual filter surface.
* URL filter param key template — wraps a field name in `filter[...]`.
* Parameterize with a view's own field union (e.g. `FilterParam<FindingsFilterField>`)
* so each view's param-keyed records stay in sync with the filters it supports.
*/
export type FilterParam =
| "filter[provider_type__in]"
| "filter[provider_id__in]"
| "filter[severity__in]"
| "filter[status__in]"
| "filter[delta__in]"
| "filter[delta]"
| "filter[region__in]"
| "filter[service__in]"
| "filter[resource_type__in]"
| "filter[category__in]"
| "filter[resource_groups__in]"
| "filter[scan]"
| "filter[scan__in]"
| "filter[scan_id]"
| "filter[scan_id__in]"
| "filter[inserted_at]"
| "filter[muted]";
export type FilterParam<Field extends string = FilterField> =
`filter[${Field}]`;
+2 -1
View File
@@ -1,4 +1,4 @@
import { MetaDataProps } from "./components";
import { MetaDataProps, ProviderGroup } from "./components";
import { FilterOption } from "./filters";
import {
OrganizationResource,
@@ -83,6 +83,7 @@ export interface ProvidersAccountsViewData {
filters: FilterOption[];
metadata?: MetaDataProps;
providers: ProviderProps[];
providerGroups: ProviderGroup[];
rows: ProvidersTableRow[];
}