mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
feat(ui): filter by provider group across main views (#11659)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
2b7db88694
commit
5b9824c379
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
All notable changes to the **Prowler UI** are documented in this file.
|
All notable changes to the **Prowler UI** are documented in this file.
|
||||||
|
|
||||||
|
## [1.32.0] (Prowler UNRELEASED)
|
||||||
|
|
||||||
|
### 🚀 Added
|
||||||
|
|
||||||
|
- Filter the Overview, Findings, Resources, Scans, and Providers views by provider group [(#11659)](https://github.com/prowler-cloud/prowler/pull/11659)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.31.1] (Prowler v5.31.1)
|
## [1.31.1] (Prowler v5.31.1)
|
||||||
|
|
||||||
### 🔄 Changed
|
### 🔄 Changed
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import type { FindingsFilterParam } from "@/actions/findings/findings-filters";
|
||||||
import {
|
import {
|
||||||
apiBaseUrl,
|
apiBaseUrl,
|
||||||
composeSort,
|
composeSort,
|
||||||
@@ -15,7 +16,6 @@ import {
|
|||||||
} from "@/lib";
|
} from "@/lib";
|
||||||
import { appendSanitizedProviderFilters } from "@/lib/provider-filters";
|
import { appendSanitizedProviderFilters } from "@/lib/provider-filters";
|
||||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||||
import { FilterParam } from "@/types/filters";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps filter[search] to filter[check_title__icontains] for finding-groups.
|
* 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
|
* finding-group resources sub-endpoint. These must be stripped before
|
||||||
* calling the resources API to avoid empty results.
|
* 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[service__in]",
|
||||||
"filter[scan__in]",
|
"filter[scan__in]",
|
||||||
"filter[scan_id]",
|
"filter[scan_id]",
|
||||||
@@ -53,7 +53,7 @@ function normalizeFindingGroupResourceFilters(
|
|||||||
Object.entries(filters).filter(
|
Object.entries(filters).filter(
|
||||||
([key]) =>
|
([key]) =>
|
||||||
!FINDING_GROUP_RESOURCE_UNSUPPORTED_FILTERS.includes(
|
!FINDING_GROUP_RESOURCE_UNSUPPORTED_FILTERS.includes(
|
||||||
key as FilterParam,
|
key as FindingsFilterParam,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { FILTER_FIELD, FilterParam } from "@/types/filters";
|
||||||
|
|
||||||
|
/** Findings-only filter fields not shared with other views. */
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const FINDINGS_EXTRA_FIELD = {
|
||||||
|
DELTA_IN: "delta__in",
|
||||||
|
SCAN_EXACT: "scan",
|
||||||
|
SCAN_ID: "scan_id",
|
||||||
|
SCAN_ID_IN: "scan_id__in",
|
||||||
|
INSERTED_AT: "inserted_at",
|
||||||
|
INSERTED_AT_GTE: "inserted_at__gte",
|
||||||
|
INSERTED_AT_LTE: "inserted_at__lte",
|
||||||
|
MUTED: "muted",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type FindingsExtraField =
|
||||||
|
(typeof FINDINGS_EXTRA_FIELD)[keyof typeof FINDINGS_EXTRA_FIELD];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL filter param keys the findings view supports, e.g. `filter[severity__in]`.
|
||||||
|
* Composed from the shared fields it uses plus a few findings-only extras
|
||||||
|
* (alternate scan/date/delta forms not used by other views).
|
||||||
|
*/
|
||||||
|
export type FindingsFilterParam = FilterParam<
|
||||||
|
// findings uses provider_id, not provider_uid
|
||||||
|
| (typeof FILTER_FIELD)[
|
||||||
|
| "PROVIDER_TYPE"
|
||||||
|
| "PROVIDER_ID"
|
||||||
|
| "PROVIDER_GROUPS"
|
||||||
|
| "REGION"
|
||||||
|
| "SERVICE"
|
||||||
|
| "SEVERITY"
|
||||||
|
| "STATUS"
|
||||||
|
| "DELTA"
|
||||||
|
| "RESOURCE_TYPE"
|
||||||
|
| "CATEGORY"
|
||||||
|
| "RESOURCE_GROUPS"
|
||||||
|
| "SCAN"]
|
||||||
|
| FindingsExtraField
|
||||||
|
>;
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const {
|
||||||
|
fetchMock,
|
||||||
|
getAuthHeadersMock,
|
||||||
|
handleApiErrorMock,
|
||||||
|
handleApiResponseMock,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
fetchMock: vi.fn(),
|
||||||
|
getAuthHeadersMock: vi.fn(),
|
||||||
|
handleApiErrorMock: vi.fn(),
|
||||||
|
handleApiResponseMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/cache", () => ({
|
||||||
|
revalidatePath: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
redirect: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib", () => ({
|
||||||
|
apiBaseUrl: "https://api.example.com/api/v1",
|
||||||
|
getAuthHeaders: getAuthHeadersMock,
|
||||||
|
getErrorMessage: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/server-actions-helper", () => ({
|
||||||
|
handleApiError: handleApiErrorMock,
|
||||||
|
handleApiResponse: handleApiResponseMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { getAllProviderGroups } from "./manage-groups";
|
||||||
|
|
||||||
|
const makeGroup = (id: string, name: string) => ({
|
||||||
|
type: "provider-groups" as const,
|
||||||
|
id,
|
||||||
|
attributes: { name, inserted_at: "", updated_at: "" },
|
||||||
|
relationships: {
|
||||||
|
providers: { meta: { count: 0 }, data: [] },
|
||||||
|
roles: { meta: { count: 0 }, data: [] },
|
||||||
|
},
|
||||||
|
links: { self: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const makePage = (
|
||||||
|
data: ReturnType<typeof makeGroup>[],
|
||||||
|
page: number,
|
||||||
|
pages: number,
|
||||||
|
) => ({
|
||||||
|
links: { first: "", last: "", next: null, prev: null },
|
||||||
|
data,
|
||||||
|
meta: { pagination: { page, pages, count: data.length } },
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAllProviderGroups", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||||
|
fetchMock.mockResolvedValue(new Response(null, { status: 200 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges every page into a single response with collapsed pagination", async () => {
|
||||||
|
handleApiResponseMock
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
makePage(
|
||||||
|
[makeGroup("g1", "Group 1"), makeGroup("g2", "Group 2")],
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(makePage([makeGroup("g3", "Group 3")], 2, 2));
|
||||||
|
|
||||||
|
const result = await getAllProviderGroups();
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(result?.data.map((group) => group.id)).toEqual(["g1", "g2", "g3"]);
|
||||||
|
expect(result?.meta.pagination).toMatchObject({
|
||||||
|
page: 1,
|
||||||
|
pages: 1,
|
||||||
|
count: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops after the first page when there is only one page", async () => {
|
||||||
|
handleApiResponseMock.mockResolvedValueOnce(
|
||||||
|
makePage([makeGroup("g1", "Group 1")], 1, 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await getAllProviderGroups();
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result?.data).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when the first page has no data", async () => {
|
||||||
|
handleApiResponseMock.mockResolvedValueOnce(makePage([], 1, 1));
|
||||||
|
|
||||||
|
const result = await getAllProviderGroups();
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when the request throws", async () => {
|
||||||
|
fetchMock.mockRejectedValueOnce(new Error("network down"));
|
||||||
|
|
||||||
|
const result = await getAllProviderGroups();
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when a later page resolves to an error payload", async () => {
|
||||||
|
handleApiResponseMock
|
||||||
|
.mockResolvedValueOnce(makePage([makeGroup("g1", "Group 1")], 1, 2))
|
||||||
|
.mockResolvedValueOnce({ error: "Forbidden", status: 403 });
|
||||||
|
|
||||||
|
const result = await getAllProviderGroups();
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined instead of a truncated list when the max-page cap is hit", async () => {
|
||||||
|
// Given an API that always reports more pages than the 50-page safety cap
|
||||||
|
handleApiResponseMock.mockImplementation((response: Response) => {
|
||||||
|
void response;
|
||||||
|
return Promise.resolve(makePage([makeGroup("g", "Group")], 1, 9999));
|
||||||
|
});
|
||||||
|
|
||||||
|
// When fetching every page
|
||||||
|
const result = await getAllProviderGroups();
|
||||||
|
|
||||||
|
// Then it must not return a partial/truncated list; bail out instead
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,6 +51,87 @@ export const getProviderGroups = async ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all provider groups by iterating through every page.
|
||||||
|
* Used to populate filter dropdowns (e.g. the Provider Group selector) without
|
||||||
|
* the pagination cap that `getProviderGroups` applies for the management table.
|
||||||
|
*/
|
||||||
|
export const getAllProviderGroups = async (): Promise<
|
||||||
|
ProviderGroupsResponse | undefined
|
||||||
|
> => {
|
||||||
|
const pageSize = 100; // Larger page size to minimize API calls
|
||||||
|
const maxPages = 50; // Safety limit: 50 pages × 100 = 5000 groups max
|
||||||
|
let currentPage = 1;
|
||||||
|
const allGroups: ProviderGroupsResponse["data"] = [];
|
||||||
|
let lastResponse: ProviderGroupsResponse | undefined;
|
||||||
|
let hasMorePages = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeaders({ contentType: false });
|
||||||
|
while (hasMorePages && currentPage <= maxPages) {
|
||||||
|
const url = new URL(`${apiBaseUrl}/provider-groups`);
|
||||||
|
url.searchParams.append("page[number]", currentPage.toString());
|
||||||
|
url.searchParams.append("page[size]", pageSize.toString());
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), { headers });
|
||||||
|
const data = (await handleApiResponse(response)) as
|
||||||
|
| ProviderGroupsResponse
|
||||||
|
| { error: string; status?: number }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
// A later page resolving to an API error payload must abort rather than
|
||||||
|
// be treated as "no more pages", which would silently truncate groups.
|
||||||
|
if (data && "error" in data) {
|
||||||
|
console.error("Error fetching all provider groups:", data.error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data?.data || data.data.length === 0) {
|
||||||
|
hasMorePages = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
allGroups.push(...data.data);
|
||||||
|
lastResponse = data;
|
||||||
|
|
||||||
|
const totalPages = data.meta?.pagination?.pages || 1;
|
||||||
|
if (currentPage >= totalPages) {
|
||||||
|
hasMorePages = false;
|
||||||
|
} else {
|
||||||
|
currentPage++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasMorePages && currentPage > maxPages) {
|
||||||
|
console.error(
|
||||||
|
`Error fetching all provider groups: exceeded max page limit (${maxPages})`,
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastResponse) {
|
||||||
|
return {
|
||||||
|
...lastResponse,
|
||||||
|
data: allGroups,
|
||||||
|
meta: {
|
||||||
|
...lastResponse.meta,
|
||||||
|
pagination: {
|
||||||
|
...lastResponse.meta?.pagination,
|
||||||
|
page: 1,
|
||||||
|
pages: 1,
|
||||||
|
count: allGroups.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching all provider groups:", error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const getProviderGroupInfoById = async (providerGroupId: string) => {
|
export const getProviderGroupInfoById = async (providerGroupId: string) => {
|
||||||
const headers = await getAuthHeaders({ contentType: false });
|
const headers = await getAuthHeaders({ contentType: false });
|
||||||
const url = new URL(`${apiBaseUrl}/provider-groups/${providerGroupId}`);
|
const url = new URL(`${apiBaseUrl}/provider-groups/${providerGroupId}`);
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { FILTER_FIELD, FilterParam } from "@/types/filters";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL filter param keys the overview dashboard scopes its widgets by. Overview has
|
||||||
|
* no single action; its widgets read these keys from the URL filters.
|
||||||
|
*/
|
||||||
|
export type OverviewFilterParam = FilterParam<
|
||||||
|
(typeof FILTER_FIELD)["PROVIDER_TYPE" | "PROVIDER_ID" | "PROVIDER_GROUPS"]
|
||||||
|
>;
|
||||||
|
|
||||||
|
/** The `filter[...]` keys overview widgets read from the URL. */
|
||||||
|
export const OVERVIEW_FILTER_PARAM = {
|
||||||
|
PROVIDER_TYPE: `filter[${FILTER_FIELD.PROVIDER_TYPE}]`,
|
||||||
|
PROVIDER_ID: `filter[${FILTER_FIELD.PROVIDER_ID}]`,
|
||||||
|
PROVIDER_GROUPS: `filter[${FILTER_FIELD.PROVIDER_GROUPS}]`,
|
||||||
|
} as const satisfies Record<string, OverviewFilterParam>;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { FILTER_FIELD, FilterParam } from "@/types/filters";
|
||||||
|
import { PROVIDERS_PAGE_FILTER } from "@/types/providers-table";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL filter param keys the providers list supports, e.g. `filter[provider__in]`.
|
||||||
|
* Provider scope plus its providers-only extras (`provider__in` API param,
|
||||||
|
* `connected` status).
|
||||||
|
*/
|
||||||
|
export type ProvidersFilterParam = FilterParam<
|
||||||
|
| (typeof FILTER_FIELD)["PROVIDER_TYPE" | "PROVIDER_GROUPS" | "PROVIDER_UID"]
|
||||||
|
| (typeof PROVIDERS_PAGE_FILTER)["PROVIDER" | "STATUS"]
|
||||||
|
>;
|
||||||
|
|
||||||
|
/** `filter[...]` keys used when mapping the provider-type filter to the API param. */
|
||||||
|
export const PROVIDERS_FILTER_PARAM = {
|
||||||
|
PROVIDER: `filter[${PROVIDERS_PAGE_FILTER.PROVIDER}]`,
|
||||||
|
PROVIDER_TYPE: `filter[${PROVIDERS_PAGE_FILTER.PROVIDER_TYPE}]`,
|
||||||
|
} as const satisfies Record<string, ProvidersFilterParam>;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { FILTER_FIELD, FilterParam } from "@/types/filters";
|
||||||
|
|
||||||
|
/** Resources-only filter fields not shared with other views. */
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const RESOURCES_EXTRA_FIELD = {
|
||||||
|
TYPE: "type__in",
|
||||||
|
GROUPS: "groups__in",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type ResourcesExtraField =
|
||||||
|
(typeof RESOURCES_EXTRA_FIELD)[keyof typeof RESOURCES_EXTRA_FIELD];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL filter param keys the resources view supports, e.g. `filter[type__in]`.
|
||||||
|
* The shared core plus its resources-only dimensions (`type__in`, `groups__in`).
|
||||||
|
*/
|
||||||
|
export type ResourcesFilterParam = FilterParam<
|
||||||
|
| (typeof FILTER_FIELD)[
|
||||||
|
| "PROVIDER_TYPE"
|
||||||
|
| "PROVIDER_ID"
|
||||||
|
| "PROVIDER_GROUPS"
|
||||||
|
| "REGION"
|
||||||
|
| "SERVICE"]
|
||||||
|
| ResourcesExtraField
|
||||||
|
>;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { FILTER_FIELD, FilterParam } from "@/types/filters";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider filter fields used to match/clear synthetic pending scan rows — the
|
||||||
|
* `__in` forms (shared with real scan rows) plus the exact forms, and the
|
||||||
|
* provider-group `__in` form so pending rows honor the group filter too.
|
||||||
|
*/
|
||||||
|
export const SCANS_PROVIDER_FILTER_FIELD = {
|
||||||
|
PROVIDER_IN: FILTER_FIELD.PROVIDER,
|
||||||
|
PROVIDER: "provider",
|
||||||
|
PROVIDER_TYPE_IN: FILTER_FIELD.PROVIDER_TYPE,
|
||||||
|
PROVIDER_TYPE: "provider_type",
|
||||||
|
PROVIDER_GROUPS_IN: FILTER_FIELD.PROVIDER_GROUPS,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Scans-only filter fields not shared with other views. */
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const SCANS_EXTRA_FIELD = {
|
||||||
|
STATE: "state__in",
|
||||||
|
TRIGGER: "trigger",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type ScansExtraField =
|
||||||
|
(typeof SCANS_EXTRA_FIELD)[keyof typeof SCANS_EXTRA_FIELD];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL filter param keys the scans view supports, e.g. `filter[state__in]`.
|
||||||
|
* Provider scope (scans filters accounts by provider id) including provider
|
||||||
|
* groups and the exact pending-row provider forms, plus the scans-only dimensions.
|
||||||
|
*/
|
||||||
|
export type ScansFilterParam = FilterParam<
|
||||||
|
| (typeof SCANS_PROVIDER_FILTER_FIELD)[keyof typeof SCANS_PROVIDER_FILTER_FIELD]
|
||||||
|
| ScansExtraField
|
||||||
|
>;
|
||||||
@@ -2,7 +2,7 @@ import { render, screen, within } from "@testing-library/react";
|
|||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { FilterType } from "@/types/filters";
|
import { FILTER_FIELD } from "@/types/filters";
|
||||||
|
|
||||||
import { AccountsSelector } from "./accounts-selector";
|
import { AccountsSelector } from "./accounts-selector";
|
||||||
|
|
||||||
@@ -189,7 +189,7 @@ describe("AccountsSelector", () => {
|
|||||||
render(
|
render(
|
||||||
<AccountsSelector
|
<AccountsSelector
|
||||||
providers={providers}
|
providers={providers}
|
||||||
filterKey={FilterType.PROVIDER_UID}
|
filterKey={FILTER_FIELD.PROVIDER_UID}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
MultiSelectValue,
|
MultiSelectValue,
|
||||||
} from "@/components/shadcn/select/multiselect";
|
} from "@/components/shadcn/select/multiselect";
|
||||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||||
import { type AccountFilterKey, FilterType } from "@/types/filters";
|
import { type AccountFilterKey, FILTER_FIELD } from "@/types/filters";
|
||||||
import {
|
import {
|
||||||
getProviderDisplayName,
|
getProviderDisplayName,
|
||||||
type ProviderProps,
|
type ProviderProps,
|
||||||
@@ -68,7 +68,7 @@ export function AccountsSelector({
|
|||||||
providers,
|
providers,
|
||||||
onBatchChange,
|
onBatchChange,
|
||||||
selectedValues,
|
selectedValues,
|
||||||
filterKey = FilterType.PROVIDER_ID,
|
filterKey = FILTER_FIELD.PROVIDER_ID,
|
||||||
id = "accounts-selector",
|
id = "accounts-selector",
|
||||||
disabledValues = [],
|
disabledValues = [],
|
||||||
search = {
|
search = {
|
||||||
@@ -91,7 +91,7 @@ export function AccountsSelector({
|
|||||||
|
|
||||||
const visibleProviders = providers;
|
const visibleProviders = providers;
|
||||||
const getProviderValue = (provider: ProviderProps) =>
|
const getProviderValue = (provider: ProviderProps) =>
|
||||||
filterKey === FilterType.PROVIDER_UID
|
filterKey === FILTER_FIELD.PROVIDER_UID
|
||||||
? provider.attributes.uid
|
? provider.attributes.uid
|
||||||
: provider.id;
|
: provider.id;
|
||||||
const disabledValuesSet = new Set(disabledValues);
|
const disabledValuesSet = new Set(disabledValues);
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { ProviderProps } from "@/types/providers";
|
||||||
|
|
||||||
|
import {
|
||||||
|
filterProvidersByScope,
|
||||||
|
parseFilterIds,
|
||||||
|
scopeProvidersByGroup,
|
||||||
|
} from "./provider-scope";
|
||||||
|
|
||||||
|
const makeProvider = (
|
||||||
|
id: string,
|
||||||
|
provider: string,
|
||||||
|
groupIds: string[] = [],
|
||||||
|
): ProviderProps =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
attributes: { provider },
|
||||||
|
relationships: {
|
||||||
|
provider_groups: {
|
||||||
|
data: groupIds.map((gid) => ({ type: "provider-groups", id: gid })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}) as unknown as ProviderProps;
|
||||||
|
|
||||||
|
describe("parseFilterIds", () => {
|
||||||
|
it("returns an empty array for undefined", () => {
|
||||||
|
// Given / When / Then
|
||||||
|
expect(parseFilterIds(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty array for an empty string", () => {
|
||||||
|
// Given an empty param value (e.g. "filter[provider_groups__in]=")
|
||||||
|
// When / Then it must not produce a [""] match
|
||||||
|
expect(parseFilterIds("")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops whitespace-only and empty segments", () => {
|
||||||
|
// Given a blank/whitespace value
|
||||||
|
// When / Then
|
||||||
|
expect(parseFilterIds(" ")).toEqual([]);
|
||||||
|
expect(parseFilterIds(",")).toEqual([]);
|
||||||
|
expect(parseFilterIds("a,,b")).toEqual(["a", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits and trims comma-separated ids", () => {
|
||||||
|
expect(parseFilterIds(" a , b ")).toEqual(["a", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes array param values", () => {
|
||||||
|
expect(parseFilterIds(["a", "", "b"])).toEqual(["a", "b"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("scopeProvidersByGroup", () => {
|
||||||
|
const providers = [
|
||||||
|
makeProvider("p1", "aws", ["g1"]),
|
||||||
|
makeProvider("p2", "gcp", ["g2"]),
|
||||||
|
makeProvider("p3", "azure", []),
|
||||||
|
];
|
||||||
|
|
||||||
|
it("returns every provider when no group is selected", () => {
|
||||||
|
expect(scopeProvidersByGroup(providers, [])).toEqual(providers);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps only providers that belong to a selected group", () => {
|
||||||
|
// When scoping to g1
|
||||||
|
const result = scopeProvidersByGroup(providers, ["g1"]);
|
||||||
|
|
||||||
|
// Then only the g1 member remains
|
||||||
|
expect(result.map((p) => p.id)).toEqual(["p1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes providers with no group memberships", () => {
|
||||||
|
expect(scopeProvidersByGroup(providers, ["g2"]).map((p) => p.id)).toEqual([
|
||||||
|
"p2",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("filterProvidersByScope", () => {
|
||||||
|
const providers = [
|
||||||
|
makeProvider("p1", "aws", ["g1"]),
|
||||||
|
makeProvider("p2", "gcp", ["g1"]),
|
||||||
|
makeProvider("p3", "aws", ["g2"]),
|
||||||
|
makeProvider("p4", "azure", []),
|
||||||
|
];
|
||||||
|
|
||||||
|
it("returns every provider when no dimension is set", () => {
|
||||||
|
const result = filterProvidersByScope(providers, {
|
||||||
|
providerIds: [],
|
||||||
|
providerTypes: [],
|
||||||
|
providerGroupIds: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual(providers);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by provider id", () => {
|
||||||
|
const result = filterProvidersByScope(providers, {
|
||||||
|
providerIds: ["p2"],
|
||||||
|
providerTypes: [],
|
||||||
|
providerGroupIds: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.map((p) => p.id)).toEqual(["p2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by provider type case-insensitively", () => {
|
||||||
|
const result = filterProvidersByScope(providers, {
|
||||||
|
providerIds: [],
|
||||||
|
providerTypes: ["AWS"],
|
||||||
|
providerGroupIds: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.map((p) => p.id)).toEqual(["p1", "p3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by provider group", () => {
|
||||||
|
const result = filterProvidersByScope(providers, {
|
||||||
|
providerIds: [],
|
||||||
|
providerTypes: [],
|
||||||
|
providerGroupIds: ["g1"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.map((p) => p.id)).toEqual(["p1", "p2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("composes group AND type (the risk-plot regression)", () => {
|
||||||
|
// Given both a group and a type filter are active
|
||||||
|
// When combining group g1 with type aws
|
||||||
|
const result = filterProvidersByScope(providers, {
|
||||||
|
providerIds: [],
|
||||||
|
providerTypes: ["aws"],
|
||||||
|
providerGroupIds: ["g1"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then only providers matching BOTH survive (p1), not all aws or all g1
|
||||||
|
expect(result.map((p) => p.id)).toEqual(["p1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("composes id AND group", () => {
|
||||||
|
// p3 is aws/g2; selecting it together with group g1 yields nothing
|
||||||
|
const result = filterProvidersByScope(providers, {
|
||||||
|
providerIds: ["p3"],
|
||||||
|
providerTypes: [],
|
||||||
|
providerGroupIds: ["g1"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("composes all three dimensions", () => {
|
||||||
|
const result = filterProvidersByScope(providers, {
|
||||||
|
providerIds: ["p1", "p2"],
|
||||||
|
providerTypes: ["aws"],
|
||||||
|
providerGroupIds: ["g1"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.map((p) => p.id)).toEqual(["p1"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { ProviderProps } from "@/types/providers";
|
||||||
|
|
||||||
|
export interface ProviderScopeFilters {
|
||||||
|
providerIds: string[];
|
||||||
|
providerTypes: string[];
|
||||||
|
providerGroupIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a comma-separated filter param into trimmed, non-empty ids.
|
||||||
|
* Guards against blank values (e.g. an empty "filter[...]=" param) so they are
|
||||||
|
* treated as "no filter" instead of matching against an empty-string id.
|
||||||
|
*/
|
||||||
|
export const parseFilterIds = (
|
||||||
|
value: string | string[] | undefined,
|
||||||
|
): string[] => {
|
||||||
|
if (value === undefined) return [];
|
||||||
|
const raw = Array.isArray(value) ? value.join(",") : value;
|
||||||
|
return raw
|
||||||
|
.split(",")
|
||||||
|
.map((id) => id.trim())
|
||||||
|
.filter((id) => id.length > 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const belongsToGroup = (provider: ProviderProps, groupIds: string[]): boolean =>
|
||||||
|
provider.relationships.provider_groups?.data?.some((group) =>
|
||||||
|
groupIds.includes(group.id),
|
||||||
|
) ?? false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep only providers belonging to one of the selected groups. An empty group
|
||||||
|
* list means "no group filter" and returns every provider unchanged.
|
||||||
|
*/
|
||||||
|
export const scopeProvidersByGroup = (
|
||||||
|
providers: ProviderProps[],
|
||||||
|
groupIds: string[],
|
||||||
|
): ProviderProps[] =>
|
||||||
|
groupIds.length === 0
|
||||||
|
? providers
|
||||||
|
: providers.filter((p) => belongsToGroup(p, groupIds));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter providers by every active scope dimension (id, type, group) combined
|
||||||
|
* with AND. Each empty dimension is skipped, so a provider is kept only when it
|
||||||
|
* satisfies all the filters that are actually set.
|
||||||
|
*/
|
||||||
|
export const filterProvidersByScope = (
|
||||||
|
providers: ProviderProps[],
|
||||||
|
{ providerIds, providerTypes, providerGroupIds }: ProviderScopeFilters,
|
||||||
|
): ProviderProps[] => {
|
||||||
|
const normalizedTypes = providerTypes.map((type) => type.toLowerCase());
|
||||||
|
|
||||||
|
return providers.filter((provider) => {
|
||||||
|
if (providerIds.length > 0 && !providerIds.includes(provider.id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalizedTypes.length > 0 &&
|
||||||
|
!normalizedTypes.includes(provider.attributes.provider.toLowerCase())
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
providerGroupIds.length > 0 &&
|
||||||
|
!belongsToGroup(provider, providerGroupIds)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
||||||
+21
-11
@@ -3,11 +3,16 @@ import {
|
|||||||
getFindingsBySeverity,
|
getFindingsBySeverity,
|
||||||
SeverityByProviderType,
|
SeverityByProviderType,
|
||||||
} from "@/actions/overview";
|
} from "@/actions/overview";
|
||||||
|
import { OVERVIEW_FILTER_PARAM } from "@/actions/overview/overview-filters";
|
||||||
import { getAllProviders } from "@/actions/providers";
|
import { getAllProviders } from "@/actions/providers";
|
||||||
import { SankeyChart } from "@/components/graphs/sankey-chart";
|
import { SankeyChart } from "@/components/graphs/sankey-chart";
|
||||||
import { SearchParamsProps } from "@/types";
|
import { SearchParamsProps } from "@/types";
|
||||||
|
|
||||||
import { pickFilterParams } from "../../_lib/filter-params";
|
import { pickFilterParams } from "../../_lib/filter-params";
|
||||||
|
import {
|
||||||
|
parseFilterIds,
|
||||||
|
scopeProvidersByGroup,
|
||||||
|
} from "../../_lib/provider-scope";
|
||||||
|
|
||||||
export async function RiskPipelineViewSSR({
|
export async function RiskPipelineViewSSR({
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -16,27 +21,31 @@ export async function RiskPipelineViewSSR({
|
|||||||
}) {
|
}) {
|
||||||
const filters = pickFilterParams(searchParams);
|
const filters = pickFilterParams(searchParams);
|
||||||
|
|
||||||
const providerTypeFilter = filters["filter[provider_type__in]"];
|
const providerTypeFilter = filters[OVERVIEW_FILTER_PARAM.PROVIDER_TYPE];
|
||||||
const providerIdFilter = filters["filter[provider_id__in]"];
|
const providerIdFilter = filters[OVERVIEW_FILTER_PARAM.PROVIDER_ID];
|
||||||
|
const providerGroupsFilter = filters[OVERVIEW_FILTER_PARAM.PROVIDER_GROUPS];
|
||||||
|
|
||||||
// Fetch providers list to know account types
|
// Fetch providers list to know account types
|
||||||
const providersListResponse = await getAllProviders();
|
const providersListResponse = await getAllProviders();
|
||||||
const allProviders = providersListResponse?.data || [];
|
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
|
// Build severityByProviderType based on filters
|
||||||
const severityByProviderType: SeverityByProviderType = {};
|
const severityByProviderType: SeverityByProviderType = {};
|
||||||
let selectedProviderTypes: string[] | undefined;
|
let selectedProviderTypes: string[] | undefined;
|
||||||
|
|
||||||
if (providerIdFilter) {
|
if (providerIdFilter) {
|
||||||
// Case: Accounts are selected - group by provider type and make parallel calls
|
// Case: Accounts are selected - group by provider type and make parallel calls
|
||||||
const selectedAccountIds = String(providerIdFilter)
|
const selectedAccountIds = parseFilterIds(providerIdFilter);
|
||||||
.split(",")
|
|
||||||
.map((id) => id.trim());
|
|
||||||
|
|
||||||
// Group selected accounts by provider type
|
// Group selected accounts by provider type
|
||||||
const accountsByType: Record<string, string[]> = {};
|
const accountsByType: Record<string, string[]> = {};
|
||||||
for (const accountId of selectedAccountIds) {
|
for (const accountId of selectedAccountIds) {
|
||||||
const provider = allProviders.find((p) => p.id === accountId);
|
const provider = scopedProviders.find((p) => p.id === accountId);
|
||||||
if (provider) {
|
if (provider) {
|
||||||
const type = provider.attributes.provider.toLowerCase();
|
const type = provider.attributes.provider.toLowerCase();
|
||||||
if (!accountsByType[type]) {
|
if (!accountsByType[type]) {
|
||||||
@@ -70,9 +79,9 @@ export async function RiskPipelineViewSSR({
|
|||||||
}
|
}
|
||||||
} else if (providerTypeFilter) {
|
} else if (providerTypeFilter) {
|
||||||
// Case: Provider types are selected - make parallel calls for each type
|
// Case: Provider types are selected - make parallel calls for each type
|
||||||
selectedProviderTypes = String(providerTypeFilter)
|
selectedProviderTypes = parseFilterIds(providerTypeFilter).map((type) =>
|
||||||
.split(",")
|
type.toLowerCase(),
|
||||||
.map((t) => t.trim().toLowerCase());
|
);
|
||||||
|
|
||||||
const severityPromises = selectedProviderTypes.map(async (providerType) => {
|
const severityPromises = selectedProviderTypes.map(async (providerType) => {
|
||||||
const response = await getFindingsBySeverity({
|
const response = await getFindingsBySeverity({
|
||||||
@@ -93,9 +102,10 @@ export async function RiskPipelineViewSSR({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} 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(
|
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) => {
|
const severityPromises = allProviderTypes.map(async (providerType) => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Info } from "lucide-react";
|
import { Info } from "lucide-react";
|
||||||
|
|
||||||
|
import { OVERVIEW_FILTER_PARAM } from "@/actions/overview/overview-filters";
|
||||||
import {
|
import {
|
||||||
adaptToRiskPlotData,
|
adaptToRiskPlotData,
|
||||||
getProvidersRiskData,
|
getProvidersRiskData,
|
||||||
@@ -8,6 +9,10 @@ import { getAllProviders } from "@/actions/providers";
|
|||||||
import { SearchParamsProps } from "@/types";
|
import { SearchParamsProps } from "@/types";
|
||||||
|
|
||||||
import { pickFilterParams } from "../../_lib/filter-params";
|
import { pickFilterParams } from "../../_lib/filter-params";
|
||||||
|
import {
|
||||||
|
filterProvidersByScope,
|
||||||
|
parseFilterIds,
|
||||||
|
} from "../../_lib/provider-scope";
|
||||||
import { RiskPlotClient } from "./risk-plot-client";
|
import { RiskPlotClient } from "./risk-plot-client";
|
||||||
|
|
||||||
export async function RiskPlotSSR({
|
export async function RiskPlotSSR({
|
||||||
@@ -17,31 +22,19 @@ export async function RiskPlotSSR({
|
|||||||
}) {
|
}) {
|
||||||
const filters = pickFilterParams(searchParams);
|
const filters = pickFilterParams(searchParams);
|
||||||
|
|
||||||
const providerTypeFilter = filters["filter[provider_type__in]"];
|
|
||||||
const providerIdFilter = filters["filter[provider_id__in]"];
|
|
||||||
|
|
||||||
// Fetch all providers
|
// Fetch all providers
|
||||||
const providersListResponse = await getAllProviders();
|
const providersListResponse = await getAllProviders();
|
||||||
const allProviders = providersListResponse?.data || [];
|
const allProviders = providersListResponse?.data || [];
|
||||||
|
|
||||||
// Filter providers based on search params
|
// Compose every active provider-scope filter with AND so combining e.g. a
|
||||||
let filteredProviders = allProviders;
|
// group and a type narrows to providers matching both.
|
||||||
|
const filteredProviders = filterProvidersByScope(allProviders, {
|
||||||
if (providerIdFilter) {
|
providerIds: parseFilterIds(filters[OVERVIEW_FILTER_PARAM.PROVIDER_ID]),
|
||||||
// Filter by specific provider IDs
|
providerTypes: parseFilterIds(filters[OVERVIEW_FILTER_PARAM.PROVIDER_TYPE]),
|
||||||
const selectedIds = String(providerIdFilter)
|
providerGroupIds: parseFilterIds(
|
||||||
.split(",")
|
filters[OVERVIEW_FILTER_PARAM.PROVIDER_GROUPS],
|
||||||
.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()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// No providers to show
|
// No providers to show
|
||||||
if (filteredProviders.length === 0) {
|
if (filteredProviders.length === 0) {
|
||||||
|
|||||||
+11
-4
@@ -3,6 +3,7 @@
|
|||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { OVERVIEW_FILTER_PARAM } from "@/actions/overview/overview-filters";
|
||||||
import { getSeverityTrendsByTimeRange } from "@/actions/overview/severity-trends";
|
import { getSeverityTrendsByTimeRange } from "@/actions/overview/severity-trends";
|
||||||
import { LineChart } from "@/components/graphs/line-chart";
|
import { LineChart } from "@/components/graphs/line-chart";
|
||||||
import { LineConfig, LineDataPoint } from "@/components/graphs/types";
|
import { LineConfig, LineDataPoint } from "@/components/graphs/types";
|
||||||
@@ -42,10 +43,16 @@ export const FindingSeverityOverTime = ({
|
|||||||
|
|
||||||
const getActiveProviderFilters = (): Record<string, string> => {
|
const getActiveProviderFilters = (): Record<string, string> => {
|
||||||
const filters: Record<string, string> = {};
|
const filters: Record<string, string> = {};
|
||||||
const providerType = searchParams.get("filter[provider_type__in]");
|
const providerType = searchParams.get(OVERVIEW_FILTER_PARAM.PROVIDER_TYPE);
|
||||||
const providerId = searchParams.get("filter[provider_id__in]");
|
const providerId = searchParams.get(OVERVIEW_FILTER_PARAM.PROVIDER_ID);
|
||||||
if (providerType) filters["filter[provider_type__in]"] = providerType;
|
const providerGroups = searchParams.get(
|
||||||
if (providerId) filters["filter[provider_id__in]"] = providerId;
|
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;
|
return filters;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
getLatestFindingGroups,
|
getLatestFindingGroups,
|
||||||
} from "@/actions/finding-groups";
|
} from "@/actions/finding-groups";
|
||||||
import { getLatestMetadataInfo, getMetadataInfo } from "@/actions/findings";
|
import { getLatestMetadataInfo, getMetadataInfo } from "@/actions/findings";
|
||||||
|
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
|
||||||
import { getAllProviders } from "@/actions/providers";
|
import { getAllProviders } from "@/actions/providers";
|
||||||
import { getScan, getScans } from "@/actions/scans";
|
import { getScan, getScans } from "@/actions/scans";
|
||||||
import { SeedFromFindingsButton } from "@/app/(prowler)/alerts/_components";
|
import { SeedFromFindingsButton } from "@/app/(prowler)/alerts/_components";
|
||||||
@@ -36,8 +37,9 @@ export default async function Findings({
|
|||||||
const { encodedSort } = extractSortAndKey(resolvedSearchParams);
|
const { encodedSort } = extractSortAndKey(resolvedSearchParams);
|
||||||
const { filters, query } = extractFiltersAndQuery(resolvedSearchParams);
|
const { filters, query } = extractFiltersAndQuery(resolvedSearchParams);
|
||||||
|
|
||||||
const [providersData, scansData] = await Promise.all([
|
const [providersData, providerGroupsData, scansData] = await Promise.all([
|
||||||
getAllProviders(),
|
getAllProviders(),
|
||||||
|
getAllProviderGroups(),
|
||||||
getScans({ pageSize: 50 }),
|
getScans({ pageSize: 50 }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -99,6 +101,7 @@ export default async function Findings({
|
|||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<FindingsFilters
|
<FindingsFilters
|
||||||
providers={providersData?.data || []}
|
providers={providersData?.data || []}
|
||||||
|
providerGroups={providerGroupsData?.data || []}
|
||||||
completedScanIds={completedScanIds}
|
completedScanIds={completedScanIds}
|
||||||
scanDetails={scanDetails}
|
scanDetails={scanDetails}
|
||||||
uniqueRegions={uniqueRegions}
|
uniqueRegions={uniqueRegions}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
|
||||||
|
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
|
||||||
import { getAllProviders } from "@/actions/providers";
|
import { getAllProviders } from "@/actions/providers";
|
||||||
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
|
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
|
||||||
|
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
|
||||||
import { ContentLayout } from "@/components/ui";
|
import { ContentLayout } from "@/components/ui";
|
||||||
import { SearchParamsProps } from "@/types";
|
import { SearchParamsProps } from "@/types";
|
||||||
|
|
||||||
@@ -38,12 +40,16 @@ export default async function Home({
|
|||||||
searchParams: Promise<SearchParamsProps>;
|
searchParams: Promise<SearchParamsProps>;
|
||||||
}) {
|
}) {
|
||||||
const resolvedSearchParams = await searchParams;
|
const resolvedSearchParams = await searchParams;
|
||||||
const providersData = await getAllProviders();
|
const [providersData, providerGroupsData] = await Promise.all([
|
||||||
|
getAllProviders(),
|
||||||
|
getAllProviderGroups(),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContentLayout title="Overview" icon="lucide:square-chart-gantt">
|
<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">
|
<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 ?? []} />
|
<ProviderAccountSelectors providers={providersData?.data ?? []} />
|
||||||
|
<ProviderGroupSelector groups={providerGroupsData?.data ?? []} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-6 xl:flex-row xl:flex-wrap xl:items-stretch">
|
<div className="flex flex-col gap-6 xl:flex-row xl:flex-wrap xl:items-stretch">
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ const ProvidersTabContent = async ({
|
|||||||
isCloud={process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"}
|
isCloud={process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"}
|
||||||
filters={providersView.filters}
|
filters={providersView.filters}
|
||||||
providers={providersView.providers}
|
providers={providersView.providers}
|
||||||
|
providerGroups={providersView.providerGroups}
|
||||||
metadata={providersView.metadata}
|
metadata={providersView.metadata}
|
||||||
rows={providersView.rows}
|
rows={providersView.rows}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ const schedulesActionsMock = vi.hoisted(() => ({
|
|||||||
getSchedules: vi.fn(),
|
getSchedules: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const manageGroupsActionsMock = vi.hoisted(() => ({
|
||||||
|
getAllProviderGroups: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/actions/providers", () => providersActionsMock);
|
vi.mock("@/actions/providers", () => providersActionsMock);
|
||||||
vi.mock(
|
vi.mock(
|
||||||
"@/actions/organizations/organizations",
|
"@/actions/organizations/organizations",
|
||||||
@@ -25,6 +29,7 @@ vi.mock(
|
|||||||
);
|
);
|
||||||
vi.mock("@/actions/scans", () => scansActionsMock);
|
vi.mock("@/actions/scans", () => scansActionsMock);
|
||||||
vi.mock("@/actions/schedules", () => schedulesActionsMock);
|
vi.mock("@/actions/schedules", () => schedulesActionsMock);
|
||||||
|
vi.mock("@/actions/manage-groups/manage-groups", () => manageGroupsActionsMock);
|
||||||
|
|
||||||
import { SearchParamsProps } from "@/types";
|
import { SearchParamsProps } from "@/types";
|
||||||
import { ProvidersApiResponse } from "@/types/providers";
|
import { ProvidersApiResponse } from "@/types/providers";
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
|
||||||
import {
|
import {
|
||||||
listOrganizationsSafe,
|
listOrganizationsSafe,
|
||||||
listOrganizationUnitsSafe,
|
listOrganizationUnitsSafe,
|
||||||
} from "@/actions/organizations/organizations";
|
} from "@/actions/organizations/organizations";
|
||||||
import { getAllProviders, getProviders } from "@/actions/providers";
|
import { getAllProviders, getProviders } from "@/actions/providers";
|
||||||
|
import { PROVIDERS_FILTER_PARAM } from "@/actions/providers/providers-filters";
|
||||||
import { getSchedules } from "@/actions/schedules";
|
import { getSchedules } from "@/actions/schedules";
|
||||||
import {
|
import {
|
||||||
extractFiltersAndQuery,
|
extractFiltersAndQuery,
|
||||||
@@ -484,13 +486,12 @@ export async function loadProvidersAccountsViewData({
|
|||||||
|
|
||||||
// Map provider_type__in (used by ProviderTypeSelector) to provider__in (API param)
|
// Map provider_type__in (used by ProviderTypeSelector) to provider__in (API param)
|
||||||
const providerTypeFilter =
|
const providerTypeFilter =
|
||||||
providerFilters[`filter[${PROVIDERS_PAGE_FILTER.PROVIDER_TYPE}]`];
|
providerFilters[PROVIDERS_FILTER_PARAM.PROVIDER_TYPE];
|
||||||
if (providerTypeFilter) {
|
if (providerTypeFilter) {
|
||||||
providerFilters[`filter[${PROVIDERS_PAGE_FILTER.PROVIDER}]`] =
|
providerFilters[PROVIDERS_FILTER_PARAM.PROVIDER] = providerTypeFilter;
|
||||||
providerTypeFilter;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
delete providerFilters[`filter[${PROVIDERS_PAGE_FILTER.PROVIDER_TYPE}]`];
|
delete providerFilters[PROVIDERS_FILTER_PARAM.PROVIDER_TYPE];
|
||||||
|
|
||||||
const emptyOrganizationsResponse: OrganizationListResponse = {
|
const emptyOrganizationsResponse: OrganizationListResponse = {
|
||||||
data: [],
|
data: [],
|
||||||
@@ -502,6 +503,7 @@ export async function loadProvidersAccountsViewData({
|
|||||||
const [
|
const [
|
||||||
providersResponse,
|
providersResponse,
|
||||||
allProvidersResponse,
|
allProvidersResponse,
|
||||||
|
allProviderGroupsResponse,
|
||||||
schedulesResponse,
|
schedulesResponse,
|
||||||
organizationsResponse,
|
organizationsResponse,
|
||||||
organizationUnitsResponse,
|
organizationUnitsResponse,
|
||||||
@@ -518,6 +520,8 @@ export async function loadProvidersAccountsViewData({
|
|||||||
// Unfiltered fetch for ProviderTypeSelector — only needs distinct types;
|
// Unfiltered fetch for ProviderTypeSelector — only needs distinct types;
|
||||||
// TODO: Replace with a dedicated lightweight endpoint when available.
|
// TODO: Replace with a dedicated lightweight endpoint when available.
|
||||||
resolveActionResult(getAllProviders()),
|
resolveActionResult(getAllProviders()),
|
||||||
|
// Unfiltered fetch for the Provider Group selector dropdown.
|
||||||
|
resolveActionResult(getAllProviderGroups()),
|
||||||
// Fetch configured schedules as a fallback when provider scan_* fields are
|
// Fetch configured schedules as a fallback when provider scan_* fields are
|
||||||
// absent (best-effort: typically empty in OSS).
|
// absent (best-effort: typically empty in OSS).
|
||||||
resolveActionResult(getSchedules()),
|
resolveActionResult(getSchedules()),
|
||||||
@@ -546,6 +550,7 @@ export async function loadProvidersAccountsViewData({
|
|||||||
filters: createProvidersFilters(),
|
filters: createProvidersFilters(),
|
||||||
metadata: providersResponse?.meta,
|
metadata: providersResponse?.meta,
|
||||||
providers: allProvidersResponse?.data ?? [],
|
providers: allProvidersResponse?.data ?? [],
|
||||||
|
providerGroups: allProviderGroupsResponse?.data ?? [],
|
||||||
rows,
|
rows,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
|
||||||
|
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
|
||||||
import { getAllProviders } from "@/actions/providers";
|
import { getAllProviders } from "@/actions/providers";
|
||||||
import {
|
import {
|
||||||
getLatestMetadataInfo,
|
getLatestMetadataInfo,
|
||||||
@@ -37,19 +38,23 @@ export default async function Resources({
|
|||||||
|
|
||||||
const initialResourceId = resolvedSearchParams.resourceId?.toString();
|
const initialResourceId = resolvedSearchParams.resourceId?.toString();
|
||||||
|
|
||||||
const [metadataInfoData, providersData, resourceByIdData] = await Promise.all(
|
const [
|
||||||
[
|
metadataInfoData,
|
||||||
(hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo)({
|
providersData,
|
||||||
query,
|
providerGroupsData,
|
||||||
filters: outputFilters,
|
resourceByIdData,
|
||||||
sort: encodedSort,
|
] = await Promise.all([
|
||||||
}),
|
(hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo)({
|
||||||
getAllProviders(),
|
query,
|
||||||
initialResourceId
|
filters: outputFilters,
|
||||||
? getResourceById(initialResourceId, { include: ["provider"] })
|
sort: encodedSort,
|
||||||
: Promise.resolve(undefined),
|
}),
|
||||||
],
|
getAllProviders(),
|
||||||
);
|
getAllProviderGroups(),
|
||||||
|
initialResourceId
|
||||||
|
? getResourceById(initialResourceId, { include: ["provider"] })
|
||||||
|
: Promise.resolve(undefined),
|
||||||
|
]);
|
||||||
|
|
||||||
const processedResource = resourceByIdData?.data
|
const processedResource = resourceByIdData?.data
|
||||||
? (() => {
|
? (() => {
|
||||||
@@ -80,6 +85,7 @@ export default async function Resources({
|
|||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<ResourcesFilters
|
<ResourcesFilters
|
||||||
providers={providersData?.data || []}
|
providers={providersData?.data || []}
|
||||||
|
providerGroups={providerGroupsData?.data || []}
|
||||||
uniqueRegions={uniqueRegions}
|
uniqueRegions={uniqueRegions}
|
||||||
uniqueServices={uniqueServices}
|
uniqueServices={uniqueServices}
|
||||||
uniqueResourceTypes={uniqueResourceTypes}
|
uniqueResourceTypes={uniqueResourceTypes}
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
|
||||||
|
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
|
||||||
import { getAllProviders } from "@/actions/providers";
|
import { getAllProviders } from "@/actions/providers";
|
||||||
import { getScans } from "@/actions/scans";
|
import { getScans } from "@/actions/scans";
|
||||||
|
import {
|
||||||
|
SCANS_PROVIDER_FILTER_FIELD,
|
||||||
|
type ScansFilterParam,
|
||||||
|
} from "@/actions/scans/scans-filters";
|
||||||
import { getSchedules, getSchedulesPage } from "@/actions/schedules";
|
import { getSchedules, getSchedulesPage } from "@/actions/schedules";
|
||||||
import { auth } from "@/auth.config";
|
import { auth } from "@/auth.config";
|
||||||
import { PageReady } from "@/components/onboarding";
|
import { PageReady } from "@/components/onboarding";
|
||||||
@@ -28,7 +33,6 @@ import {
|
|||||||
} from "@/lib/schedules";
|
} from "@/lib/schedules";
|
||||||
import { isCloud } from "@/lib/shared/env";
|
import { isCloud } from "@/lib/shared/env";
|
||||||
import {
|
import {
|
||||||
FilterType,
|
|
||||||
ProviderProps,
|
ProviderProps,
|
||||||
SCAN_JOBS_TAB,
|
SCAN_JOBS_TAB,
|
||||||
SCAN_TRIGGER,
|
SCAN_TRIGGER,
|
||||||
@@ -41,29 +45,22 @@ import {
|
|||||||
} from "@/types/schedules";
|
} from "@/types/schedules";
|
||||||
|
|
||||||
const ACTIVE_SCAN_COUNT_PAGE_SIZE = 1;
|
const ACTIVE_SCAN_COUNT_PAGE_SIZE = 1;
|
||||||
// Pending schedule rows must honor the same provider filters as real scan rows.
|
// Pending schedule rows are derived from provider schedules, but must honor the
|
||||||
// The `__in` keys reuse the shared FilterType; the singular variants have no
|
// same provider filters as real scan rows. The filter keys live with the scans
|
||||||
// FilterType equivalent, so they stay as literals.
|
// action (SCANS_PROVIDER_FILTER_FIELD) so they stay in sync with ScansFilterParam.
|
||||||
const PENDING_ROW_PROVIDER_FILTER = {
|
|
||||||
PROVIDER_IN: FilterType.PROVIDER,
|
|
||||||
PROVIDER: "provider",
|
|
||||||
PROVIDER_TYPE_IN: FilterType.PROVIDER_TYPE,
|
|
||||||
PROVIDER_TYPE: "provider_type",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
type PendingRowProviderFilter =
|
|
||||||
(typeof PENDING_ROW_PROVIDER_FILTER)[keyof typeof PENDING_ROW_PROVIDER_FILTER];
|
|
||||||
type PendingRowProviderFilterParam = `filter[${PendingRowProviderFilter}]`;
|
|
||||||
|
|
||||||
const PROVIDER_ID_FILTER_KEYS = [
|
const PROVIDER_ID_FILTER_KEYS = [
|
||||||
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_IN}]`,
|
`filter[${SCANS_PROVIDER_FILTER_FIELD.PROVIDER_IN}]`,
|
||||||
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER}]`,
|
`filter[${SCANS_PROVIDER_FILTER_FIELD.PROVIDER}]`,
|
||||||
] as const satisfies ReadonlyArray<PendingRowProviderFilterParam>;
|
] as const satisfies ReadonlyArray<ScansFilterParam>;
|
||||||
|
|
||||||
const PROVIDER_TYPE_FILTER_KEYS = [
|
const PROVIDER_TYPE_FILTER_KEYS = [
|
||||||
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_TYPE_IN}]`,
|
`filter[${SCANS_PROVIDER_FILTER_FIELD.PROVIDER_TYPE_IN}]`,
|
||||||
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_TYPE}]`,
|
`filter[${SCANS_PROVIDER_FILTER_FIELD.PROVIDER_TYPE}]`,
|
||||||
] as const satisfies ReadonlyArray<PendingRowProviderFilterParam>;
|
] 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 = (
|
const getFilterSearchQuery = (
|
||||||
filters: Record<string, string | string[]>,
|
filters: Record<string, string | string[]>,
|
||||||
@@ -86,7 +83,7 @@ const parseCsvParam = (value?: string | string[]): string[] => {
|
|||||||
|
|
||||||
const getFirstSearchParam = (
|
const getFirstSearchParam = (
|
||||||
searchParams: SearchParamsProps,
|
searchParams: SearchParamsProps,
|
||||||
keys: ReadonlyArray<PendingRowProviderFilterParam>,
|
keys: ReadonlyArray<ScansFilterParam>,
|
||||||
): string | string[] | undefined => {
|
): string | string[] | undefined => {
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const value = searchParams[key];
|
const value = searchParams[key];
|
||||||
@@ -107,11 +104,18 @@ const filterProvidersForPendingRows = (
|
|||||||
const types = parseCsvParam(
|
const types = parseCsvParam(
|
||||||
getFirstSearchParam(searchParams, PROVIDER_TYPE_FILTER_KEYS),
|
getFirstSearchParam(searchParams, PROVIDER_TYPE_FILTER_KEYS),
|
||||||
);
|
);
|
||||||
|
const groups = parseCsvParam(
|
||||||
|
getFirstSearchParam(searchParams, PROVIDER_GROUP_FILTER_KEYS),
|
||||||
|
);
|
||||||
|
|
||||||
return providers.filter(
|
return providers.filter(
|
||||||
(provider) =>
|
(provider) =>
|
||||||
(ids.length === 0 || ids.includes(provider.id)) &&
|
(ids.length === 0 || ids.includes(provider.id)) &&
|
||||||
(types.length === 0 || types.includes(provider.attributes.provider)),
|
(types.length === 0 || types.includes(provider.attributes.provider)) &&
|
||||||
|
(groups.length === 0 ||
|
||||||
|
(provider.relationships?.provider_groups?.data ?? []).some((group) =>
|
||||||
|
groups.includes(group.id),
|
||||||
|
)),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -177,8 +181,12 @@ export default async function Scans({
|
|||||||
const session = await auth();
|
const session = await auth();
|
||||||
const resolvedSearchParams = await searchParams;
|
const resolvedSearchParams = await searchParams;
|
||||||
|
|
||||||
const providersData = await getAllProviders();
|
const [providersData, providerGroupsData] = await Promise.all([
|
||||||
|
getAllProviders(),
|
||||||
|
getAllProviderGroups(),
|
||||||
|
]);
|
||||||
const providers = providersData?.data ?? [];
|
const providers = providersData?.data ?? [];
|
||||||
|
const providerGroups = providerGroupsData?.data ?? [];
|
||||||
|
|
||||||
const connectedProviders = providers.filter(
|
const connectedProviders = providers.filter(
|
||||||
(provider: ProviderProps) =>
|
(provider: ProviderProps) =>
|
||||||
@@ -229,6 +237,7 @@ export default async function Scans({
|
|||||||
) : (
|
) : (
|
||||||
<ScansPageShell
|
<ScansPageShell
|
||||||
providers={providers}
|
providers={providers}
|
||||||
|
providerGroups={providerGroups}
|
||||||
hasManageScansPermission={hasManageScansPermission}
|
hasManageScansPermission={hasManageScansPermission}
|
||||||
activeScanCount={activeScanCount}
|
activeScanCount={activeScanCount}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CONNECTION_STATUS_MAPPING } from "@/lib/helper-filters";
|
import { CONNECTION_STATUS_MAPPING } from "@/lib/helper-filters";
|
||||||
import { FilterOption, FilterType } from "@/types/filters";
|
import { FILTER_FIELD, FilterOption } from "@/types/filters";
|
||||||
import {
|
import {
|
||||||
PROVIDER_DISPLAY_NAMES,
|
PROVIDER_DISPLAY_NAMES,
|
||||||
PROVIDER_TYPES,
|
PROVIDER_TYPES,
|
||||||
@@ -64,19 +64,19 @@ export const filterScans = [
|
|||||||
//Static filters for findings
|
//Static filters for findings
|
||||||
export const filterFindings = [
|
export const filterFindings = [
|
||||||
{
|
{
|
||||||
key: FilterType.SEVERITY,
|
key: FILTER_FIELD.SEVERITY,
|
||||||
labelCheckboxGroup: "Severity",
|
labelCheckboxGroup: "Severity",
|
||||||
values: ["critical", "high", "medium", "low", "informational"],
|
values: ["critical", "high", "medium", "low", "informational"],
|
||||||
index: 0,
|
index: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: FilterType.STATUS,
|
key: FILTER_FIELD.STATUS,
|
||||||
labelCheckboxGroup: "Status",
|
labelCheckboxGroup: "Status",
|
||||||
values: ["PASS", "FAIL", "MANUAL"],
|
values: ["PASS", "FAIL", "MANUAL"],
|
||||||
index: 1,
|
index: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: FilterType.DELTA,
|
key: FILTER_FIELD.DELTA,
|
||||||
labelCheckboxGroup: "Delta",
|
labelCheckboxGroup: "Delta",
|
||||||
values: ["new", "changed"],
|
values: ["new", "changed"],
|
||||||
index: 2,
|
index: 2,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { render } from "@testing-library/react";
|
import { render } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { FilterType } from "@/types/filters";
|
import { FILTER_FIELD } from "@/types/filters";
|
||||||
import type { ProviderProps } from "@/types/providers";
|
import type { ProviderProps } from "@/types/providers";
|
||||||
|
|
||||||
import { ProviderAccountSelectors } from "./provider-account-selectors";
|
import { ProviderAccountSelectors } from "./provider-account-selectors";
|
||||||
@@ -171,7 +171,7 @@ describe("ProviderAccountSelectors", () => {
|
|||||||
render(
|
render(
|
||||||
<ProviderAccountSelectors
|
<ProviderAccountSelectors
|
||||||
providers={providers}
|
providers={providers}
|
||||||
accountFilterKey={FilterType.PROVIDER_UID}
|
accountFilterKey={FILTER_FIELD.PROVIDER_UID}
|
||||||
accountValue="uid"
|
accountValue="uid"
|
||||||
paramsToDeleteOnChange={["page", "scanId"]}
|
paramsToDeleteOnChange={["page", "scanId"]}
|
||||||
/>,
|
/>,
|
||||||
@@ -230,7 +230,7 @@ describe("ProviderAccountSelectors", () => {
|
|||||||
<ProviderAccountSelectors
|
<ProviderAccountSelectors
|
||||||
providers={providers}
|
providers={providers}
|
||||||
mode="batch"
|
mode="batch"
|
||||||
accountFilterKey={FilterType.PROVIDER_UID}
|
accountFilterKey={FILTER_FIELD.PROVIDER_UID}
|
||||||
accountValue="uid"
|
accountValue="uid"
|
||||||
selectedProviderTypes={["aws"]}
|
selectedProviderTypes={["aws"]}
|
||||||
selectedAccounts={["123456789012", "prowler-project"]}
|
selectedAccounts={["123456789012", "prowler-project"]}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useSearchParams } from "next/navigation";
|
|||||||
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
|
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
|
||||||
import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector";
|
import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector";
|
||||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||||
import { type AccountFilterKey, FilterType } from "@/types/filters";
|
import { type AccountFilterKey, FILTER_FIELD } from "@/types/filters";
|
||||||
import type { ProviderProps } from "@/types/providers";
|
import type { ProviderProps } from "@/types/providers";
|
||||||
|
|
||||||
const ACCOUNT_VALUE = {
|
const ACCOUNT_VALUE = {
|
||||||
@@ -91,7 +91,7 @@ const getCompatibleAccounts = ({
|
|||||||
|
|
||||||
export function ProviderAccountSelectors({
|
export function ProviderAccountSelectors({
|
||||||
providers,
|
providers,
|
||||||
accountFilterKey = FilterType.PROVIDER_ID,
|
accountFilterKey = FILTER_FIELD.PROVIDER_ID,
|
||||||
accountValue = ACCOUNT_VALUE.ID,
|
accountValue = ACCOUNT_VALUE.ID,
|
||||||
providerSelectorClassName,
|
providerSelectorClassName,
|
||||||
accountSelectorClassName,
|
accountSelectorClassName,
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { ProviderGroup } from "@/types/components";
|
||||||
|
|
||||||
|
import { ProviderGroupSelector } from "./provider-group-selector";
|
||||||
|
|
||||||
|
const multiSelectContentSpy = vi.fn();
|
||||||
|
|
||||||
|
const { navigateWithParamsMock } = vi.hoisted(() => ({
|
||||||
|
navigateWithParamsMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let currentSearchParams = new URLSearchParams();
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useSearchParams: () => currentSearchParams,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-url-filters", () => ({
|
||||||
|
useUrlFilters: () => ({
|
||||||
|
navigateWithParams: navigateWithParamsMock,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/shadcn/select/multiselect", () => ({
|
||||||
|
MultiSelect: ({
|
||||||
|
children,
|
||||||
|
onValuesChange,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onValuesChange: (values: string[]) => void;
|
||||||
|
}) => (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
data-testid="mock-select-group-2"
|
||||||
|
onClick={() => onValuesChange(["group-2"])}
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div data-testid="trigger">{children}</div>
|
||||||
|
),
|
||||||
|
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
|
||||||
|
<span>{placeholder}</span>
|
||||||
|
),
|
||||||
|
MultiSelectContent: ({
|
||||||
|
children,
|
||||||
|
search,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
search?: unknown;
|
||||||
|
}) => {
|
||||||
|
multiSelectContentSpy(search);
|
||||||
|
return <div>{children}</div>;
|
||||||
|
},
|
||||||
|
MultiSelectItem: ({
|
||||||
|
children,
|
||||||
|
value,
|
||||||
|
keywords,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
value: string;
|
||||||
|
keywords?: string[];
|
||||||
|
}) => (
|
||||||
|
<div data-value={value} data-keywords={keywords?.join("|")}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const makeGroup = (id: string, name: string): ProviderGroup => ({
|
||||||
|
type: "provider-groups",
|
||||||
|
id,
|
||||||
|
attributes: { name, inserted_at: "", updated_at: "" },
|
||||||
|
relationships: {
|
||||||
|
providers: { meta: { count: 0 }, data: [] },
|
||||||
|
roles: { meta: { count: 0 }, data: [] },
|
||||||
|
},
|
||||||
|
links: { self: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const groups = [
|
||||||
|
makeGroup("group-1", "Production"),
|
||||||
|
makeGroup("group-2", "Dev"),
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("ProviderGroupSelector", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
currentSearchParams = new URLSearchParams();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays visible with the placeholder and empty message when there are no provider groups", () => {
|
||||||
|
render(<ProviderGroupSelector groups={[]} />);
|
||||||
|
|
||||||
|
// Control is still rendered (visible even with zero groups)...
|
||||||
|
expect(screen.getByText("All Provider Groups")).toBeInTheDocument();
|
||||||
|
// ...and the single empty state is the MultiSelect's own emptyMessage,
|
||||||
|
// not a duplicate custom message.
|
||||||
|
expect(multiSelectContentSpy).toHaveBeenCalledWith({
|
||||||
|
placeholder: "Search Provider Groups...",
|
||||||
|
emptyMessage: "No Provider Groups found.",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
screen.queryByText("No Provider Groups available"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes searchable dropdown defaults to MultiSelectContent and lists groups", () => {
|
||||||
|
render(<ProviderGroupSelector groups={groups} />);
|
||||||
|
|
||||||
|
expect(multiSelectContentSpy).toHaveBeenCalledWith({
|
||||||
|
placeholder: "Search Provider Groups...",
|
||||||
|
emptyMessage: "No Provider Groups found.",
|
||||||
|
});
|
||||||
|
expect(screen.getByText("Production")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Dev")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows disabling search explicitly", () => {
|
||||||
|
render(<ProviderGroupSelector groups={groups} search={false} />);
|
||||||
|
|
||||||
|
expect(multiSelectContentSpy).toHaveBeenLastCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the group name as a search keyword", () => {
|
||||||
|
render(<ProviderGroupSelector groups={groups} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText("Production").closest("[data-value]"),
|
||||||
|
).toHaveAttribute("data-keywords", expect.stringContaining("Production"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables select all when nothing is selected", () => {
|
||||||
|
render(<ProviderGroupSelector groups={groups} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("option", { name: /select all Provider Groups/i }),
|
||||||
|
).toHaveAttribute("aria-disabled", "true");
|
||||||
|
expect(screen.getByText("All selected")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the selected count in the trigger when multiple groups are selected", () => {
|
||||||
|
render(
|
||||||
|
<ProviderGroupSelector
|
||||||
|
groups={groups}
|
||||||
|
onBatchChange={vi.fn()}
|
||||||
|
selectedValues={["group-1", "group-2"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const trigger = screen.getByTestId("trigger");
|
||||||
|
expect(
|
||||||
|
within(trigger).getByText("2 Provider Groups selected"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the single group name in the trigger when one group is selected", () => {
|
||||||
|
render(
|
||||||
|
<ProviderGroupSelector
|
||||||
|
groups={groups}
|
||||||
|
onBatchChange={vi.fn()}
|
||||||
|
selectedValues={["group-1"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const trigger = screen.getByTestId("trigger");
|
||||||
|
expect(within(trigger).getByText("Production")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("instant mode: writes the selection to filter[provider_groups__in] in the URL", () => {
|
||||||
|
render(<ProviderGroupSelector groups={groups} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("mock-select-group-2"));
|
||||||
|
|
||||||
|
expect(navigateWithParamsMock).toHaveBeenCalledTimes(1);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
navigateWithParamsMock.mock.calls[0][0](params);
|
||||||
|
expect(params.get("filter[provider_groups__in]")).toBe("group-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("instant mode: clearing deletes the filter key and the extra paramsToDeleteOnChange keys", () => {
|
||||||
|
currentSearchParams = new URLSearchParams(
|
||||||
|
"filter[provider_groups__in]=group-1&page=3&scanId=abc",
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
<ProviderGroupSelector
|
||||||
|
groups={groups}
|
||||||
|
paramsToDeleteOnChange={["page", "scanId"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("option", { name: /select all Provider Groups/i }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(navigateWithParamsMock).toHaveBeenCalledTimes(1);
|
||||||
|
const params = new URLSearchParams(
|
||||||
|
"filter[provider_groups__in]=group-1&page=3&scanId=abc",
|
||||||
|
);
|
||||||
|
navigateWithParamsMock.mock.calls[0][0](params);
|
||||||
|
expect(params.has("filter[provider_groups__in]")).toBe(false);
|
||||||
|
expect(params.has("page")).toBe(false);
|
||||||
|
expect(params.has("scanId")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults the control id and links the sr-only label to it", () => {
|
||||||
|
render(<ProviderGroupSelector groups={groups} />);
|
||||||
|
|
||||||
|
const label = screen.getByText(/Filter by Provider Group/i);
|
||||||
|
expect(label).toHaveAttribute("for", "provider-group-selector");
|
||||||
|
expect(label).toHaveAttribute("id", "provider-group-selector-label");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies a custom id so multiple instances don't collide", () => {
|
||||||
|
render(
|
||||||
|
<ProviderGroupSelector groups={groups} id="resources-provider-group" />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const label = screen.getByText(/Filter by Provider Group/i);
|
||||||
|
expect(label).toHaveAttribute("for", "resources-provider-group");
|
||||||
|
expect(label).toHaveAttribute("id", "resources-provider-group-label");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not navigate on clear when nothing is selected", () => {
|
||||||
|
render(<ProviderGroupSelector groups={groups} />);
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("option", { name: /select all Provider Groups/i }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(navigateWithParamsMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
import {
|
||||||
|
MultiSelect,
|
||||||
|
MultiSelectContent,
|
||||||
|
MultiSelectItem,
|
||||||
|
type MultiSelectSearchProp,
|
||||||
|
MultiSelectTrigger,
|
||||||
|
MultiSelectValue,
|
||||||
|
} from "@/components/shadcn/select/multiselect";
|
||||||
|
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||||
|
import type { ProviderGroup } from "@/types/components";
|
||||||
|
import { FILTER_FIELD } from "@/types/filters";
|
||||||
|
|
||||||
|
const PROVIDER_GROUP_FILTER_KEY = FILTER_FIELD.PROVIDER_GROUPS;
|
||||||
|
const URL_FILTER_KEY = `filter[${PROVIDER_GROUP_FILTER_KEY}]`;
|
||||||
|
|
||||||
|
/** Common props shared by both batch and instant modes. */
|
||||||
|
interface ProviderGroupSelectorBaseProps {
|
||||||
|
groups: ProviderGroup[];
|
||||||
|
search?: MultiSelectSearchProp;
|
||||||
|
/** DOM id for the control; pass a unique one when rendering more than one. */
|
||||||
|
id?: string;
|
||||||
|
/**
|
||||||
|
* Instant mode only: extra URL params to delete when the selection changes
|
||||||
|
* (e.g. ["page", "scanId"]), mirroring ProviderAccountSelectors. Ignored in
|
||||||
|
* batch mode, where the parent owns URL updates.
|
||||||
|
*/
|
||||||
|
paramsToDeleteOnChange?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Batch mode: caller controls both pending state and notification callback (all-or-nothing). */
|
||||||
|
interface ProviderGroupSelectorBatchProps
|
||||||
|
extends ProviderGroupSelectorBaseProps {
|
||||||
|
/**
|
||||||
|
* Called instead of navigating immediately.
|
||||||
|
* Use this on pages that batch filter changes (e.g. Findings).
|
||||||
|
*
|
||||||
|
* @param filterKey - The raw filter key without "filter[]" wrapper, e.g. "provider_groups__in"
|
||||||
|
* @param values - The selected values array
|
||||||
|
*/
|
||||||
|
onBatchChange: (filterKey: string, values: string[]) => void;
|
||||||
|
/**
|
||||||
|
* Pending selected values controlled by the parent.
|
||||||
|
* Reflects pending state before Apply is clicked.
|
||||||
|
*/
|
||||||
|
selectedValues: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Instant mode: URL-driven — neither callback nor controlled value. */
|
||||||
|
interface ProviderGroupSelectorInstantProps
|
||||||
|
extends ProviderGroupSelectorBaseProps {
|
||||||
|
onBatchChange?: never;
|
||||||
|
selectedValues?: never;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProviderGroupSelectorProps =
|
||||||
|
| ProviderGroupSelectorBatchProps
|
||||||
|
| ProviderGroupSelectorInstantProps;
|
||||||
|
|
||||||
|
export function ProviderGroupSelector({
|
||||||
|
groups,
|
||||||
|
onBatchChange,
|
||||||
|
selectedValues,
|
||||||
|
id = "provider-group-selector",
|
||||||
|
search = {
|
||||||
|
placeholder: "Search Provider Groups...",
|
||||||
|
emptyMessage: "No Provider Groups found.",
|
||||||
|
},
|
||||||
|
paramsToDeleteOnChange = [],
|
||||||
|
}: ProviderGroupSelectorProps) {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { navigateWithParams } = useUrlFilters();
|
||||||
|
const labelId = `${id}-label`;
|
||||||
|
|
||||||
|
const current = searchParams.get(URL_FILTER_KEY) || "";
|
||||||
|
const urlSelectedIds = current ? current.split(",").filter(Boolean) : [];
|
||||||
|
|
||||||
|
// In batch mode, use the parent-controlled pending values; otherwise, use URL state.
|
||||||
|
const selectedIds = onBatchChange ? selectedValues : urlSelectedIds;
|
||||||
|
|
||||||
|
const handleMultiValueChange = (ids: string[]) => {
|
||||||
|
if (onBatchChange) {
|
||||||
|
onBatchChange(PROVIDER_GROUP_FILTER_KEY, ids);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigateWithParams((params) => {
|
||||||
|
if (ids.length > 0) {
|
||||||
|
params.set(URL_FILTER_KEY, ids.join(","));
|
||||||
|
} else {
|
||||||
|
params.delete(URL_FILTER_KEY);
|
||||||
|
}
|
||||||
|
paramsToDeleteOnChange.forEach((key) => params.delete(key));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedLabel = () => {
|
||||||
|
if (selectedIds.length === 0) return null;
|
||||||
|
if (selectedIds.length === 1) {
|
||||||
|
const group = groups.find((g) => g.id === selectedIds[0]);
|
||||||
|
return (
|
||||||
|
<span className="truncate">
|
||||||
|
{group ? group.attributes.name : selectedIds[0]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="truncate">
|
||||||
|
{selectedIds.length} Provider Groups selected
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<label htmlFor={id} className="sr-only" id={labelId}>
|
||||||
|
Filter by Provider Group. Select one or more Provider Groups to filter
|
||||||
|
results.
|
||||||
|
</label>
|
||||||
|
<MultiSelect values={selectedIds} onValuesChange={handleMultiValueChange}>
|
||||||
|
<MultiSelectTrigger id={id} aria-labelledby={labelId}>
|
||||||
|
{selectedLabel() || (
|
||||||
|
<MultiSelectValue placeholder="All Provider Groups" />
|
||||||
|
)}
|
||||||
|
</MultiSelectTrigger>
|
||||||
|
<MultiSelectContent search={search}>
|
||||||
|
{/* No items when empty: the MultiSelect's own emptyMessage is the
|
||||||
|
single empty state (avoids a duplicate "none" message). */}
|
||||||
|
{groups.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
role="option"
|
||||||
|
aria-selected={selectedIds.length === 0}
|
||||||
|
aria-disabled={selectedIds.length === 0}
|
||||||
|
aria-label="Select all Provider Groups (clears current selection to show all)"
|
||||||
|
tabIndex={0}
|
||||||
|
className="text-text-neutral-secondary flex w-full cursor-pointer items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold hover:bg-slate-200 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 dark:hover:bg-slate-700/50"
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedIds.length === 0) return;
|
||||||
|
handleMultiValueChange([]);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectedIds.length === 0) return;
|
||||||
|
handleMultiValueChange([]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedIds.length === 0 ? "All selected" : "Select All"}
|
||||||
|
</div>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<MultiSelectItem
|
||||||
|
key={group.id}
|
||||||
|
value={group.id}
|
||||||
|
badgeLabel={group.attributes.name}
|
||||||
|
keywords={[group.attributes.name]}
|
||||||
|
aria-label={`${group.attributes.name} Provider Group`}
|
||||||
|
>
|
||||||
|
<span className="truncate">{group.attributes.name}</span>
|
||||||
|
</MultiSelectItem>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</MultiSelectContent>
|
||||||
|
</MultiSelect>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,12 +14,14 @@ import {
|
|||||||
FilterSummaryStrip,
|
FilterSummaryStrip,
|
||||||
} from "@/components/filters/filter-summary-strip";
|
} from "@/components/filters/filter-summary-strip";
|
||||||
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
|
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
|
||||||
|
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
|
||||||
import { Button } from "@/components/shadcn";
|
import { Button } from "@/components/shadcn";
|
||||||
import { ExpandableSection } from "@/components/ui/expandable-section";
|
import { ExpandableSection } from "@/components/ui/expandable-section";
|
||||||
import { DataTableFilterCustom } from "@/components/ui/table/data-table-filter-custom";
|
import { DataTableFilterCustom } from "@/components/ui/table/data-table-filter-custom";
|
||||||
import { useFilterBatch } from "@/hooks/use-filter-batch";
|
import { useFilterBatch } from "@/hooks/use-filter-batch";
|
||||||
import { getCategoryLabel, getGroupLabel } from "@/lib/categories";
|
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 { DATA_TABLE_FILTER_MODE } from "@/types/filters";
|
||||||
import { ProviderProps } from "@/types/providers";
|
import { ProviderProps } from "@/types/providers";
|
||||||
|
|
||||||
@@ -31,6 +33,8 @@ import {
|
|||||||
interface FindingsFiltersProps {
|
interface FindingsFiltersProps {
|
||||||
/** Provider data for provider/account filter controls. */
|
/** Provider data for provider/account filter controls. */
|
||||||
providers: ProviderProps[];
|
providers: ProviderProps[];
|
||||||
|
/** Provider groups for the provider group filter control. */
|
||||||
|
providerGroups?: ProviderGroup[];
|
||||||
completedScanIds: string[];
|
completedScanIds: string[];
|
||||||
scanDetails: { [key: string]: ScanEntity }[];
|
scanDetails: { [key: string]: ScanEntity }[];
|
||||||
uniqueRegions: string[];
|
uniqueRegions: string[];
|
||||||
@@ -70,6 +74,10 @@ const FILTER_GRID_ITEM_CLASS = "min-w-0";
|
|||||||
|
|
||||||
export const FindingsFilterBatchControls = ({
|
export const FindingsFilterBatchControls = ({
|
||||||
providers,
|
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,
|
completedScanIds,
|
||||||
scanDetails,
|
scanDetails,
|
||||||
uniqueRegions,
|
uniqueRegions,
|
||||||
@@ -97,7 +105,7 @@ export const FindingsFilterBatchControls = ({
|
|||||||
|
|
||||||
const customFilters = [
|
const customFilters = [
|
||||||
...filterFindings
|
...filterFindings
|
||||||
.filter((filter) => !isAlertsEdit || filter.key !== FilterType.STATUS)
|
.filter((filter) => !isAlertsEdit || filter.key !== FILTER_FIELD.STATUS)
|
||||||
.map((filter) => ({
|
.map((filter) => ({
|
||||||
...filter,
|
...filter,
|
||||||
labelFormatter: (value: string) =>
|
labelFormatter: (value: string) =>
|
||||||
@@ -107,32 +115,32 @@ export const FindingsFilterBatchControls = ({
|
|||||||
}),
|
}),
|
||||||
})),
|
})),
|
||||||
{
|
{
|
||||||
key: FilterType.REGION,
|
key: FILTER_FIELD.REGION,
|
||||||
labelCheckboxGroup: "Regions",
|
labelCheckboxGroup: "Regions",
|
||||||
values: uniqueRegions,
|
values: uniqueRegions,
|
||||||
index: 3,
|
index: 3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: FilterType.SERVICE,
|
key: FILTER_FIELD.SERVICE,
|
||||||
labelCheckboxGroup: "Services",
|
labelCheckboxGroup: "Services",
|
||||||
values: uniqueServices,
|
values: uniqueServices,
|
||||||
index: 4,
|
index: 4,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: FilterType.RESOURCE_TYPE,
|
key: FILTER_FIELD.RESOURCE_TYPE,
|
||||||
labelCheckboxGroup: "Resource Type",
|
labelCheckboxGroup: "Resource Type",
|
||||||
values: uniqueResourceTypes,
|
values: uniqueResourceTypes,
|
||||||
index: 8,
|
index: 8,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: FilterType.CATEGORY,
|
key: FILTER_FIELD.CATEGORY,
|
||||||
labelCheckboxGroup: "Category",
|
labelCheckboxGroup: "Category",
|
||||||
values: uniqueCategories,
|
values: uniqueCategories,
|
||||||
labelFormatter: getCategoryLabel,
|
labelFormatter: getCategoryLabel,
|
||||||
index: 5,
|
index: 5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: FilterType.RESOURCE_GROUPS,
|
key: FILTER_FIELD.RESOURCE_GROUPS,
|
||||||
labelCheckboxGroup: "Resource Group",
|
labelCheckboxGroup: "Resource Group",
|
||||||
values: uniqueGroups,
|
values: uniqueGroups,
|
||||||
labelFormatter: getGroupLabel,
|
labelFormatter: getGroupLabel,
|
||||||
@@ -142,14 +150,14 @@ export const FindingsFilterBatchControls = ({
|
|||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
key: FilterType.SCAN,
|
key: FILTER_FIELD.SCAN,
|
||||||
labelCheckboxGroup: "Scan ID",
|
labelCheckboxGroup: "Scan ID",
|
||||||
values: completedScanIds,
|
values: completedScanIds,
|
||||||
width: "wide" as const,
|
width: "wide" as const,
|
||||||
valueLabelMapping: scanDetails,
|
valueLabelMapping: scanDetails,
|
||||||
labelFormatter: (value: string) =>
|
labelFormatter: (value: string) =>
|
||||||
getFindingsFilterDisplayValue(
|
getFindingsFilterDisplayValue(
|
||||||
`filter[${FilterType.SCAN}]`,
|
`filter[${FILTER_FIELD.SCAN}]`,
|
||||||
value,
|
value,
|
||||||
{
|
{
|
||||||
providers,
|
providers,
|
||||||
@@ -167,6 +175,7 @@ export const FindingsFilterBatchControls = ({
|
|||||||
appliedFilters,
|
appliedFilters,
|
||||||
{
|
{
|
||||||
providers,
|
providers,
|
||||||
|
providerGroups,
|
||||||
scans: scanDetails,
|
scans: scanDetails,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -174,6 +183,7 @@ export const FindingsFilterBatchControls = ({
|
|||||||
changedFilters,
|
changedFilters,
|
||||||
{
|
{
|
||||||
providers,
|
providers,
|
||||||
|
providerGroups,
|
||||||
scans: scanDetails,
|
scans: scanDetails,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -199,15 +209,26 @@ export const FindingsFilterBatchControls = ({
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const providerAccountControls = (className: string) => (
|
const providerAccountControls = (className: string) => (
|
||||||
<ProviderAccountSelectors
|
<>
|
||||||
providers={providers}
|
<ProviderAccountSelectors
|
||||||
mode="batch"
|
providers={providers}
|
||||||
selectedProviderTypes={getFilterValue("filter[provider_type__in]")}
|
mode="batch"
|
||||||
selectedAccounts={getFilterValue("filter[provider_id__in]")}
|
selectedProviderTypes={getFilterValue("filter[provider_type__in]")}
|
||||||
onBatchChange={setPending}
|
selectedAccounts={getFilterValue("filter[provider_id__in]")}
|
||||||
providerSelectorClassName={className}
|
onBatchChange={setPending}
|
||||||
accountSelectorClassName={className}
|
providerSelectorClassName={className}
|
||||||
/>
|
accountSelectorClassName={className}
|
||||||
|
/>
|
||||||
|
{providerGroups !== undefined && (
|
||||||
|
<div className={className}>
|
||||||
|
<ProviderGroupSelector
|
||||||
|
groups={providerGroups}
|
||||||
|
selectedValues={getFilterValue("filter[provider_groups__in]")}
|
||||||
|
onBatchChange={setPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const alertEditFilterGrid = hasCustomFilters ? (
|
const alertEditFilterGrid = hasCustomFilters ? (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { ProviderGroup } from "@/types/components";
|
||||||
import { ProviderProps } from "@/types/providers";
|
import { ProviderProps } from "@/types/providers";
|
||||||
import { ScanEntity } from "@/types/scans";
|
import { ScanEntity } from "@/types/scans";
|
||||||
|
|
||||||
@@ -8,6 +9,19 @@ import {
|
|||||||
getFindingsFilterDisplayValue,
|
getFindingsFilterDisplayValue,
|
||||||
} from "./findings-filters.utils";
|
} 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(
|
function makeProvider(
|
||||||
overrides: Partial<ProviderProps> & { id: string },
|
overrides: Partial<ProviderProps> & { id: string },
|
||||||
): ProviderProps {
|
): ProviderProps {
|
||||||
@@ -98,6 +112,24 @@ describe("getFindingsFilterDisplayValue", () => {
|
|||||||
).toBe("missing-provider");
|
).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", () => {
|
it("shows the resolved scan badge label for scan filters instead of formatting the raw scan id", () => {
|
||||||
expect(
|
expect(
|
||||||
getFindingsFilterDisplayValue("filter[scan__in]", "scan-1", { scans }),
|
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", () => {
|
it("treats filter[delta] and filter[delta__in] identically", () => {
|
||||||
// Given
|
// Given
|
||||||
const chipsSingular = buildFindingsFilterChips({
|
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 type { FilterChip } from "@/components/filters/filter-summary-strip";
|
||||||
import { formatLabel, getCategoryLabel, getGroupLabel } from "@/lib/categories";
|
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 { FINDING_STATUS_DISPLAY_NAMES } from "@/types";
|
||||||
import { FilterParam } from "@/types/filters";
|
import { ProviderGroup } from "@/types/components";
|
||||||
import { getProviderDisplayName, ProviderProps } from "@/types/providers";
|
import { getProviderDisplayName, ProviderProps } from "@/types/providers";
|
||||||
import { ScanEntity } from "@/types/scans";
|
import { ScanEntity } from "@/types/scans";
|
||||||
import { SEVERITY_DISPLAY_NAMES } from "@/types/severities";
|
import { SEVERITY_DISPLAY_NAMES } from "@/types/severities";
|
||||||
@@ -10,6 +14,7 @@ import { SEVERITY_DISPLAY_NAMES } from "@/types/severities";
|
|||||||
interface GetFindingsFilterDisplayValueOptions {
|
interface GetFindingsFilterDisplayValueOptions {
|
||||||
providers?: ProviderProps[];
|
providers?: ProviderProps[];
|
||||||
scans?: Array<{ [scanId: string]: ScanEntity }>;
|
scans?: Array<{ [scanId: string]: ScanEntity }>;
|
||||||
|
providerGroups?: ProviderGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const FINDING_DELTA_DISPLAY_NAMES: Record<string, string> = {
|
const FINDING_DELTA_DISPLAY_NAMES: Record<string, string> = {
|
||||||
@@ -42,7 +47,7 @@ function getScanDisplayValue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getFindingsFilterDisplayValue(
|
export function getFindingsFilterDisplayValue(
|
||||||
filterKey: string,
|
filterKey: FindingsFilterParam,
|
||||||
value: string,
|
value: string,
|
||||||
options: GetFindingsFilterDisplayValueOptions = {},
|
options: GetFindingsFilterDisplayValueOptions = {},
|
||||||
): string {
|
): string {
|
||||||
@@ -53,6 +58,9 @@ export function getFindingsFilterDisplayValue(
|
|||||||
if (filterKey === "filter[provider_id__in]") {
|
if (filterKey === "filter[provider_id__in]") {
|
||||||
return getProviderAccountDisplayValue(value, options.providers || []);
|
return getProviderAccountDisplayValue(value, options.providers || []);
|
||||||
}
|
}
|
||||||
|
if (filterKey === "filter[provider_groups__in]") {
|
||||||
|
return getProviderGroupDisplayValue(value, options.providerGroups || []);
|
||||||
|
}
|
||||||
if (filterKey === "filter[scan__in]" || filterKey === "filter[scan]") {
|
if (filterKey === "filter[scan__in]" || filterKey === "filter[scan]") {
|
||||||
return getScanDisplayValue(value, options.scans || []);
|
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.
|
* Maps raw filter param keys (e.g. "filter[severity__in]") to human-readable labels.
|
||||||
* Used to render chips in the FilterSummaryStrip.
|
* Used to render chips in the FilterSummaryStrip.
|
||||||
* Typed as Record<FilterParam, string> so TypeScript enforces exhaustiveness — any
|
* Typed as Record<FindingsFilterParam, string> so TypeScript enforces exhaustiveness
|
||||||
* addition to FilterParam will cause a compile error here if the label is missing.
|
* — 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_type__in]": "Provider",
|
||||||
"filter[provider_id__in]": "Account",
|
"filter[provider_id__in]": "Account",
|
||||||
|
"filter[provider_groups__in]": "Provider Group",
|
||||||
"filter[severity__in]": "Severity",
|
"filter[severity__in]": "Severity",
|
||||||
"filter[status__in]": "Status",
|
"filter[status__in]": "Status",
|
||||||
"filter[delta__in]": "Delta",
|
"filter[delta__in]": "Delta",
|
||||||
@@ -115,12 +125,15 @@ export const FILTER_KEY_LABELS: Record<FilterParam, string> = {
|
|||||||
"filter[scan_id]": "Scan",
|
"filter[scan_id]": "Scan",
|
||||||
"filter[scan_id__in]": "Scan",
|
"filter[scan_id__in]": "Scan",
|
||||||
"filter[inserted_at]": "Date",
|
"filter[inserted_at]": "Date",
|
||||||
|
"filter[inserted_at__gte]": "Date",
|
||||||
|
"filter[inserted_at__lte]": "Date",
|
||||||
"filter[muted]": "Muted",
|
"filter[muted]": "Muted",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface BuildFindingsFilterChipsOptions {
|
interface BuildFindingsFilterChipsOptions {
|
||||||
providers?: ProviderProps[];
|
providers?: ProviderProps[];
|
||||||
scans?: Array<{ [scanId: string]: ScanEntity }>;
|
scans?: Array<{ [scanId: string]: ScanEntity }>;
|
||||||
|
providerGroups?: ProviderGroup[];
|
||||||
includeMuted?: boolean;
|
includeMuted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,13 +155,13 @@ export function buildFindingsFilterChips(
|
|||||||
Object.entries(pendingFilters).forEach(([key, values]) => {
|
Object.entries(pendingFilters).forEach(([key, values]) => {
|
||||||
if (!values || values.length === 0) return;
|
if (!values || values.length === 0) return;
|
||||||
if (key === "filter[muted]" && !options.includeMuted) 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;
|
const visibleValues = values;
|
||||||
if (visibleValues.length === 0) return;
|
if (visibleValues.length === 0) return;
|
||||||
|
|
||||||
const displayValues = visibleValues.map((value) =>
|
const displayValues = visibleValues.map((value) =>
|
||||||
getFindingsFilterDisplayValue(key, value, options),
|
getFindingsFilterDisplayValue(key as FindingsFilterParam, value, options),
|
||||||
);
|
);
|
||||||
|
|
||||||
const chip: FilterChip = {
|
const chip: FilterChip = {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
getTourTargetSelector,
|
getTourTargetSelector,
|
||||||
} from "@/lib/tours/use-driver-tour";
|
} from "@/lib/tours/use-driver-tour";
|
||||||
import type { FilterOption, MetaDataProps, ProviderProps } from "@/types";
|
import type { FilterOption, MetaDataProps, ProviderProps } from "@/types";
|
||||||
|
import type { ProviderGroup } from "@/types/components";
|
||||||
import type { ProvidersTableRow } from "@/types/providers-table";
|
import type { ProvidersTableRow } from "@/types/providers-table";
|
||||||
import type { ScanScheduleCapability } from "@/types/schedules";
|
import type { ScanScheduleCapability } from "@/types/schedules";
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ interface ProvidersAccountsViewProps {
|
|||||||
filters: FilterOption[];
|
filters: FilterOption[];
|
||||||
metadata?: MetaDataProps;
|
metadata?: MetaDataProps;
|
||||||
providers: ProviderProps[];
|
providers: ProviderProps[];
|
||||||
|
providerGroups?: ProviderGroup[];
|
||||||
rows: ProvidersTableRow[];
|
rows: ProvidersTableRow[];
|
||||||
/** Cloud overlay seam for provider-creation scan launch. */
|
/** Cloud overlay seam for provider-creation scan launch. */
|
||||||
scanScheduleCapability?: ScanScheduleCapability;
|
scanScheduleCapability?: ScanScheduleCapability;
|
||||||
@@ -62,6 +64,7 @@ export function ProvidersAccountsView({
|
|||||||
filters,
|
filters,
|
||||||
metadata,
|
metadata,
|
||||||
providers,
|
providers,
|
||||||
|
providerGroups = [],
|
||||||
rows,
|
rows,
|
||||||
scanScheduleCapability,
|
scanScheduleCapability,
|
||||||
isScanLimitReached,
|
isScanLimitReached,
|
||||||
@@ -141,6 +144,7 @@ export function ProvidersAccountsView({
|
|||||||
<ProvidersFilters
|
<ProvidersFilters
|
||||||
filters={filters}
|
filters={filters}
|
||||||
providers={providers}
|
providers={providers}
|
||||||
|
providerGroups={providerGroups}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<MutedFindingsConfigButton />
|
<MutedFindingsConfigButton />
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ vi.mock("@/app/(prowler)/_overview/_components/provider-type-selector", () => ({
|
|||||||
ProviderTypeSelector: () => <div>Provider type selector</div>,
|
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", () => ({
|
vi.mock("@/components/filters/clear-filters-button", () => ({
|
||||||
ClearFiltersButton: () => <button type="button">Clear</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 { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector";
|
||||||
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
|
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
|
||||||
|
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
|
||||||
import {
|
import {
|
||||||
MultiSelect,
|
MultiSelect,
|
||||||
MultiSelectContent,
|
MultiSelectContent,
|
||||||
@@ -18,6 +19,7 @@ import { EntityInfo } from "@/components/ui/entities/entity-info";
|
|||||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||||
import { isConnectionStatus, isGroupFilterEntity } from "@/lib/helper-filters";
|
import { isConnectionStatus, isGroupFilterEntity } from "@/lib/helper-filters";
|
||||||
import { FilterEntity, FilterOption, ProviderEntity } from "@/types";
|
import { FilterEntity, FilterOption, ProviderEntity } from "@/types";
|
||||||
|
import { ProviderGroup } from "@/types/components";
|
||||||
import {
|
import {
|
||||||
GroupFilterEntity,
|
GroupFilterEntity,
|
||||||
ProviderConnectionStatus,
|
ProviderConnectionStatus,
|
||||||
@@ -31,12 +33,14 @@ function isNonEmptyString(value: string | null | undefined): value is string {
|
|||||||
interface ProvidersFiltersProps {
|
interface ProvidersFiltersProps {
|
||||||
filters: FilterOption[];
|
filters: FilterOption[];
|
||||||
providers: ProviderProps[];
|
providers: ProviderProps[];
|
||||||
|
providerGroups?: ProviderGroup[];
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProvidersFilters = ({
|
export const ProvidersFilters = ({
|
||||||
filters,
|
filters,
|
||||||
providers,
|
providers,
|
||||||
|
providerGroups = [],
|
||||||
actions,
|
actions,
|
||||||
}: ProvidersFiltersProps) => {
|
}: ProvidersFiltersProps) => {
|
||||||
const { updateFilter } = useUrlFilters();
|
const { updateFilter } = useUrlFilters();
|
||||||
@@ -153,6 +157,9 @@ export const ProvidersFilters = ({
|
|||||||
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
|
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
|
||||||
<ProviderTypeSelector providers={providers} />
|
<ProviderTypeSelector providers={providers} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="max-w-[240px] min-w-[180px] flex-1">
|
||||||
|
<ProviderGroupSelector groups={providerGroups} />
|
||||||
|
</div>
|
||||||
{sortedFilters.map((filter) => {
|
{sortedFilters.map((filter) => {
|
||||||
const selectedValues = getSelectedValues(filter);
|
const selectedValues = getSelectedValues(filter);
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ import {
|
|||||||
FilterSummaryStrip,
|
FilterSummaryStrip,
|
||||||
} from "@/components/filters/filter-summary-strip";
|
} from "@/components/filters/filter-summary-strip";
|
||||||
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
|
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
|
||||||
|
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
|
||||||
import { Button } from "@/components/shadcn";
|
import { Button } from "@/components/shadcn";
|
||||||
import { ExpandableSection } from "@/components/ui/expandable-section";
|
import { ExpandableSection } from "@/components/ui/expandable-section";
|
||||||
import { DataTableFilterCustom } from "@/components/ui/table";
|
import { DataTableFilterCustom } from "@/components/ui/table";
|
||||||
import { useFilterBatch } from "@/hooks/use-filter-batch";
|
import { useFilterBatch } from "@/hooks/use-filter-batch";
|
||||||
import { getGroupLabel } from "@/lib/categories";
|
import { getGroupLabel } from "@/lib/categories";
|
||||||
|
import { ProviderGroup } from "@/types/components";
|
||||||
import { DATA_TABLE_FILTER_MODE } from "@/types/filters";
|
import { DATA_TABLE_FILTER_MODE } from "@/types/filters";
|
||||||
import { ProviderProps } from "@/types/providers";
|
import { ProviderProps } from "@/types/providers";
|
||||||
|
|
||||||
@@ -26,6 +28,7 @@ import {
|
|||||||
|
|
||||||
interface ResourcesFiltersProps {
|
interface ResourcesFiltersProps {
|
||||||
providers: ProviderProps[];
|
providers: ProviderProps[];
|
||||||
|
providerGroups?: ProviderGroup[];
|
||||||
uniqueRegions: string[];
|
uniqueRegions: string[];
|
||||||
uniqueServices: string[];
|
uniqueServices: string[];
|
||||||
uniqueResourceTypes: string[];
|
uniqueResourceTypes: string[];
|
||||||
@@ -40,6 +43,7 @@ const FILTER_CONTROL_COLUMN_CLASS =
|
|||||||
|
|
||||||
export const ResourcesFilters = ({
|
export const ResourcesFilters = ({
|
||||||
providers,
|
providers,
|
||||||
|
providerGroups = [],
|
||||||
uniqueRegions,
|
uniqueRegions,
|
||||||
uniqueServices,
|
uniqueServices,
|
||||||
uniqueResourceTypes,
|
uniqueResourceTypes,
|
||||||
@@ -93,10 +97,12 @@ export const ResourcesFilters = ({
|
|||||||
const appliedFilterChips: FilterChip[] = buildResourcesFilterChips(
|
const appliedFilterChips: FilterChip[] = buildResourcesFilterChips(
|
||||||
appliedFilters,
|
appliedFilters,
|
||||||
providers,
|
providers,
|
||||||
|
providerGroups,
|
||||||
);
|
);
|
||||||
const pendingFilterChips: FilterChip[] = buildResourcesFilterChips(
|
const pendingFilterChips: FilterChip[] = buildResourcesFilterChips(
|
||||||
changedFilters,
|
changedFilters,
|
||||||
providers,
|
providers,
|
||||||
|
providerGroups,
|
||||||
);
|
);
|
||||||
const appliedCount = countVisibleFilterKeys(appliedFilters);
|
const appliedCount = countVisibleFilterKeys(appliedFilters);
|
||||||
const showAppliedRow = appliedFilterChips.length > 0;
|
const showAppliedRow = appliedFilterChips.length > 0;
|
||||||
@@ -178,6 +184,13 @@ export const ResourcesFilters = ({
|
|||||||
providerSelectorClassName={FILTER_CONTROL_COLUMN_CLASS}
|
providerSelectorClassName={FILTER_CONTROL_COLUMN_CLASS}
|
||||||
accountSelectorClassName={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 && (
|
{hasCustomFilters && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
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 type { FilterChip } from "@/components/filters/filter-summary-strip";
|
||||||
import { formatLabel, getGroupLabel } from "@/lib/categories";
|
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 type { ProviderProps } from "@/types/providers";
|
||||||
import { getProviderDisplayName } 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_type__in]": "Provider",
|
||||||
"filter[provider_id__in]": "Account",
|
"filter[provider_id__in]": "Account",
|
||||||
|
"filter[provider_groups__in]": "Provider Group",
|
||||||
"filter[region__in]": "Region",
|
"filter[region__in]": "Region",
|
||||||
"filter[service__in]": "Service",
|
"filter[service__in]": "Service",
|
||||||
"filter[type__in]": "Type",
|
"filter[type__in]": "Type",
|
||||||
@@ -28,6 +32,7 @@ export function getResourcesFilterDisplayValue(
|
|||||||
filterKey: string,
|
filterKey: string,
|
||||||
value: string,
|
value: string,
|
||||||
providers: ProviderProps[],
|
providers: ProviderProps[],
|
||||||
|
providerGroups: ProviderGroup[] = [],
|
||||||
): string {
|
): string {
|
||||||
if (!value) return value;
|
if (!value) return value;
|
||||||
|
|
||||||
@@ -39,6 +44,10 @@ export function getResourcesFilterDisplayValue(
|
|||||||
return getProviderAccountDisplayValue(value, providers);
|
return getProviderAccountDisplayValue(value, providers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filterKey === "filter[provider_groups__in]") {
|
||||||
|
return getProviderGroupDisplayValue(value, providerGroups);
|
||||||
|
}
|
||||||
|
|
||||||
if (filterKey === "filter[groups__in]") {
|
if (filterKey === "filter[groups__in]") {
|
||||||
return getGroupLabel(value);
|
return getGroupLabel(value);
|
||||||
}
|
}
|
||||||
@@ -53,15 +62,17 @@ export function getResourcesFilterDisplayValue(
|
|||||||
export function buildResourcesFilterChips(
|
export function buildResourcesFilterChips(
|
||||||
pendingFilters: Record<string, string[]>,
|
pendingFilters: Record<string, string[]>,
|
||||||
providers: ProviderProps[],
|
providers: ProviderProps[],
|
||||||
|
providerGroups: ProviderGroup[] = [],
|
||||||
): FilterChip[] {
|
): FilterChip[] {
|
||||||
const chips: FilterChip[] = [];
|
const chips: FilterChip[] = [];
|
||||||
|
|
||||||
Object.entries(pendingFilters).forEach(([key, values]) => {
|
Object.entries(pendingFilters).forEach(([key, values]) => {
|
||||||
if (!values || values.length === 0) return;
|
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) =>
|
const displayValues = values.map((value) =>
|
||||||
getResourcesFilterDisplayValue(key, value, providers),
|
getResourcesFilterDisplayValue(key, value, providers, providerGroups),
|
||||||
);
|
);
|
||||||
|
|
||||||
const chip: FilterChip = {
|
const chip: FilterChip = {
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ vi.mock("@/components/filters/provider-account-selectors", () => ({
|
|||||||
ProviderAccountSelectors: () => <div>Provider account selectors</div>,
|
ProviderAccountSelectors: () => <div>Provider account selectors</div>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/filters/provider-group-selector", () => ({
|
||||||
|
ProviderGroupSelector: () => <div>Provider group selector</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/components/shadcn", () => ({
|
vi.mock("@/components/shadcn", () => ({
|
||||||
Select: ({ children }: { children: React.ReactNode }) => (
|
Select: ({ children }: { children: React.ReactNode }) => (
|
||||||
<div>{children}</div>
|
<div>{children}</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
|
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
|
||||||
|
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -9,7 +10,8 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/shadcn";
|
} from "@/components/shadcn";
|
||||||
import { SCAN_JOBS_TAB, type ScanJobsTab } from "@/types";
|
import { SCAN_JOBS_TAB, type ScanJobsTab } from "@/types";
|
||||||
import { FilterType } from "@/types/filters";
|
import type { ProviderGroup } from "@/types/components";
|
||||||
|
import { FILTER_FIELD } from "@/types/filters";
|
||||||
import type { ProviderProps } from "@/types/providers";
|
import type { ProviderProps } from "@/types/providers";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -19,6 +21,7 @@ import {
|
|||||||
|
|
||||||
interface ScansFilterBarProps {
|
interface ScansFilterBarProps {
|
||||||
providers: ProviderProps[];
|
providers: ProviderProps[];
|
||||||
|
providerGroups?: ProviderGroup[];
|
||||||
activeTab: ScanJobsTab;
|
activeTab: ScanJobsTab;
|
||||||
scheduleType: string;
|
scheduleType: string;
|
||||||
scanStatus: string;
|
scanStatus: string;
|
||||||
@@ -31,6 +34,7 @@ const filterItemClass = "w-full md:w-[calc(50%-0.375rem)] xl:w-60";
|
|||||||
|
|
||||||
export function ScansFilterBar({
|
export function ScansFilterBar({
|
||||||
providers,
|
providers,
|
||||||
|
providerGroups = [],
|
||||||
activeTab,
|
activeTab,
|
||||||
scheduleType,
|
scheduleType,
|
||||||
scanStatus,
|
scanStatus,
|
||||||
@@ -47,13 +51,20 @@ export function ScansFilterBar({
|
|||||||
<>
|
<>
|
||||||
<ProviderAccountSelectors
|
<ProviderAccountSelectors
|
||||||
providers={providers}
|
providers={providers}
|
||||||
accountFilterKey={FilterType.PROVIDER}
|
accountFilterKey={FILTER_FIELD.PROVIDER}
|
||||||
accountValue="id"
|
accountValue="id"
|
||||||
paramsToDeleteOnChange={["page", "scanId"]}
|
paramsToDeleteOnChange={["page", "scanId"]}
|
||||||
providerSelectorClassName={filterItemClass}
|
providerSelectorClassName={filterItemClass}
|
||||||
accountSelectorClassName={filterItemClass}
|
accountSelectorClassName={filterItemClass}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className={filterItemClass}>
|
||||||
|
<ProviderGroupSelector
|
||||||
|
groups={providerGroups}
|
||||||
|
paramsToDeleteOnChange={["page", "scanId"]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{showScheduleTypeFilter && (
|
{showScheduleTypeFilter && (
|
||||||
<Select value={scheduleType} onValueChange={onScheduleTypeChange}>
|
<Select value={scheduleType} onValueChange={onScheduleTypeChange}>
|
||||||
<SelectTrigger aria-label="All Types" className={filterItemClass}>
|
<SelectTrigger aria-label="All Types" className={filterItemClass}>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { buildViewFirstScanTour } from "@/lib/tours/view-first-scan.tour";
|
import { buildViewFirstScanTour } from "@/lib/tours/view-first-scan.tour";
|
||||||
import { useScansStore } from "@/store";
|
import { useScansStore } from "@/store";
|
||||||
import { SCAN_JOBS_TAB, SCAN_TAB_LABELS, type ScanJobsTab } from "@/types";
|
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 { ProviderProps } from "@/types/providers";
|
||||||
import type { ScanScheduleCapability } from "@/types/schedules";
|
import type { ScanScheduleCapability } from "@/types/schedules";
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ import { useScansFilters } from "./use-scans-filters";
|
|||||||
|
|
||||||
interface ScansPageShellProps {
|
interface ScansPageShellProps {
|
||||||
providers: ProviderProps[];
|
providers: ProviderProps[];
|
||||||
|
providerGroups?: ProviderGroup[];
|
||||||
hasManageScansPermission: boolean;
|
hasManageScansPermission: boolean;
|
||||||
activeScanCount?: number;
|
activeScanCount?: number;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -42,6 +44,7 @@ interface ScansPageShellProps {
|
|||||||
|
|
||||||
export function ScansPageShell({
|
export function ScansPageShell({
|
||||||
providers,
|
providers,
|
||||||
|
providerGroups = [],
|
||||||
hasManageScansPermission,
|
hasManageScansPermission,
|
||||||
activeScanCount = 0,
|
activeScanCount = 0,
|
||||||
children,
|
children,
|
||||||
@@ -116,6 +119,7 @@ export function ScansPageShell({
|
|||||||
>
|
>
|
||||||
<ScansFilterBar
|
<ScansFilterBar
|
||||||
providers={providers}
|
providers={providers}
|
||||||
|
providerGroups={providerGroups}
|
||||||
activeTab={filters.activeTab}
|
activeTab={filters.activeTab}
|
||||||
scheduleType={filters.scheduleType}
|
scheduleType={filters.scheduleType}
|
||||||
scanStatus={filters.scanStatus}
|
scanStatus={filters.scanStatus}
|
||||||
|
|||||||
@@ -62,22 +62,22 @@ describe("useFilterBatch", () => {
|
|||||||
expect(result.current.hasChanges).toBe(false);
|
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.
|
// Given — URL from LinkToFindings uses `filter[delta]` (singular), matching the API.
|
||||||
setSearchParams({
|
setSearchParams({
|
||||||
"filter[status__in]": "FAIL",
|
"filter[status__in]": "FAIL",
|
||||||
"filter[delta]": "new",
|
"filter[delta]": "new",
|
||||||
});
|
});
|
||||||
|
|
||||||
const { FilterType } = await import("@/types/filters");
|
const { FILTER_FIELD } = await import("@/types/filters");
|
||||||
|
|
||||||
// When
|
// When
|
||||||
const { result } = renderHook(() => useFilterBatch());
|
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"].
|
// For the checkbox of "new" to appear checked, that lookup must return ["new"].
|
||||||
expect(
|
expect(
|
||||||
result.current.getFilterValue(`filter[${FilterType.DELTA}]`),
|
result.current.getFilterValue(`filter[${FILTER_FIELD.DELTA}]`),
|
||||||
).toEqual(["new"]);
|
).toEqual(["new"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { useSearchParams } from "next/navigation";
|
|||||||
|
|
||||||
import { isScanEntity } from "@/lib/helper-filters";
|
import { isScanEntity } from "@/lib/helper-filters";
|
||||||
import {
|
import {
|
||||||
|
FILTER_FIELD,
|
||||||
FilterEntity,
|
FilterEntity,
|
||||||
FilterType,
|
FilterParam,
|
||||||
ProviderEntity,
|
ProviderEntity,
|
||||||
ProviderType,
|
ProviderType,
|
||||||
ScanEntity,
|
ScanEntity,
|
||||||
@@ -16,7 +17,9 @@ interface UseRelatedFiltersProps {
|
|||||||
completedScanIds?: string[];
|
completedScanIds?: string[];
|
||||||
scanDetails?: { [key: string]: ScanEntity }[];
|
scanDetails?: { [key: string]: ScanEntity }[];
|
||||||
enableScanRelation?: boolean;
|
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 = [],
|
completedScanIds = [],
|
||||||
scanDetails = [],
|
scanDetails = [],
|
||||||
enableScanRelation = false,
|
enableScanRelation = false,
|
||||||
providerFilterType = FilterType.PROVIDER,
|
providerFilterType = FILTER_FIELD.PROVIDER,
|
||||||
}: UseRelatedFiltersProps) => {
|
}: UseRelatedFiltersProps) => {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const providers = providerIds.length > 0 ? providerIds : providerUIDs;
|
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(
|
const providerTypeParam = searchParams.get(
|
||||||
`filter[${FilterType.PROVIDER_TYPE}]`,
|
`filter[${FILTER_FIELD.PROVIDER_TYPE}]` satisfies FilterParam,
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentProviders = providerParam ? providerParam.split(",") : [];
|
const currentProviders = providerParam ? providerParam.split(",") : [];
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { ProviderGroup } from "@/types/components";
|
||||||
import type { ScanEntity } from "@/types/scans";
|
import type { ScanEntity } from "@/types/scans";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
getProviderGroupDisplayValue,
|
||||||
getScanEntityLabel,
|
getScanEntityLabel,
|
||||||
hasDateFilter,
|
hasDateFilter,
|
||||||
hasDateOrScanFilter,
|
hasDateOrScanFilter,
|
||||||
hasHistoricalFindingFilter,
|
hasHistoricalFindingFilter,
|
||||||
} from "./helper-filters";
|
} 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 {
|
function makeScan(overrides: Partial<ScanEntity> = {}): ScanEntity {
|
||||||
return {
|
return {
|
||||||
id: "scan-1",
|
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", () => {
|
describe("hasDateOrScanFilter", () => {
|
||||||
it("returns true for scan filters", () => {
|
it("returns true for scan filters", () => {
|
||||||
expect(hasDateOrScanFilter({ "filter[scan__in]": "scan-1" })).toBe(true);
|
expect(hasDateOrScanFilter({ "filter[scan__in]": "scan-1" })).toBe(true);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ProviderProps, ProvidersApiResponse, ScanProps } from "@/types";
|
import { ProviderProps, ProvidersApiResponse, ScanProps } from "@/types";
|
||||||
|
import { ProviderGroup } from "@/types/components";
|
||||||
import { FilterEntity } from "@/types/filters";
|
import { FilterEntity } from "@/types/filters";
|
||||||
import {
|
import {
|
||||||
getProviderDisplayName,
|
getProviderDisplayName,
|
||||||
@@ -119,6 +120,19 @@ export function getScanEntityLabel(scan: ScanEntity): string {
|
|||||||
return providerLabel || scanName;
|
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.
|
* Creates a scan details mapping for filters from completed scans.
|
||||||
* Used to provide detailed information for scan filters in the UI.
|
* Used to provide detailed information for scan filters in the UI.
|
||||||
|
|||||||
+36
-40
@@ -29,32 +29,44 @@ export interface CustomDropdownFilterProps {
|
|||||||
onFilterChange: (key: string, values: string[]) => void;
|
onFilterChange: (key: string, values: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum FilterType {
|
/**
|
||||||
SCAN = "scan__in",
|
* Filter field names — the inner part of a `filter[...]` URL param key, and the
|
||||||
PROVIDER = "provider__in",
|
* `key` values used to build `FilterOption` dropdown configs. Single source of
|
||||||
PROVIDER_ID = "provider_id__in",
|
* truth for the `FilterParam` template; per-view modules compose their own field
|
||||||
PROVIDER_UID = "provider_uid__in",
|
* set from these plus their own extras.
|
||||||
PROVIDER_TYPE = "provider_type__in",
|
*/
|
||||||
REGION = "region__in",
|
export const FILTER_FIELD = {
|
||||||
SERVICE = "service__in",
|
// core — provider scope + shared resource dimensions (used across views)
|
||||||
RESOURCE_TYPE = "resource_type__in",
|
PROVIDER_TYPE: "provider_type__in",
|
||||||
SEVERITY = "severity__in",
|
PROVIDER_ID: "provider_id__in",
|
||||||
STATUS = "status__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
|
// The API only registers `delta` (exact, singular). `delta__in` is silently
|
||||||
// dropped, so the dropdown, URL, and backend must all use `delta`.
|
// dropped, so the dropdown, URL, and backend must all use `delta`.
|
||||||
DELTA = "delta",
|
DELTA: "delta",
|
||||||
CATEGORY = "category__in",
|
CATEGORY: "category__in",
|
||||||
RESOURCE_GROUPS = "resource_groups__in",
|
RESOURCE_GROUPS: "resource_groups__in",
|
||||||
}
|
} as const;
|
||||||
|
|
||||||
|
export type FilterField = (typeof FILTER_FIELD)[keyof typeof FILTER_FIELD];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter keys the account selectors accept: a provider id (`provider__in` /
|
* Filter keys the account selectors accept: a provider id (`provider__in` /
|
||||||
* `provider_id__in`) or the cloud account uid (`provider_uid__in`).
|
* `provider_id__in`) or the cloud account uid (`provider_uid__in`).
|
||||||
*/
|
*/
|
||||||
export type AccountFilterKey =
|
export type AccountFilterKey = (typeof FILTER_FIELD)[
|
||||||
| FilterType.PROVIDER
|
| "PROVIDER"
|
||||||
| FilterType.PROVIDER_ID
|
| "PROVIDER_ID"
|
||||||
| FilterType.PROVIDER_UID;
|
| "PROVIDER_UID"];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Controls the filter dispatch behavior of DataTableFilterCustom.
|
* Controls the filter dispatch behavior of DataTableFilterCustom.
|
||||||
@@ -70,25 +82,9 @@ export type DataTableFilterMode =
|
|||||||
(typeof DATA_TABLE_FILTER_MODE)[keyof typeof DATA_TABLE_FILTER_MODE];
|
(typeof DATA_TABLE_FILTER_MODE)[keyof typeof DATA_TABLE_FILTER_MODE];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exhaustive union of all URL filter param keys used in Findings filters.
|
* URL filter param key template — wraps a field name in `filter[...]`.
|
||||||
* Use this instead of `string` to ensure FILTER_KEY_LABELS and other
|
* Parameterize with a view's own field union (e.g. `FilterParam<FindingsFilterField>`)
|
||||||
* param-keyed records stay in sync with the actual filter surface.
|
* so each view's param-keyed records stay in sync with the filters it supports.
|
||||||
*/
|
*/
|
||||||
export type FilterParam =
|
export type FilterParam<Field extends string = FilterField> =
|
||||||
| "filter[provider_type__in]"
|
`filter[${Field}]`;
|
||||||
| "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]";
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MetaDataProps } from "./components";
|
import { MetaDataProps, ProviderGroup } from "./components";
|
||||||
import { FilterOption } from "./filters";
|
import { FilterOption } from "./filters";
|
||||||
import {
|
import {
|
||||||
OrganizationResource,
|
OrganizationResource,
|
||||||
@@ -86,6 +86,7 @@ export interface ProvidersAccountsViewData {
|
|||||||
filters: FilterOption[];
|
filters: FilterOption[];
|
||||||
metadata?: MetaDataProps;
|
metadata?: MetaDataProps;
|
||||||
providers: ProviderProps[];
|
providers: ProviderProps[];
|
||||||
|
providerGroups: ProviderGroup[];
|
||||||
rows: ProvidersTableRow[];
|
rows: ProvidersTableRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user