diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index b87211a69b..31dc7fc349 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to the **Prowler API** are documented in this file. ## [1.19.0] (Prowler UNRELEASED) +### Added + +- `provider_id` and `provider_id__in` filters for resources endpoints (`GET /resources` and `GET /resources/metadata/latest`) [(#9864)](https://github.com/prowler-cloud/prowler/pull/9864) + ### 🔄 Changed - Lazy-load providers and compliance data to reduce API/worker startup memory and time [(#9857)](https://github.com/prowler-cloud/prowler/pull/9857) diff --git a/api/src/backend/api/filters.py b/api/src/backend/api/filters.py index 579888e206..bf34950156 100644 --- a/api/src/backend/api/filters.py +++ b/api/src/backend/api/filters.py @@ -453,6 +453,8 @@ class Meta: class ResourceFilter(ProviderRelationshipFilterSet): + provider_id = UUIDFilter(field_name="provider__id", lookup_expr="exact") + provider_id__in = UUIDInFilter(field_name="provider__id", lookup_expr="in") tag_key = CharFilter(method="filter_tag_key") tag_value = CharFilter(method="filter_tag_value") tag = CharFilter(method="filter_tag") @@ -540,6 +542,8 @@ def filter_tag(self, queryset, name, value): class LatestResourceFilter(ProviderRelationshipFilterSet): + provider_id = UUIDFilter(field_name="provider__id", lookup_expr="exact") + provider_id__in = UUIDInFilter(field_name="provider__id", lookup_expr="in") tag_key = CharFilter(method="filter_tag_key") tag_value = CharFilter(method="filter_tag_value") tag = CharFilter(method="filter_tag") diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 7008c1477e..cd7723e229 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -8186,6 +8186,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_type] schema: @@ -8588,6 +8603,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_type] schema: @@ -8884,6 +8914,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_type] schema: @@ -9186,6 +9231,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_type] schema: diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index a05345aaac..27684978ec 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -4353,6 +4353,108 @@ def test_resources_metadata_latest( assert attributes["types"] == [latest_scan_resource.type] assert "groups" in attributes + def test_resources_latest_filter_by_provider_id( + self, authenticated_client, latest_scan_resource + ): + """Test that provider_id filter works on latest resources endpoint.""" + provider = latest_scan_resource.provider + response = authenticated_client.get( + reverse("resource-latest"), + {"filter[provider_id]": str(provider.id)}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 1 + assert ( + response.json()["data"][0]["attributes"]["uid"] == latest_scan_resource.uid + ) + + def test_resources_latest_filter_by_provider_id_in( + self, authenticated_client, latest_scan_resource + ): + """Test that provider_id__in filter works on latest resources endpoint.""" + provider = latest_scan_resource.provider + response = authenticated_client.get( + reverse("resource-latest"), + {"filter[provider_id__in]": str(provider.id)}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 1 + assert ( + response.json()["data"][0]["attributes"]["uid"] == latest_scan_resource.uid + ) + + def test_resources_latest_filter_by_provider_id_in_multiple( + self, authenticated_client, providers_fixture + ): + """Test that provider_id__in filter works with multiple provider IDs.""" + provider1, provider2 = providers_fixture[0], providers_fixture[1] + tenant_id = str(provider1.tenant_id) + + # Create completed scans for both providers + scan1 = Scan.objects.create( + name="scan for provider 1", + provider=provider1, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant_id, + ) + scan2 = Scan.objects.create( + name="scan for provider 2", + provider=provider2, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant_id, + ) + + # Create resources for each provider + resource1 = Resource.objects.create( + tenant_id=tenant_id, + provider=provider1, + uid="resource_provider_1", + name="Resource Provider 1", + region="us-east-1", + service="ec2", + type="instance", + ) + resource2 = Resource.objects.create( + tenant_id=tenant_id, + provider=provider2, + uid="resource_provider_2", + name="Resource Provider 2", + region="us-west-2", + service="s3", + type="bucket", + ) + + # Test filtering by both providers + response = authenticated_client.get( + reverse("resource-latest"), + {"filter[provider_id__in]": f"{provider1.id},{provider2.id}"}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 2 + + # Test filtering by single provider returns only that provider's resource + response = authenticated_client.get( + reverse("resource-latest"), + {"filter[provider_id__in]": str(provider1.id)}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 1 + assert response.json()["data"][0]["attributes"]["uid"] == resource1.uid + + def test_resources_latest_filter_by_provider_id_no_match( + self, authenticated_client, latest_scan_resource + ): + """Test that provider_id filter returns empty when no match.""" + non_existent_id = str(uuid4()) + response = authenticated_client.get( + reverse("resource-latest"), + {"filter[provider_id]": non_existent_id}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 0 + @pytest.mark.django_db class TestFindingViewSet: diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index c1868e0ee8..4605a74c39 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to the **Prowler UI** are documented in this file. +## [1.18.0] (Prowler v5.18.0 UNRELEASED) + +### 🔄 Changed + +- Restyle resources view with improved resource detail drawer [(#9864)](https://github.com/prowler-cloud/prowler/pull/9864) + +--- + ## [1.17.0] (Prowler v5.17.0) ### 🚀 Added diff --git a/ui/app/(prowler)/_overview/severity-over-time/_components/time-range-selector.tsx b/ui/app/(prowler)/_overview/severity-over-time/_components/time-range-selector.tsx index 864c00585d..cf25498d58 100644 --- a/ui/app/(prowler)/_overview/severity-over-time/_components/time-range-selector.tsx +++ b/ui/app/(prowler)/_overview/severity-over-time/_components/time-range-selector.tsx @@ -32,7 +32,7 @@ export const TimeRangeSelector = ({ isLoading = false, }: TimeRangeSelectorProps) => { return ( -
+ {displayName} +
++ {typeof type === "string" ? type : "-"} +
); }, }, + // Region column { - accessorKey: "type", + accessorKey: "region", header: ({ column }) => ( -+ {typeof region === "string" ? region : "-"} +
); }, }, + // Service column { accessorKey: "service", header: ({ column }) => ( -+ {typeof service === "string" ? service : "-"} +
); }, }, + // Actions column { - accessorKey: "provider", - header: ({ column }) => ( -- Loading resource details... -
-+ Loading finding details... +
+
+ {metadataCopied ? (
+
+ ) : (
+
+ )}
+
+
{JSON.stringify(parsedMetadata, null, 2)}
+
Loading findings...
- Total failed findings: {failedFindings.length} -
- {failedFindings.map((finding: ResourceFinding, index: number) => { - const { attributes: findingAttrs, id } = finding; - - // Handle cases where finding might not have all attributes - if (!findingAttrs) { - return ( -- Finding {id} - No attributes available -
-- No failed findings found for this resource. -
+ <> +{value}
+
+ - {description} -
- )} - {children} -- {label} +
+ {label} {highlight && ( NEW diff --git a/ui/components/ui/table/data-table-pagination.tsx b/ui/components/ui/table/data-table-pagination.tsx index a7fc143fc3..6ba2d9a158 100644 --- a/ui/components/ui/table/data-table-pagination.tsx +++ b/ui/components/ui/table/data-table-pagination.tsx @@ -24,6 +24,18 @@ import { MetaDataProps } from "@/types"; interface DataTablePaginationProps { metadata?: MetaDataProps; disableScroll?: boolean; + /** Prefix for URL params to avoid conflicts (e.g., "findings" -> "findingsPage") */ + paramPrefix?: string; + + /* + * Controlled mode: Use these props to manage pagination via React state + * instead of URL params. Useful for tables in drawers/modals to avoid + * triggering page re-renders when paginating. + */ + controlledPage?: number; + controlledPageSize?: number; + onPageChange?: (page: number) => void; + onPageSizeChange?: (pageSize: number) => void; } const NAV_BUTTON_STYLES = { @@ -35,18 +47,44 @@ const NAV_BUTTON_STYLES = { export function DataTablePagination({ metadata, disableScroll = false, + paramPrefix = "", + controlledPage, + controlledPageSize, + onPageChange, + onPageSizeChange, }: DataTablePaginationProps) { const pathname = usePathname(); const searchParams = useSearchParams(); const router = useRouter(); - const initialPageSize = searchParams.get("pageSize") ?? "50"; + + // Determine if we're in controlled mode + const isControlled = controlledPage !== undefined && onPageChange; + + // Determine param names based on prefix + const pageParam = paramPrefix ? `${paramPrefix}Page` : "page"; + const pageSizeParam = paramPrefix ? `${paramPrefix}PageSize` : "pageSize"; + + const initialPageSize = isControlled + ? String(controlledPageSize ?? 10) + : (searchParams.get(pageSizeParam) ?? "10"); const [selectedPageSize, setSelectedPageSize] = useState(initialPageSize); if (!metadata) return null; - const { currentPage, totalPages, totalEntries, itemsPerPageOptions } = - getPaginationInfo(metadata); + const { + currentPage: metaCurrentPage, + totalPages, + totalEntries, + itemsPerPageOptions, + } = getPaginationInfo(metadata); + + // For controlled mode, use controlled values; for prefixed, read from URL; otherwise use metadata + const currentPage = isControlled + ? controlledPage + : paramPrefix + ? parseInt(searchParams.get(pageParam) || "1", 10) + : metaCurrentPage; const createPageUrl = (pageNumber: number | string) => { const params = new URLSearchParams(searchParams); @@ -60,7 +98,7 @@ export function DataTablePagination({ return `${pathname}?${params.toString()}`; } - params.set("page", pageNumber.toString()); + params.set(pageParam, pageNumber.toString()); // Ensure that scanId, id and version are preserved if (scanId) params.set("scanId", scanId); @@ -70,6 +108,20 @@ export function DataTablePagination({ return `${pathname}?${params.toString()}`; }; + // Handle page navigation for controlled mode + const handlePageChange = (pageNumber: number) => { + if (isControlled) { + onPageChange(pageNumber); + } else { + const url = createPageUrl(pageNumber); + if (disableScroll) { + router.push(url, { scroll: false }); + } else { + router.push(url); + } + } + }; + const isFirstPage = currentPage === 1; const isLastPage = currentPage === totalPages; @@ -87,6 +139,12 @@ export function DataTablePagination({ onValueChange={(value) => { setSelectedPageSize(value); + if (isControlled) { + onPageSizeChange?.(parseInt(value, 10)); + onPageChange(1); // Reset to first page + return; + } + const params = new URLSearchParams(searchParams); // Preserve all important parameters @@ -94,8 +152,8 @@ export function DataTablePagination({ const id = searchParams.get("id"); const version = searchParams.get("version"); - params.set("pageSize", value); - params.set("page", "1"); + params.set(pageSizeParam, value); + params.set(pageParam, "1"); // Ensure that scanId, id and version are preserved if (scanId) params.set("scanId", scanId); @@ -137,82 +195,145 @@ export function DataTablePagination({ Page {currentPage} of {totalPages}