From 0c5778d4a120e1cab5e55cbe50174fe46911556e Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Wed, 28 Jan 2026 14:07:01 +0100 Subject: [PATCH 1/2] feat: resource view re-styling with new components (#9864) --- api/CHANGELOG.md | 1 + api/src/backend/api/filters.py | 4 + api/src/backend/api/specs/v1.yaml | 60 ++ api/src/backend/api/tests/test_views.py | 102 +++ ui/CHANGELOG.md | 1 + .../_components/time-range-selector.tsx | 2 +- ui/app/(prowler)/findings/page.tsx | 43 +- .../config/(connect-llm)/layout.tsx | 8 +- .../_components/advanced-mutelist-form.tsx | 8 +- .../simple/mute-rules-table-client.tsx | 14 +- ui/app/(prowler)/resources/page.tsx | 94 ++- ui/auth.config.ts | 11 +- ui/components/auth/oss/sign-in-form.tsx | 3 +- .../findings/mute-findings-modal.tsx | 34 +- ui/components/findings/send-to-jira-modal.tsx | 8 +- .../findings/table/column-findings.tsx | 5 +- .../findings/table/data-table-row-actions.tsx | 142 ++-- .../findings/table/finding-detail.tsx | 6 +- .../jira/jira-integrations-manager.tsx | 14 +- .../s3/s3-integrations-manager.tsx | 14 +- .../saml/saml-integration-card.tsx | 14 +- .../security-hub-integrations-manager.tsx | 14 +- .../table/data-table-row-actions.tsx | 14 +- .../table/data-table-row-actions.tsx | 8 +- ui/components/providers/provider-info.tsx | 2 +- .../table/data-table-row-actions.tsx | 14 +- .../resources/resource-details-sheet.tsx | 48 ++ ui/components/resources/resources-filters.tsx | 105 ++- .../skeleton/skeleton-table-resources.tsx | 155 ++++- .../resources/table/column-resources.tsx | 259 ++++--- ui/components/resources/table/index.ts | 2 + .../resources/table/resource-detail.tsx | 639 +++++++++++------- .../table/resource-findings-columns.tsx | 146 ++++ .../table/resources-table-with-selection.tsx | 28 + .../roles/table/data-table-row-actions.tsx | 8 +- .../table/scans/data-table-row-actions.tsx | 8 +- .../shadcn/dropdown/action-dropdown.tsx | 131 ++++ ui/components/shadcn/dropdown/index.ts | 23 + ui/components/shadcn/modal/index.ts | 1 + ui/components/shadcn/modal/modal.tsx | 67 ++ .../ui/code-snippet/code-snippet.tsx | 110 ++- .../ui/custom/custom-alert-modal.tsx | 50 -- ui/components/ui/custom/index.ts | 1 - ui/components/ui/entities/entity-info.tsx | 94 +-- ui/components/ui/sidebar/menu-item.tsx | 9 +- .../ui/table/data-table-pagination.tsx | 285 +++++--- ui/components/ui/table/data-table-search.tsx | 98 ++- ui/components/ui/table/data-table.tsx | 82 ++- .../users/profile/api-key-success-modal.tsx | 8 +- .../users/profile/create-api-key-modal.tsx | 8 +- .../users/profile/edit-api-key-name-modal.tsx | 8 +- .../users/profile/membership-item.tsx | 10 +- .../users/profile/revoke-api-key-modal.tsx | 8 +- .../users/table/data-table-row-actions.tsx | 14 +- ui/contexts/filter-transition-context.tsx | 63 ++ ui/contexts/index.ts | 6 + ui/hooks/use-url-filters.ts | 37 +- ui/tests/auth/auth-session-errors.spec.ts | 3 +- ui/types/resources.ts | 1 + 59 files changed, 2288 insertions(+), 867 deletions(-) create mode 100644 ui/components/resources/resource-details-sheet.tsx create mode 100644 ui/components/resources/table/resource-findings-columns.tsx create mode 100644 ui/components/resources/table/resources-table-with-selection.tsx create mode 100644 ui/components/shadcn/dropdown/action-dropdown.tsx create mode 100644 ui/components/shadcn/dropdown/index.ts create mode 100644 ui/components/shadcn/modal/index.ts create mode 100644 ui/components/shadcn/modal/modal.tsx delete mode 100644 ui/components/ui/custom/custom-alert-modal.tsx create mode 100644 ui/contexts/filter-transition-context.tsx create mode 100644 ui/contexts/index.ts diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index c89e0b2cb7..34a38ada6f 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to the **Prowler API** are documented in this file. ### 🚀 Added - Attack Paths: Bedrock Code Interpreter and AttachRolePolicy privilege escalation queries [(#9885)](https://github.com/prowler-cloud/prowler/pull/9885) +- `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) - Added memory optimizations for large compliance report generation [(#9444)](https://github.com/prowler-cloud/prowler/pull/9444) - `GET /api/v1/resources/{id}/events` endpoint to retrieve AWS resource modification history from CloudTrail [(#9101)](https://github.com/prowler-cloud/prowler/pull/9101) - Partial index on findings to speed up new failed findings queries [(#9904)](https://github.com/prowler-cloud/prowler/pull/9904) 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 ResourceTagFilter(FilterSet): 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 @@ class ResourceFilter(ProviderRelationshipFilterSet): 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 8b2f595d26..5acfad545c 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: @@ -8646,6 +8661,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: @@ -8942,6 +8972,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: @@ -9244,6 +9289,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 547190d788..8d4fdabcbe 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -4356,6 +4356,108 @@ class TestResourceViewSet: 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 + Scan.objects.create( + name="scan for provider 1", + provider=provider1, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant_id, + ) + 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", + ) + 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 + # Events endpoint tests def test_events_non_aws_provider(self, authenticated_client, providers_fixture): """Test events endpoint rejects non-AWS providers.""" diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 927793458b..b957c2961a 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🔄 Changed +- Restyle resources view with improved resource detail drawer [(#9864)](https://github.com/prowler-cloud/prowler/pull/9864) - Launch Scan page now displays all providers without pagination limit [(#9700)](https://github.com/prowler-cloud/prowler/pull/9700) - Upgrade Next.js from 15.5.9 to 16.1.3 with ESLint 9 flat config migration [(#9826)](https://github.com/prowler-cloud/prowler/pull/9826) 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 ( -
|
+ |
+ {/* Provider Account */}
+
+ |
+ {/* Failed Findings */}
+
+ |
+ {/* Group */}
+
+ |
+ {/* Type */}
+
+ |
+ {/* Region */}
+
+ |
+ {/* Service */}
+
+ |
+ {/* Actions - empty header */}
+ + |
|---|
+ {displayName} +
+-
; + } + + const displayLabel = getGroupLabel(groups[0]); + const extraCount = groups.length - 1; + return ( - - {failedFindingsCount} - ++ {displayLabel} +
+ {extraCount > 0 && ( + + +{extraCount} + + )} ++ {typeof type === "string" ? type : "-"} +
); }, }, + // Region column + { + 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. -
+ <> +{displayValue}
+ - {description} -
- )} - {children} -- {entityAlias} -
++ {entityAlias} +
+- - -
++ - +
+- {entityId} -
++ {entityId} +
+- {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}
U(YDL2DTh9vX~SXox4os#@4<^cAI}C<95yER+I ^*i-`TJujv{L
zE_^f}@m}QLi14|_MOMo=RpIn`vIb9sUz^$?-{+A*wXNO8jch7MgKAa85^L0q*KE++
zzN!kIqAH|ErH3WpjCe9N s?#HXqydtuj2<{<1Y<0<UfqezY0f6;5KNe
zH@2?rSxwmDT)iR0n`6_Ge07kqc0BHt((v`ao_dU+%Pc@fQfuQ94fj9PVUX_ijt@-X
z>cw7Ox-;TPqTgr+6K5z4wBlNpy21R9X8uw%{8RA`z6A{8@}0VW-udmTNJAivD?3i>
z_1VM!8~OgX1pey*!(|{f%7o!>g}(p7)PnYJlkEgLW+}$M8O>G89tx*Kh5eu{CM@I}
z9SZz3n&w961!D4>Z$hchu!y<9W8NJfXM9qDXv3@IIkIh8_~8c4cpivO{CtkIDnGxG
z*
}`w~g~6Rs+mY>54)^13&iae=N?Nc4`QOztf~>9r
z(E^Ss`nU1lJ=|K37X%m8?__%)!~#Cs+V9PH6(1bOiy}HoOr)8ZnZE_U;O9dPyh!f3
zUEHvnS5b2?78njVO1guM;3K*0^Z~nJP|LL;rrp)g5nV;eqBGX+^9w}=nR?Y*=?i91
z%>wN$2k=w{{nC3Gy7$D?V~LYM&P_BB{{GRV&|S=U9#sFae@}+~ZOnV+12cOf|MONc
zh}bHVPKDKbAZG%52(^~o%ZK_7M852
Qe+!OmgKU
zJ43HNwV2GO;&&e30bDT1Dn1(cP!VO?v_1aHAYY6LL>veCMyc&?`441mWhBNGTVc
z^B$)3*55aTK1g4{I%vV`aiEBA=6fH%6Y)LCo>7yGa80auV8=Z{Z`vw}pRv$!yo+ur
zM3<}D`5q+jTg+t2$0?5pyuKz(K{i`!nypjiwRdAn#6mG5Khl;ZNy2X`cLEhdIm+Pl
zjz+j=JDA`*lYqe3