mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-01-25 02:08:11 +00:00
feat: filters relationships in findings and scans page (#8046)
Co-authored-by: Pablo Lara <larabjj@gmail.com>
This commit is contained in:
@@ -27,6 +27,7 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
- Aligned Next.js version to `v14.2.29` across Prowler and Cloud environments for consistency and improved maintainability [(#7962)](https://github.com/prowler-cloud/prowler/pull/7962)
|
||||
- Refactor credentials forms with reusable components and error handling [(#7988)](https://github.com/prowler-cloud/prowler/pull/7988)
|
||||
- Updated the provider details section in Scan and Findings detail pages [(#7968)](https://github.com/prowler-cloud/prowler/pull/7968)
|
||||
- Improve filter behaviour and relationships between filters in findings page [(#8046)](https://github.com/prowler-cloud/prowler/pull/8046)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
|
||||
@@ -9,14 +9,13 @@ import {
|
||||
} from "@/actions/findings";
|
||||
import { getProviders } from "@/actions/providers";
|
||||
import { getScans } from "@/actions/scans";
|
||||
import { filterFindings } from "@/components/filters/data-filters";
|
||||
import { FilterControls } from "@/components/filters/filter-controls";
|
||||
import { FindingsFilters } from "@/components/findings/findings-filters";
|
||||
import {
|
||||
ColumnFindings,
|
||||
SkeletonTableFindings,
|
||||
} from "@/components/findings/table";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import {
|
||||
createDict,
|
||||
createScanDetailsMapping,
|
||||
@@ -28,7 +27,7 @@ import {
|
||||
createProviderDetailsMapping,
|
||||
extractProviderUIDs,
|
||||
} from "@/lib/provider-helpers";
|
||||
import { ScanProps } from "@/types";
|
||||
import { FilterEntity, ScanEntity, ScanProps } from "@/types";
|
||||
import { FindingProps, SearchParamsProps } from "@/types/components";
|
||||
|
||||
export default async function Findings({
|
||||
@@ -61,21 +60,11 @@ export default async function Findings({
|
||||
// Extract provider UIDs and details using helper functions
|
||||
const providerUIDs = providersData ? extractProviderUIDs(providersData) : [];
|
||||
const providerDetails = providersData
|
||||
? createProviderDetailsMapping(providerUIDs, providersData)
|
||||
? (createProviderDetailsMapping(providerUIDs, providersData) as {
|
||||
[uid: string]: FilterEntity;
|
||||
}[])
|
||||
: [];
|
||||
|
||||
// Update the Provider UID filter
|
||||
const updatedFilters = filterFindings.map((filter) => {
|
||||
if (filter.key === "provider_uid__in") {
|
||||
return {
|
||||
...filter,
|
||||
values: providerUIDs,
|
||||
valueLabelMapping: providerDetails,
|
||||
};
|
||||
}
|
||||
return filter;
|
||||
});
|
||||
|
||||
// Extract scan UUIDs with "completed" state and more than one resource
|
||||
const completedScans = scansData?.data?.filter(
|
||||
(scan: ScanProps) =>
|
||||
@@ -86,45 +75,24 @@ export default async function Findings({
|
||||
const completedScanIds =
|
||||
completedScans?.map((scan: ScanProps) => scan.id) || [];
|
||||
|
||||
const scanDetails = createScanDetailsMapping(completedScans, providersData);
|
||||
const scanDetails = createScanDetailsMapping(
|
||||
completedScans,
|
||||
providersData,
|
||||
) as { [uid: string]: ScanEntity }[];
|
||||
|
||||
return (
|
||||
<ContentLayout title="Findings" icon="carbon:data-view-alt">
|
||||
<FilterControls search date />
|
||||
<Spacer y={8} />
|
||||
<DataTableFilterCustom
|
||||
filters={[
|
||||
...updatedFilters,
|
||||
{
|
||||
key: "region__in",
|
||||
labelCheckboxGroup: "Regions",
|
||||
values: uniqueRegions,
|
||||
index: 5,
|
||||
},
|
||||
{
|
||||
key: "service__in",
|
||||
labelCheckboxGroup: "Services",
|
||||
values: uniqueServices,
|
||||
index: 6,
|
||||
},
|
||||
{
|
||||
key: "resource_type__in",
|
||||
labelCheckboxGroup: "Resource Type",
|
||||
values: uniqueResourceTypes,
|
||||
index: 7,
|
||||
},
|
||||
{
|
||||
key: "scan__in",
|
||||
labelCheckboxGroup: "Scan ID",
|
||||
values: completedScanIds,
|
||||
valueLabelMapping: scanDetails,
|
||||
index: 9,
|
||||
},
|
||||
]}
|
||||
defaultOpen={true}
|
||||
<FindingsFilters
|
||||
providerUIDs={providerUIDs}
|
||||
providerDetails={providerDetails}
|
||||
completedScans={completedScans || []}
|
||||
completedScanIds={completedScanIds}
|
||||
scanDetails={scanDetails}
|
||||
uniqueRegions={uniqueRegions}
|
||||
uniqueServices={uniqueServices}
|
||||
uniqueResourceTypes={uniqueResourceTypes}
|
||||
/>
|
||||
<Spacer y={8} />
|
||||
|
||||
<Suspense key={searchParamsKey} fallback={<SkeletonTableFindings />}>
|
||||
<SSRDataTable searchParams={searchParams} />
|
||||
</Suspense>
|
||||
|
||||
@@ -33,37 +33,35 @@ export default function Home({
|
||||
const searchParamsKey = JSON.stringify(searchParams || {});
|
||||
return (
|
||||
<ContentLayout title="Overview" icon="solar:pie-chart-2-outline">
|
||||
<Spacer y={4} />
|
||||
<FilterControls providers />
|
||||
<div className="mx-auto space-y-8 px-0 py-6">
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
<div className="col-span-12 lg:col-span-4">
|
||||
<Suspense fallback={<SkeletonProvidersOverview />}>
|
||||
<SSRProvidersOverview />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 lg:col-span-4">
|
||||
<Suspense fallback={<SkeletonFindingsBySeverityChart />}>
|
||||
<SSRFindingsBySeverity searchParams={searchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-12 lg:gap-6">
|
||||
<div className="col-span-12 lg:col-span-4">
|
||||
<Suspense fallback={<SkeletonProvidersOverview />}>
|
||||
<SSRProvidersOverview />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 lg:col-span-4">
|
||||
<Suspense fallback={<SkeletonFindingsByStatusChart />}>
|
||||
<SSRFindingsByStatus searchParams={searchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
<div className="col-span-12 lg:col-span-4">
|
||||
<Suspense fallback={<SkeletonFindingsBySeverityChart />}>
|
||||
<SSRFindingsBySeverity searchParams={searchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<div className="col-span-12">
|
||||
<Spacer y={16} />
|
||||
<Suspense
|
||||
key={searchParamsKey}
|
||||
fallback={<SkeletonTableNewFindings />}
|
||||
>
|
||||
<SSRDataNewFindingsTable />
|
||||
</Suspense>
|
||||
</div>
|
||||
<div className="col-span-12 lg:col-span-4">
|
||||
<Suspense fallback={<SkeletonFindingsByStatusChart />}>
|
||||
<SSRFindingsByStatus searchParams={searchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<div className="col-span-12">
|
||||
<Spacer y={16} />
|
||||
<Suspense
|
||||
key={searchParamsKey}
|
||||
fallback={<SkeletonTableNewFindings />}
|
||||
>
|
||||
<SSRDataNewFindingsTable />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</ContentLayout>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
SkeletonTableProviders,
|
||||
} from "@/components/providers/table";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { ProviderProps, SearchParamsProps } from "@/types";
|
||||
|
||||
export default async function Providers({
|
||||
@@ -22,14 +22,12 @@ export default async function Providers({
|
||||
|
||||
return (
|
||||
<ContentLayout title="Cloud Providers" icon="fluent:cloud-sync-24-regular">
|
||||
<FilterControls search />
|
||||
<FilterControls search customFilters={filterProviders || []} />
|
||||
<Spacer y={8} />
|
||||
<div className="flex items-center gap-4 md:justify-end">
|
||||
<ManageGroupsButton />
|
||||
<AddProviderButton />
|
||||
</div>
|
||||
<Spacer y={4} />
|
||||
<DataTableFilterCustom filters={filterProviders || []} />
|
||||
<Spacer y={8} />
|
||||
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
|
||||
@@ -3,17 +3,17 @@ import { Suspense } from "react";
|
||||
|
||||
import { getProvider, getProviders } from "@/actions/providers";
|
||||
import { getScans, getScansByState } from "@/actions/scans";
|
||||
import { FilterControls, filterScans } from "@/components/filters";
|
||||
import {
|
||||
AutoRefresh,
|
||||
NoProvidersAdded,
|
||||
NoProvidersConnected,
|
||||
ScansFilters,
|
||||
} from "@/components/scans";
|
||||
import { LaunchScanWorkflow } from "@/components/scans/launch-workflow";
|
||||
import { SkeletonTableScans } from "@/components/scans/table";
|
||||
import { ColumnGetScans } from "@/components/scans/table/scans";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import {
|
||||
createProviderDetailsMapping,
|
||||
extractProviderUIDs,
|
||||
@@ -49,8 +49,7 @@ export default async function Scans({
|
||||
filters: { "filter[connected]": true },
|
||||
pageSize: 50,
|
||||
});
|
||||
const thereIsNoProviders =
|
||||
!providersCountConnected?.data || providersCountConnected.data.length === 0;
|
||||
const thereIsNoProviders = !providersCountConnected?.data;
|
||||
|
||||
const thereIsNoProvidersConnected = providersCountConnected?.data?.every(
|
||||
(provider: ProviderProps) => !provider.attributes.connection.connected,
|
||||
@@ -71,59 +70,37 @@ export default async function Scans({
|
||||
? createProviderDetailsMapping(providerUIDs, providersData)
|
||||
: [];
|
||||
|
||||
// Update the Provider UID filter
|
||||
const updatedFilters = filterScans.map((filter) => {
|
||||
if (filter.key === "provider_uid__in") {
|
||||
return {
|
||||
...filter,
|
||||
values: providerUIDs,
|
||||
valueLabelMapping: providerDetails,
|
||||
};
|
||||
}
|
||||
return filter;
|
||||
});
|
||||
if (thereIsNoProviders) {
|
||||
return (
|
||||
<ContentLayout title="Scans" icon="lucide:scan-search">
|
||||
<NoProvidersAdded />
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{thereIsNoProviders && (
|
||||
<>
|
||||
<Spacer y={4} />
|
||||
<NoProvidersAdded />
|
||||
</>
|
||||
)}
|
||||
|
||||
{!thereIsNoProviders && (
|
||||
<>
|
||||
{thereIsNoProvidersConnected ? (
|
||||
<ContentLayout title="Scans" icon="lucide:scan-search">
|
||||
<Spacer y={8} />
|
||||
<NoProvidersConnected />
|
||||
<Spacer y={8} />
|
||||
</ContentLayout>
|
||||
) : (
|
||||
<ContentLayout title="Scans" icon="lucide:scan-search">
|
||||
<AutoRefresh hasExecutingScan={hasExecutingScan} />
|
||||
<LaunchScanWorkflow providers={providerInfo} />
|
||||
<Spacer y={8} />
|
||||
</ContentLayout>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-12 items-start gap-4 px-6 py-4 sm:px-8 xl:px-10">
|
||||
<div className="col-span-12">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<DataTableFilterCustom filters={updatedFilters || []} />
|
||||
<Spacer x={4} />
|
||||
<FilterControls />
|
||||
</div>
|
||||
<Spacer y={8} />
|
||||
<Suspense key={searchParamsKey} fallback={<SkeletonTableScans />}>
|
||||
<SSRDataTableScans searchParams={searchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
<ContentLayout title="Scans" icon="lucide:scan-search">
|
||||
<AutoRefresh hasExecutingScan={hasExecutingScan} />
|
||||
<>
|
||||
{thereIsNoProvidersConnected ? (
|
||||
<>
|
||||
<Spacer y={8} />
|
||||
<NoProvidersConnected />
|
||||
<Spacer y={8} />
|
||||
</>
|
||||
) : (
|
||||
<LaunchScanWorkflow providers={providerInfo} />
|
||||
)}
|
||||
<ScansFilters
|
||||
providerUIDs={providerUIDs}
|
||||
providerDetails={providerDetails}
|
||||
/>
|
||||
<Spacer y={8} />
|
||||
<Suspense key={searchParamsKey} fallback={<SkeletonTableScans />}>
|
||||
<SSRDataTableScans searchParams={searchParams} />
|
||||
</Suspense>
|
||||
</>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -58,11 +58,10 @@ export const ComplianceHeader = ({
|
||||
<>
|
||||
{(showProviders || showSearch) && (
|
||||
<>
|
||||
<div className="flex items-center justify-start gap-4">
|
||||
<div className="flex items-start justify-start gap-4">
|
||||
{showProviders && <DataCompliance scans={scans} />}
|
||||
{showSearch && <FilterControls search />}
|
||||
</div>
|
||||
<Spacer y={8} />
|
||||
</>
|
||||
)}
|
||||
{allFilters.length > 0 && (
|
||||
|
||||
38
ui/components/filters/clear-filters-button.tsx
Normal file
38
ui/components/filters/clear-filters-button.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { CrossIcon } from "@/components/icons";
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
|
||||
import { CustomButton } from "../ui/custom/custom-button";
|
||||
|
||||
export interface ClearFiltersButtonProps {
|
||||
className?: string;
|
||||
text?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export const ClearFiltersButton = ({
|
||||
className = "w-full md:w-fit",
|
||||
text = "Clear all filters",
|
||||
ariaLabel = "Reset",
|
||||
}: ClearFiltersButtonProps) => {
|
||||
const { clearAllFilters, hasFilters } = useUrlFilters();
|
||||
|
||||
if (!hasFilters()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomButton
|
||||
ariaLabel={ariaLabel}
|
||||
className={className}
|
||||
onPress={clearAllFilters}
|
||||
variant="dashed"
|
||||
size="md"
|
||||
endContent={<CrossIcon size={24} />}
|
||||
radius="sm"
|
||||
>
|
||||
{text}
|
||||
</CustomButton>
|
||||
);
|
||||
};
|
||||
@@ -74,7 +74,7 @@ export const CustomSelectProvider: React.FC = () => {
|
||||
placeholder="Select a provider"
|
||||
classNames={{
|
||||
selectorIcon: "right-2",
|
||||
label: "!z-0",
|
||||
label: "!z-0 mb-2",
|
||||
}}
|
||||
label="Provider"
|
||||
labelPlacement="inside"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { FilterType } from "@/types/filters";
|
||||
|
||||
export const filterProviders = [
|
||||
{
|
||||
key: "connected",
|
||||
@@ -12,6 +14,7 @@ export const filterScans = [
|
||||
key: "provider_type__in",
|
||||
labelCheckboxGroup: "Cloud Provider",
|
||||
values: ["aws", "azure", "m365", "gcp", "kubernetes"],
|
||||
index: 0,
|
||||
},
|
||||
{
|
||||
key: "state__in",
|
||||
@@ -24,52 +27,43 @@ export const filterScans = [
|
||||
"failed",
|
||||
"cancelled",
|
||||
],
|
||||
index: 2,
|
||||
},
|
||||
{
|
||||
key: "trigger",
|
||||
labelCheckboxGroup: "Trigger",
|
||||
values: ["scheduled", "manual"],
|
||||
},
|
||||
{
|
||||
key: "provider_uid__in",
|
||||
labelCheckboxGroup: "Provider UID",
|
||||
values: [],
|
||||
index: 3,
|
||||
},
|
||||
// Add more filter categories as needed
|
||||
];
|
||||
|
||||
//Static filters for findings
|
||||
export const filterFindings = [
|
||||
{
|
||||
key: "severity__in",
|
||||
key: FilterType.SEVERITY,
|
||||
labelCheckboxGroup: "Severity",
|
||||
values: ["critical", "high", "medium", "low", "informational"],
|
||||
index: 0,
|
||||
},
|
||||
{
|
||||
key: FilterType.STATUS,
|
||||
labelCheckboxGroup: "Status",
|
||||
values: ["PASS", "FAIL", "MANUAL"],
|
||||
index: 1,
|
||||
},
|
||||
{
|
||||
key: "status__in",
|
||||
labelCheckboxGroup: "Status",
|
||||
values: ["PASS", "FAIL", "MANUAL"],
|
||||
index: 2,
|
||||
},
|
||||
{
|
||||
key: "provider_type__in",
|
||||
key: FilterType.PROVIDER_TYPE,
|
||||
labelCheckboxGroup: "Cloud Provider",
|
||||
values: ["aws", "azure", "m365", "gcp", "kubernetes"],
|
||||
index: 4,
|
||||
index: 5,
|
||||
},
|
||||
{
|
||||
key: "provider_uid__in",
|
||||
labelCheckboxGroup: "Provider UID",
|
||||
values: [],
|
||||
index: 8,
|
||||
},
|
||||
{
|
||||
key: "delta__in",
|
||||
key: FilterType.DELTA,
|
||||
labelCheckboxGroup: "Delta",
|
||||
values: ["new", "changed"],
|
||||
index: 3,
|
||||
index: 2,
|
||||
},
|
||||
// Add more filter categories as needed
|
||||
];
|
||||
|
||||
export const filterUsers = [
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Spacer } from "@nextui-org/react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
import { FilterControlsProps } from "@/types";
|
||||
|
||||
import { CrossIcon } from "../icons";
|
||||
import { CustomButton } from "../ui/custom";
|
||||
import { DataTableFilterCustom } from "../ui/table";
|
||||
import { ClearFiltersButton } from "./clear-filters-button";
|
||||
import { CustomAccountSelection } from "./custom-account-selection";
|
||||
import { CustomCheckboxMutedFindings } from "./custom-checkbox-muted-findings";
|
||||
import { CustomDatePicker } from "./custom-date-picker";
|
||||
@@ -26,7 +25,6 @@ export const FilterControls: React.FC<FilterControlsProps> = ({
|
||||
customFilters,
|
||||
}) => {
|
||||
const searchParams = useSearchParams();
|
||||
const { clearAllFilters } = useUrlFilters();
|
||||
const [showClearButton, setShowClearButton] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -37,7 +35,7 @@ export const FilterControls: React.FC<FilterControlsProps> = ({
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col">
|
||||
<div className="grid grid-cols-1 items-center gap-x-4 gap-y-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{search && <CustomSearchInput />}
|
||||
{providers && <CustomSelectProvider />}
|
||||
@@ -45,22 +43,16 @@ export const FilterControls: React.FC<FilterControlsProps> = ({
|
||||
{regions && <CustomRegionSelection />}
|
||||
{accounts && <CustomAccountSelection />}
|
||||
{mutedFindings && <CustomCheckboxMutedFindings />}
|
||||
|
||||
{showClearButton && (
|
||||
<CustomButton
|
||||
ariaLabel="Reset"
|
||||
className="w-full md:w-fit"
|
||||
onPress={clearAllFilters}
|
||||
variant="dashed"
|
||||
size="md"
|
||||
endContent={<CrossIcon size={24} />}
|
||||
radius="sm"
|
||||
>
|
||||
Clear all filters
|
||||
</CustomButton>
|
||||
)}
|
||||
{!customFilters && showClearButton && <ClearFiltersButton />}
|
||||
</div>
|
||||
{customFilters && <DataTableFilterCustom filters={customFilters} />}
|
||||
<Spacer y={8} />
|
||||
{customFilters && (
|
||||
<DataTableFilterCustom
|
||||
filters={customFilters}
|
||||
showClearButton={showClearButton}
|
||||
defaultOpen
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./clear-filters-button";
|
||||
export * from "./custom-account-selection";
|
||||
export * from "./custom-checkbox-muted-findings";
|
||||
export * from "./custom-date-picker";
|
||||
|
||||
79
ui/components/findings/findings-filters.tsx
Normal file
79
ui/components/findings/findings-filters.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { filterFindings } from "@/components/filters/data-filters";
|
||||
import { FilterControls } from "@/components/filters/filter-controls";
|
||||
import { useRelatedFilters } from "@/hooks";
|
||||
import { FilterEntity, FilterType, ScanEntity, ScanProps } from "@/types";
|
||||
|
||||
interface FindingsFiltersProps {
|
||||
providerUIDs: string[];
|
||||
providerDetails: { [uid: string]: FilterEntity }[];
|
||||
completedScans: ScanProps[];
|
||||
completedScanIds: string[];
|
||||
scanDetails: { [key: string]: ScanEntity }[];
|
||||
uniqueRegions: string[];
|
||||
uniqueServices: string[];
|
||||
uniqueResourceTypes: string[];
|
||||
}
|
||||
|
||||
export const FindingsFilters = ({
|
||||
providerUIDs,
|
||||
providerDetails,
|
||||
completedScanIds,
|
||||
scanDetails,
|
||||
uniqueRegions,
|
||||
uniqueServices,
|
||||
uniqueResourceTypes,
|
||||
}: FindingsFiltersProps) => {
|
||||
const { availableProviderUIDs, availableScans } = useRelatedFilters({
|
||||
providerUIDs,
|
||||
providerDetails,
|
||||
completedScanIds,
|
||||
scanDetails,
|
||||
enableScanRelation: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterControls
|
||||
search
|
||||
date
|
||||
customFilters={[
|
||||
...filterFindings,
|
||||
{
|
||||
key: FilterType.PROVIDER_UID,
|
||||
labelCheckboxGroup: "Provider UID",
|
||||
values: availableProviderUIDs,
|
||||
valueLabelMapping: providerDetails,
|
||||
index: 6,
|
||||
},
|
||||
{
|
||||
key: FilterType.REGION,
|
||||
labelCheckboxGroup: "Regions",
|
||||
values: uniqueRegions,
|
||||
index: 3,
|
||||
},
|
||||
{
|
||||
key: FilterType.SERVICE,
|
||||
labelCheckboxGroup: "Services",
|
||||
values: uniqueServices,
|
||||
index: 4,
|
||||
},
|
||||
{
|
||||
key: FilterType.RESOURCE_TYPE,
|
||||
labelCheckboxGroup: "Resource Type",
|
||||
values: uniqueResourceTypes,
|
||||
index: 8,
|
||||
},
|
||||
{
|
||||
key: FilterType.SCAN,
|
||||
labelCheckboxGroup: "Scan ID",
|
||||
values: availableScans,
|
||||
valueLabelMapping: scanDetails,
|
||||
index: 7,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -50,21 +50,11 @@ export const ProviderInfo: React.FC<ProviderInfoProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dark:bg-prowler-blue-400">
|
||||
<div className="grid grid-cols-1">
|
||||
<div className="flex items-center text-sm">
|
||||
<div className="flex items-center">
|
||||
<span className="flex items-center justify-center px-4">
|
||||
{getProviderLogo(provider)}
|
||||
</span>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex-shrink-0">{getIcon()}</div>
|
||||
<span className="font-medium">
|
||||
{providerAlias || providerUID}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center text-sm">
|
||||
<div className="flex items-center space-x-4">
|
||||
{getProviderLogo(provider)}
|
||||
{getIcon()}
|
||||
<span className="font-medium">{providerAlias || providerUID}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from "./auto-refresh";
|
||||
export * from "./link-to-findings-from-scan";
|
||||
export * from "./no-providers-added";
|
||||
export * from "./no-providers-connected";
|
||||
export * from "./scans-filters";
|
||||
|
||||
@@ -8,7 +8,7 @@ import { CustomButton } from "../ui/custom";
|
||||
|
||||
export const NoProvidersAdded = () => {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center dark:bg-prowler-blue-800">
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="mx-auto w-full max-w-7xl px-4">
|
||||
<Card className="mx-auto w-full max-w-3xl rounded-lg dark:bg-prowler-blue-400">
|
||||
<CardBody className="flex flex-col items-center space-y-4 p-6 text-center sm:p-8">
|
||||
|
||||
37
ui/components/scans/scans-filters.tsx
Normal file
37
ui/components/scans/scans-filters.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { filterScans } from "@/components/filters/data-filters";
|
||||
import { FilterControls } from "@/components/filters/filter-controls";
|
||||
import { useRelatedFilters } from "@/hooks";
|
||||
import { FilterEntity, FilterType } from "@/types";
|
||||
|
||||
interface ScansFiltersProps {
|
||||
providerUIDs: string[];
|
||||
providerDetails: { [uid: string]: FilterEntity }[];
|
||||
}
|
||||
|
||||
export const ScansFilters = ({
|
||||
providerUIDs,
|
||||
providerDetails,
|
||||
}: ScansFiltersProps) => {
|
||||
const { availableProviderUIDs } = useRelatedFilters({
|
||||
providerUIDs,
|
||||
providerDetails,
|
||||
enableScanRelation: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<FilterControls
|
||||
customFilters={[
|
||||
...filterScans,
|
||||
{
|
||||
key: FilterType.PROVIDER_UID,
|
||||
labelCheckboxGroup: "Provider UID",
|
||||
values: availableProviderUIDs,
|
||||
valueLabelMapping: providerDetails,
|
||||
index: 1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -282,7 +282,10 @@ export const CustomDropdownFilter = ({
|
||||
onValueChange={onSelectionChange}
|
||||
className="font-bold"
|
||||
>
|
||||
{filter?.showSelectAll !== false && (
|
||||
{filterValues.length === 0 && (
|
||||
<span className="text-small font-normal">No results found</span>
|
||||
)}
|
||||
{filter?.showSelectAll !== false && filterValues.length > 0 && (
|
||||
<>
|
||||
<Checkbox
|
||||
classNames={{
|
||||
@@ -296,45 +299,49 @@ export const CustomDropdownFilter = ({
|
||||
<Divider orientation="horizontal" className="mt-2" />
|
||||
</>
|
||||
)}
|
||||
<ScrollShadow
|
||||
hideScrollBar
|
||||
className="flex max-h-96 max-w-full flex-col gap-y-2 py-2"
|
||||
>
|
||||
{filterValues.map((value) => {
|
||||
const entity: FilterEntity | undefined =
|
||||
filter.valueLabelMapping?.find((entry) => entry[value])?.[
|
||||
value
|
||||
];
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
classNames={{
|
||||
label: "text-small font-normal",
|
||||
wrapper: "checkbox-update",
|
||||
}}
|
||||
key={value}
|
||||
value={value}
|
||||
>
|
||||
{entity ? (
|
||||
isScanEntity(entity as ScanEntity) ? (
|
||||
<ComplianceScanInfo scan={entity as ScanEntity} />
|
||||
) : (
|
||||
<EntityInfoShort
|
||||
cloudProvider={(entity as ProviderEntity).provider}
|
||||
entityAlias={
|
||||
(entity as ProviderEntity).alias ?? undefined
|
||||
}
|
||||
entityId={(entity as ProviderEntity).uid}
|
||||
hideCopyButton
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
{filterValues.length > 0 && (
|
||||
<ScrollShadow
|
||||
hideScrollBar
|
||||
className="flex max-h-96 max-w-full flex-col gap-y-2 py-2"
|
||||
>
|
||||
{filterValues.map((value) => {
|
||||
const entity: FilterEntity | undefined =
|
||||
filter.valueLabelMapping?.find((entry) => entry[value])?.[
|
||||
value
|
||||
)}
|
||||
</Checkbox>
|
||||
);
|
||||
})}
|
||||
</ScrollShadow>
|
||||
];
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
classNames={{
|
||||
label: "text-small font-normal",
|
||||
wrapper: "checkbox-update",
|
||||
}}
|
||||
key={value}
|
||||
value={value}
|
||||
>
|
||||
{entity ? (
|
||||
isScanEntity(entity as ScanEntity) ? (
|
||||
<ComplianceScanInfo scan={entity as ScanEntity} />
|
||||
) : (
|
||||
<EntityInfoShort
|
||||
cloudProvider={
|
||||
(entity as ProviderEntity).provider
|
||||
}
|
||||
entityAlias={
|
||||
(entity as ProviderEntity).alias ?? undefined
|
||||
}
|
||||
entityId={(entity as ProviderEntity).uid}
|
||||
hideCopyButton
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
value
|
||||
)}
|
||||
</Checkbox>
|
||||
);
|
||||
})}
|
||||
</ScrollShadow>
|
||||
)}
|
||||
</CheckboxGroup>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useState } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import { ClearFiltersButton } from "@/components/filters";
|
||||
import { CustomFilterIcon } from "@/components/icons";
|
||||
import { CustomButton, CustomDropdownFilter } from "@/components/ui/custom";
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
@@ -11,11 +12,13 @@ import { FilterOption } from "@/types";
|
||||
export interface DataTableFilterCustomProps {
|
||||
filters: FilterOption[];
|
||||
defaultOpen?: boolean;
|
||||
showClearButton?: boolean;
|
||||
}
|
||||
|
||||
export const DataTableFilterCustom = ({
|
||||
filters,
|
||||
defaultOpen = false,
|
||||
showClearButton = false,
|
||||
}: DataTableFilterCustomProps) => {
|
||||
const { updateFilter } = useUrlFilters();
|
||||
const [showFilters, setShowFilters] = useState(defaultOpen);
|
||||
@@ -43,39 +46,33 @@ export const DataTableFilterCustom = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex ${
|
||||
sortedFilters.length > 4 ? "flex-col" : "flex-col md:flex-row"
|
||||
} gap-4`}
|
||||
>
|
||||
<CustomButton
|
||||
ariaLabel={showFilters ? "Hide Filters" : "Show Filters"}
|
||||
variant="flat"
|
||||
color={showFilters ? "action" : "primary"}
|
||||
size="md"
|
||||
startContent={<CustomFilterIcon size={16} />}
|
||||
onPress={() => setShowFilters(!showFilters)}
|
||||
className="w-full max-w-fit"
|
||||
>
|
||||
<h3 className="text-small">
|
||||
{showFilters ? "Hide Filters" : "Show Filters"}
|
||||
</h3>
|
||||
</CustomButton>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-row gap-4">
|
||||
<CustomButton
|
||||
ariaLabel={showFilters ? "Hide Filters" : "Show Filters"}
|
||||
variant="flat"
|
||||
color={showFilters ? "action" : "primary"}
|
||||
size="md"
|
||||
startContent={<CustomFilterIcon size={16} />}
|
||||
onPress={() => setShowFilters(!showFilters)}
|
||||
className="w-full max-w-fit"
|
||||
>
|
||||
<h3 className="text-small">
|
||||
{showFilters ? "Hide Filters" : "Show Filters"}
|
||||
</h3>
|
||||
</CustomButton>
|
||||
|
||||
{showClearButton && <ClearFiltersButton />}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`transition-all duration-700 ease-in-out ${
|
||||
showFilters
|
||||
? "max-h-96 w-full translate-x-0 overflow-visible opacity-100"
|
||||
: "max-h-0 -translate-x-full overflow-hidden opacity-0"
|
||||
? "w-full translate-x-0 overflow-visible opacity-100"
|
||||
: "mt-[-16px] max-h-0 -translate-x-full overflow-hidden opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`grid gap-4 ${
|
||||
sortedFilters.length >= 4
|
||||
? "grid-cols-1 md:grid-cols-4"
|
||||
: "grid-cols-1 md:grid-cols-3"
|
||||
}`}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
{sortedFilters.map((filter) => (
|
||||
<CustomDropdownFilter
|
||||
key={filter.key}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./use-credentials-form";
|
||||
export * from "./use-form-server-errors";
|
||||
export * from "./use-local-storage";
|
||||
export * from "./use-related-filters";
|
||||
export * from "./use-sidebar";
|
||||
export * from "./use-store";
|
||||
|
||||
213
ui/hooks/use-related-filters.ts
Normal file
213
ui/hooks/use-related-filters.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
import { isScanEntity } from "@/lib/helper-filters";
|
||||
import {
|
||||
FilterEntity,
|
||||
FilterType,
|
||||
ProviderEntity,
|
||||
ProviderType,
|
||||
ScanEntity,
|
||||
} from "@/types";
|
||||
|
||||
interface UseRelatedFiltersProps {
|
||||
providerUIDs: string[];
|
||||
providerDetails: { [uid: string]: FilterEntity }[];
|
||||
completedScanIds?: string[];
|
||||
scanDetails?: { [key: string]: ScanEntity }[];
|
||||
enableScanRelation?: boolean;
|
||||
}
|
||||
|
||||
export const useRelatedFilters = ({
|
||||
providerUIDs,
|
||||
providerDetails,
|
||||
completedScanIds = [],
|
||||
scanDetails = [],
|
||||
enableScanRelation = false,
|
||||
}: UseRelatedFiltersProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
const { updateFilter } = useUrlFilters();
|
||||
const [availableScans, setAvailableScans] =
|
||||
useState<string[]>(completedScanIds);
|
||||
const [availableProviderUIDs, setAvailableProviderUIDs] =
|
||||
useState<string[]>(providerUIDs);
|
||||
const previousProviders = useRef<string[]>([]);
|
||||
const previousProviderTypes = useRef<ProviderType[]>([]);
|
||||
const isManualDeselection = useRef(false);
|
||||
|
||||
const getScanProvider = (scanId: string) => {
|
||||
if (!enableScanRelation) return null;
|
||||
const scanDetail = scanDetails.find(
|
||||
(detail) => Object.keys(detail)[0] === scanId,
|
||||
);
|
||||
return scanDetail ? scanDetail[scanId]?.providerInfo?.uid : null;
|
||||
};
|
||||
|
||||
const getScanProviderType = (scanId: string): ProviderType | null => {
|
||||
if (!enableScanRelation) return null;
|
||||
const scanDetail = scanDetails.find(
|
||||
(detail) => Object.keys(detail)[0] === scanId,
|
||||
);
|
||||
return scanDetail ? scanDetail[scanId]?.providerInfo?.provider : null;
|
||||
};
|
||||
|
||||
const getProviderType = (providerUid: string): ProviderType | null => {
|
||||
const providerDetail = providerDetails.find(
|
||||
(detail) => Object.keys(detail)[0] === providerUid,
|
||||
);
|
||||
if (!providerDetail) return null;
|
||||
|
||||
const entity = providerDetail[providerUid];
|
||||
if (!isScanEntity(entity as ScanEntity)) {
|
||||
return (entity as ProviderEntity).provider;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const scanParam = enableScanRelation
|
||||
? searchParams.get(`filter[${FilterType.SCAN}]`)
|
||||
: null;
|
||||
const providerParam = searchParams.get(
|
||||
`filter[${FilterType.PROVIDER_UID}]`,
|
||||
);
|
||||
const providerTypeParam = searchParams.get(
|
||||
`filter[${FilterType.PROVIDER_TYPE}]`,
|
||||
);
|
||||
|
||||
const currentProviders = providerParam ? providerParam.split(",") : [];
|
||||
const currentProviderTypes = providerTypeParam
|
||||
? (providerTypeParam.split(",") as ProviderType[])
|
||||
: [];
|
||||
|
||||
// Detect deselected items
|
||||
const deselectedProviders = previousProviders.current.filter(
|
||||
(provider) => !currentProviders.includes(provider),
|
||||
);
|
||||
const deselectedProviderTypes = previousProviderTypes.current.filter(
|
||||
(type) => !currentProviderTypes.includes(type),
|
||||
);
|
||||
|
||||
// Check if it's a manual deselection
|
||||
if (deselectedProviderTypes.length > 0) {
|
||||
isManualDeselection.current = true;
|
||||
} else if (
|
||||
currentProviderTypes.length === 0 &&
|
||||
previousProviderTypes.current.length === 0
|
||||
) {
|
||||
isManualDeselection.current = false;
|
||||
}
|
||||
|
||||
// Update references
|
||||
previousProviders.current = currentProviders;
|
||||
previousProviderTypes.current = currentProviderTypes;
|
||||
|
||||
// Handle scan selection logic
|
||||
if (enableScanRelation && scanParam) {
|
||||
const scanProviderId = getScanProvider(scanParam);
|
||||
const scanProviderType = getScanProviderType(scanParam);
|
||||
|
||||
const shouldDeselectScan =
|
||||
(scanProviderId &&
|
||||
(deselectedProviders.includes(scanProviderId) ||
|
||||
(currentProviders.length > 0 &&
|
||||
!currentProviders.includes(scanProviderId)))) ||
|
||||
(scanProviderType &&
|
||||
!isManualDeselection.current &&
|
||||
(deselectedProviderTypes.includes(scanProviderType) ||
|
||||
(currentProviderTypes.length > 0 &&
|
||||
!currentProviderTypes.includes(scanProviderType))));
|
||||
|
||||
if (shouldDeselectScan) {
|
||||
updateFilter(FilterType.SCAN, null);
|
||||
} else {
|
||||
// Add provider if not already selected
|
||||
if (scanProviderId && !currentProviders.includes(scanProviderId)) {
|
||||
updateFilter(FilterType.PROVIDER_UID, [
|
||||
...currentProviders,
|
||||
scanProviderId,
|
||||
]);
|
||||
}
|
||||
|
||||
// Only add provider type if there are none selected
|
||||
if (
|
||||
scanProviderType &&
|
||||
currentProviderTypes.length === 0 &&
|
||||
!isManualDeselection.current
|
||||
) {
|
||||
updateFilter(FilterType.PROVIDER_TYPE, [scanProviderType]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle provider selection logic
|
||||
if (
|
||||
currentProviders.length > 0 &&
|
||||
deselectedProviders.length === 0 &&
|
||||
!isManualDeselection.current
|
||||
) {
|
||||
const providerTypes = currentProviders
|
||||
.map(getProviderType)
|
||||
.filter((type): type is ProviderType => type !== null);
|
||||
const selectedProviderTypes = Array.from(new Set(providerTypes));
|
||||
|
||||
if (
|
||||
selectedProviderTypes.length > 0 &&
|
||||
currentProviderTypes.length === 0
|
||||
) {
|
||||
updateFilter(FilterType.PROVIDER_TYPE, selectedProviderTypes);
|
||||
}
|
||||
}
|
||||
|
||||
// Update available providers
|
||||
if (currentProviderTypes.length > 0) {
|
||||
const filteredProviderUIDs = providerUIDs.filter((uid) => {
|
||||
const providerType = getProviderType(uid);
|
||||
return providerType && currentProviderTypes.includes(providerType);
|
||||
});
|
||||
setAvailableProviderUIDs(filteredProviderUIDs);
|
||||
|
||||
const validProviders = currentProviders.filter((uid) => {
|
||||
const providerType = getProviderType(uid);
|
||||
return providerType && currentProviderTypes.includes(providerType);
|
||||
});
|
||||
|
||||
if (validProviders.length !== currentProviders.length) {
|
||||
updateFilter(
|
||||
FilterType.PROVIDER_UID,
|
||||
validProviders.length > 0 ? validProviders : null,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setAvailableProviderUIDs(providerUIDs);
|
||||
}
|
||||
|
||||
// Update available scans
|
||||
if (enableScanRelation) {
|
||||
if (currentProviders.length > 0 || currentProviderTypes.length > 0) {
|
||||
const filteredScans = completedScanIds.filter((scanId) => {
|
||||
const scanProviderId = getScanProvider(scanId);
|
||||
const scanProviderType = getScanProviderType(scanId);
|
||||
|
||||
return (
|
||||
(currentProviders.length === 0 ||
|
||||
(scanProviderId && currentProviders.includes(scanProviderId))) &&
|
||||
(currentProviderTypes.length === 0 ||
|
||||
(scanProviderType &&
|
||||
currentProviderTypes.includes(scanProviderType)))
|
||||
);
|
||||
});
|
||||
setAvailableScans(filteredScans);
|
||||
} else {
|
||||
setAvailableScans(completedScanIds);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams]);
|
||||
|
||||
return {
|
||||
availableProviderUIDs,
|
||||
availableScans,
|
||||
};
|
||||
};
|
||||
@@ -66,9 +66,17 @@ export const useUrlFilters = () => {
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
}, [router, searchParams, pathname]);
|
||||
|
||||
const hasFilters = useCallback(() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
return Array.from(params.keys()).some(
|
||||
(key) => key.startsWith("filter[") || key === "sort",
|
||||
);
|
||||
}, [searchParams]);
|
||||
|
||||
return {
|
||||
updateFilter,
|
||||
clearFilter,
|
||||
clearAllFilters,
|
||||
hasFilters,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -28,3 +28,15 @@ export interface FilterControlsProps {
|
||||
mutedFindings?: boolean;
|
||||
customFilters?: FilterOption[];
|
||||
}
|
||||
|
||||
export enum FilterType {
|
||||
SCAN = "scan__in",
|
||||
PROVIDER_UID = "provider_uid__in",
|
||||
PROVIDER_TYPE = "provider_type__in",
|
||||
REGION = "region__in",
|
||||
SERVICE = "service__in",
|
||||
RESOURCE_TYPE = "resource_type__in",
|
||||
SEVERITY = "severity__in",
|
||||
STATUS = "status__in",
|
||||
DELTA = "delta__in",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user