diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 8cb9e73f52..a2378abe62 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the **Prowler API** are documented in this file. ### Added - Support AlibabaCloud provider [(#9485)](https://github.com/prowler-cloud/prowler/pull/9485) +- `provider_id` and `provider_id__in` filter aliases for findings endpoints to enable consistent frontend parameter naming [(#9701)](https://github.com/prowler-cloud/prowler/pull/9701) --- diff --git a/api/src/backend/api/filters.py b/api/src/backend/api/filters.py index f626732477..ddb82c12f4 100644 --- a/api/src/backend/api/filters.py +++ b/api/src/backend/api/filters.py @@ -94,8 +94,12 @@ class ChoiceInFilter(BaseInFilter, ChoiceFilter): class CommonFindingFilters(FilterSet): # We filter providers from the scan in findings + # Both 'provider' and 'provider_id' parameters are supported for API consistency + # Frontend uses 'provider_id' uniformly across all endpoints provider = UUIDFilter(field_name="scan__provider__id", lookup_expr="exact") provider__in = UUIDInFilter(field_name="scan__provider__id", lookup_expr="in") + provider_id = UUIDFilter(field_name="scan__provider__id", lookup_expr="exact") + provider_id__in = UUIDInFilter(field_name="scan__provider__id", lookup_expr="in") provider_type = ChoiceFilter( choices=Provider.ProviderChoices.choices, field_name="scan__provider__provider" ) diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 2c2bd8f027..9d43537db4 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -879,12 +879,28 @@ 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: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -894,7 +910,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- * `aws` - AWS * `azure` - Azure @@ -912,8 +927,9 @@ paths: type: array items: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -923,7 +939,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- Multiple values may be separated by commas. @@ -1436,12 +1451,28 @@ 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: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -1451,7 +1482,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- * `aws` - AWS * `azure` - Azure @@ -1469,8 +1499,9 @@ paths: type: array items: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -1480,7 +1511,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- Multiple values may be separated by commas. @@ -1901,12 +1931,28 @@ 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: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -1916,7 +1962,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- * `aws` - AWS * `azure` - Azure @@ -1934,8 +1979,9 @@ paths: type: array items: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -1945,7 +1991,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- Multiple values may be separated by commas. @@ -2364,12 +2409,28 @@ 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: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -2379,7 +2440,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- * `aws` - AWS * `azure` - Azure @@ -2397,8 +2457,9 @@ paths: type: array items: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -2408,7 +2469,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- Multiple values may be separated by commas. @@ -2815,12 +2875,28 @@ 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: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -2830,7 +2906,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- * `aws` - AWS * `azure` - Azure @@ -2848,8 +2923,9 @@ paths: type: array items: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -2859,7 +2935,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- Multiple values may be separated by commas. @@ -4627,8 +4702,9 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -4648,14 +4724,16 @@ paths: * `mongodbatlas` - MongoDB Atlas * `iac` - IaC * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -4677,6 +4755,7 @@ paths: * `mongodbatlas` - MongoDB Atlas * `iac` - IaC * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud explode: false style: form - name: filter[search] @@ -4782,8 +4861,9 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -4803,14 +4883,16 @@ paths: * `mongodbatlas` - MongoDB Atlas * `iac` - IaC * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -4832,6 +4914,7 @@ paths: * `mongodbatlas` - MongoDB Atlas * `iac` - IaC * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud explode: false style: form - name: filter[search] @@ -4956,8 +5039,9 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -4967,7 +5051,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- * `aws` - AWS * `azure` - Azure @@ -4985,8 +5068,9 @@ paths: type: array items: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -4996,7 +5080,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- Multiple values may be separated by commas. @@ -5149,8 +5232,9 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -5160,7 +5244,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- * `aws` - AWS * `azure` - Azure @@ -5178,8 +5261,9 @@ paths: type: array items: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -5189,7 +5273,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- Multiple values may be separated by commas. @@ -5338,6 +5421,7 @@ paths: schema: type: string enum: + - alibabacloud - aws - azure - gcp @@ -5347,7 +5431,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- * `aws` - AWS * `azure` - Azure @@ -5366,6 +5449,7 @@ paths: items: type: string enum: + - alibabacloud - aws - azure - gcp @@ -5375,7 +5459,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- Multiple values may be separated by commas. @@ -5564,8 +5647,9 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -5575,7 +5659,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- * `aws` - AWS * `azure` - Azure @@ -5593,8 +5676,9 @@ paths: type: array items: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -5604,7 +5688,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- Multiple values may be separated by commas. @@ -5740,8 +5823,9 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -5751,7 +5835,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- * `aws` - AWS * `azure` - Azure @@ -5769,8 +5852,9 @@ paths: type: array items: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -5780,7 +5864,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- Multiple values may be separated by commas. @@ -6563,8 +6646,9 @@ paths: name: filter[provider] schema: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -6574,7 +6658,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- * `aws` - AWS * `azure` - Azure @@ -6592,8 +6675,9 @@ paths: type: array items: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -6603,7 +6687,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- Multiple values may be separated by commas. @@ -6623,8 +6706,9 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -6634,7 +6718,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- * `aws` - AWS * `azure` - Azure @@ -6652,8 +6735,9 @@ paths: type: array items: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -6663,7 +6747,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- Multiple values may be separated by commas. @@ -7277,8 +7360,9 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -7288,7 +7372,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- * `aws` - AWS * `azure` - Azure @@ -7306,8 +7389,9 @@ paths: type: array items: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -7317,7 +7401,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- Multiple values may be separated by commas. @@ -7664,8 +7747,9 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -7675,7 +7759,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- * `aws` - AWS * `azure` - Azure @@ -7693,8 +7776,9 @@ paths: type: array items: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -7704,7 +7788,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- Multiple values may be separated by commas. @@ -7946,8 +8029,9 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -7957,7 +8041,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- * `aws` - AWS * `azure` - Azure @@ -7975,8 +8058,9 @@ paths: type: array items: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -7986,7 +8070,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- Multiple values may be separated by commas. @@ -8234,8 +8317,9 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -8245,7 +8329,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- * `aws` - AWS * `azure` - Azure @@ -8263,8 +8346,9 @@ paths: type: array items: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -8274,7 +8358,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- Multiple values may be separated by commas. @@ -9085,8 +9168,9 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -9096,7 +9180,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- * `aws` - AWS * `azure` - Azure @@ -9114,8 +9197,9 @@ paths: type: array items: type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f enum: + - alibabacloud - aws - azure - gcp @@ -9125,7 +9209,6 @@ paths: - m365 - mongodbatlas - oraclecloud - - alibabacloud description: |- Multiple values may be separated by commas. @@ -15637,6 +15720,44 @@ components: required: - atlas_public_key - atlas_private_key + - type: object + title: Alibaba Cloud Static Credentials + properties: + access_key_id: + type: string + description: The Alibaba Cloud access key ID for authentication. + access_key_secret: + type: string + description: The Alibaba Cloud access key secret for authentication. + security_token: + type: string + description: The STS security token for temporary credentials + (optional). + required: + - access_key_id + - access_key_secret + - type: object + title: Alibaba Cloud RAM Role Assumption + properties: + role_arn: + type: string + description: The ARN of the RAM role to assume (e.g., acs:ram::1234567890123456:role/ProwlerRole). + access_key_id: + type: string + description: The Alibaba Cloud access key ID of the RAM user + that will assume the role. + access_key_secret: + type: string + description: The Alibaba Cloud access key secret of the RAM + user that will assume the role. + role_session_name: + type: string + description: An identifier for the role session (optional, + defaults to 'ProwlerSession'). + required: + - role_arn + - access_key_id + - access_key_secret writeOnly: true required: - secret @@ -16647,7 +16768,7 @@ components: * `iac` - IaC * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f uid: type: string title: Unique identifier for the provider, set by the provider @@ -16764,7 +16885,7 @@ components: - oraclecloud - alibabacloud type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f description: |- Type of provider to create. @@ -16826,7 +16947,7 @@ components: - oraclecloud - alibabacloud type: string - x-spec-enum-id: eca8c51e6bd28935 + x-spec-enum-id: 684bf4173d2b754f description: |- Type of provider to create. @@ -17602,6 +17723,44 @@ components: required: - atlas_public_key - atlas_private_key + - type: object + title: Alibaba Cloud Static Credentials + properties: + access_key_id: + type: string + description: The Alibaba Cloud access key ID for authentication. + access_key_secret: + type: string + description: The Alibaba Cloud access key secret for authentication. + security_token: + type: string + description: The STS security token for temporary credentials + (optional). + required: + - access_key_id + - access_key_secret + - type: object + title: Alibaba Cloud RAM Role Assumption + properties: + role_arn: + type: string + description: The ARN of the RAM role to assume (e.g., acs:ram::1234567890123456:role/ProwlerRole). + access_key_id: + type: string + description: The Alibaba Cloud access key ID of the RAM user that + will assume the role. + access_key_secret: + type: string + description: The Alibaba Cloud access key secret of the RAM user + that will assume the role. + role_session_name: + type: string + description: An identifier for the role session (optional, defaults + to 'ProwlerSession'). + required: + - role_arn + - access_key_id + - access_key_secret writeOnly: true required: - secret_type @@ -17927,6 +18086,44 @@ components: required: - atlas_public_key - atlas_private_key + - type: object + title: Alibaba Cloud Static Credentials + properties: + access_key_id: + type: string + description: The Alibaba Cloud access key ID for authentication. + access_key_secret: + type: string + description: The Alibaba Cloud access key secret for authentication. + security_token: + type: string + description: The STS security token for temporary credentials + (optional). + required: + - access_key_id + - access_key_secret + - type: object + title: Alibaba Cloud RAM Role Assumption + properties: + role_arn: + type: string + description: The ARN of the RAM role to assume (e.g., acs:ram::1234567890123456:role/ProwlerRole). + access_key_id: + type: string + description: The Alibaba Cloud access key ID of the RAM user + that will assume the role. + access_key_secret: + type: string + description: The Alibaba Cloud access key secret of the RAM + user that will assume the role. + role_session_name: + type: string + description: An identifier for the role session (optional, + defaults to 'ProwlerSession'). + required: + - role_arn + - access_key_id + - access_key_secret writeOnly: true required: - secret_type @@ -18266,6 +18463,44 @@ components: required: - atlas_public_key - atlas_private_key + - type: object + title: Alibaba Cloud Static Credentials + properties: + access_key_id: + type: string + description: The Alibaba Cloud access key ID for authentication. + access_key_secret: + type: string + description: The Alibaba Cloud access key secret for authentication. + security_token: + type: string + description: The STS security token for temporary credentials + (optional). + required: + - access_key_id + - access_key_secret + - type: object + title: Alibaba Cloud RAM Role Assumption + properties: + role_arn: + type: string + description: The ARN of the RAM role to assume (e.g., acs:ram::1234567890123456:role/ProwlerRole). + access_key_id: + type: string + description: The Alibaba Cloud access key ID of the RAM user that + will assume the role. + access_key_secret: + type: string + description: The Alibaba Cloud access key secret of the RAM user + that will assume the role. + role_session_name: + type: string + description: An identifier for the role session (optional, defaults + to 'ProwlerSession'). + required: + - role_arn + - access_key_id + - access_key_secret writeOnly: true required: - secret diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 6babb92fc8..dc0ec1ac26 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -4110,6 +4110,37 @@ def test_finding_filter_by_provider_id_in( assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == 2 + def test_finding_filter_by_provider_id_alias( + self, authenticated_client, findings_fixture + ): + """Test that provider_id filter alias works identically to provider filter.""" + response = authenticated_client.get( + reverse("finding-list"), + { + "filter[provider_id]": findings_fixture[0].scan.provider.id, + "filter[inserted_at]": TODAY, + }, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 2 + + def test_finding_filter_by_provider_id_in_alias( + self, authenticated_client, findings_fixture + ): + """Test that provider_id__in filter alias works identically to provider__in filter.""" + response = authenticated_client.get( + reverse("finding-list"), + { + "filter[provider_id__in]": [ + findings_fixture[0].scan.provider.id, + findings_fixture[1].scan.provider.id, + ], + "filter[inserted_at]": TODAY, + }, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 2 + @pytest.mark.parametrize( "filter_name", ( @@ -4331,6 +4362,28 @@ def test_findings_latest(self, authenticated_client, latest_scan_finding): == latest_scan_finding.status ) + def test_findings_latest_filter_by_provider_id_alias( + self, authenticated_client, latest_scan_finding + ): + """Test that provider_id filter alias works on latest findings endpoint.""" + response = authenticated_client.get( + reverse("finding-latest"), + {"filter[provider_id]": latest_scan_finding.scan.provider.id}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 1 + + def test_findings_latest_filter_by_provider_id_in_alias( + self, authenticated_client, latest_scan_finding + ): + """Test that provider_id__in filter alias works on latest findings endpoint.""" + response = authenticated_client.get( + reverse("finding-latest"), + {"filter[provider_id__in]": str(latest_scan_finding.scan.provider.id)}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 1 + def test_findings_metadata_latest(self, authenticated_client, latest_scan_finding): response = authenticated_client.get( reverse("finding-metadata_latest"), diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 29ba43583b..4aa46e86cb 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🚀 Added - Add search bar when adding a provider [(#9634)](https://github.com/prowler-cloud/prowler/pull/9634) +- New findings table UI with new design system components, improved filtering UX, and enhanced table interactions [(#9699)](https://github.com/prowler-cloud/prowler/pull/9699) - Add gradient background to Risk Plot for visual risk context [(#9664)](https://github.com/prowler-cloud/prowler/pull/9664) ### 🔄 Changed diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx index 80a22b1379..dce5f049dc 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx @@ -45,7 +45,8 @@ export function AccountsSelector({ providers }: AccountsSelectorProps) { const router = useRouter(); const searchParams = useSearchParams(); - const current = searchParams.get("filter[provider_id__in]") || ""; + const filterKey = "filter[provider_id__in]"; + const current = searchParams.get(filterKey) || ""; const selectedTypes = searchParams.get("filter[provider_type__in]") || ""; const selectedTypesList = selectedTypes ? selectedTypes.split(",").filter(Boolean) @@ -61,10 +62,10 @@ export function AccountsSelector({ providers }: AccountsSelectorProps) { const handleMultiValueChange = (ids: string[]) => { const params = new URLSearchParams(searchParams.toString()); + params.delete(filterKey); + if (ids.length > 0) { - params.set("filter[provider_id__in]", ids.join(",")); - } else { - params.delete("filter[provider_id__in]"); + params.set(filterKey, ids.join(",")); } // Auto-deselect provider types that no longer have any selected accounts diff --git a/ui/app/(prowler)/_overview/attack-surface/_components/attack-surface-card-item.tsx b/ui/app/(prowler)/_overview/attack-surface/_components/attack-surface-card-item.tsx index 9a26763c3e..46f2b0dd8e 100644 --- a/ui/app/(prowler)/_overview/attack-surface/_components/attack-surface-card-item.tsx +++ b/ui/app/(prowler)/_overview/attack-surface/_components/attack-surface-card-item.tsx @@ -2,7 +2,6 @@ import Link from "next/link"; import { AttackSurfaceItem } from "@/actions/overview"; import { Card, CardContent } from "@/components/shadcn"; -import { mapProviderFiltersForFindings } from "@/lib"; interface AttackSurfaceCardItemProps { item: AttackSurfaceItem; @@ -29,9 +28,6 @@ export function AttackSurfaceCardItem({ } }); - // Map provider filters for findings page compatibility - mapProviderFiltersForFindings(params); - return `/findings?${params.toString()}`; }; diff --git a/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx index 7dcbac50e9..c71e74c623 100644 --- a/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx +++ b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx @@ -8,7 +8,6 @@ import { LinkToFindings } from "@/components/overview"; import { ColumnNewFindingsToDate } from "@/components/overview/new-findings-table/table/column-new-findings-to-date"; import { DataTable } from "@/components/ui/table"; import { createDict } from "@/lib/helper"; -import { mapProviderFiltersForFindingsObject } from "@/lib/provider-helpers"; import { FindingProps, SearchParamsProps } from "@/types"; import { pickFilterParams } from "../../_lib/filter-params"; @@ -27,8 +26,7 @@ export async function FindingsViewSSR({ searchParams }: FindingsViewSSRProps) { }; const filters = pickFilterParams(searchParams); - const mappedFilters = mapProviderFiltersForFindingsObject(filters); - const combinedFilters = { ...defaultFilters, ...mappedFilters }; + const combinedFilters = { ...defaultFilters, ...filters }; const findingsData = await getLatestFindings({ query: undefined, diff --git a/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot-client.tsx b/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot-client.tsx index dc312762ea..84405fe0f1 100644 --- a/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot-client.tsx +++ b/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot-client.tsx @@ -8,7 +8,6 @@ import { HorizontalBarChart } from "@/components/graphs/horizontal-bar-chart"; import { ScatterPlot } from "@/components/graphs/scatter-plot"; import { AlertPill } from "@/components/graphs/shared/alert-pill"; import type { BarDataPoint } from "@/components/graphs/types"; -import { mapProviderFiltersForFindings } from "@/lib/provider-helpers"; import { SEVERITY_FILTER_MAP } from "@/types/severities"; // Score color thresholds (0-100 scale, higher = better) @@ -42,9 +41,6 @@ export function RiskPlotClient({ data }: RiskPlotClientProps) { // Build the URL with current filters const params = new URLSearchParams(searchParams.toString()); - // Transform provider filters (provider_id__in -> provider__in) - mapProviderFiltersForFindings(params); - // Add severity filter const severity = SEVERITY_FILTER_MAP[dataPoint.name]; if (severity) { @@ -52,7 +48,7 @@ export function RiskPlotClient({ data }: RiskPlotClientProps) { } // Add provider filter for the selected point - params.set("filter[provider__in]", selectedPoint.providerId); + params.set("filter[provider_id__in]", selectedPoint.providerId); // Add exclude muted findings filter params.set("filter[muted]", "false"); diff --git a/ui/app/(prowler)/_overview/risk-severity/_components/risk-severity-chart.tsx b/ui/app/(prowler)/_overview/risk-severity/_components/risk-severity-chart.tsx index e718168698..9a8b11cd8f 100644 --- a/ui/app/(prowler)/_overview/risk-severity/_components/risk-severity-chart.tsx +++ b/ui/app/(prowler)/_overview/risk-severity/_components/risk-severity-chart.tsx @@ -5,7 +5,6 @@ import { useRouter, useSearchParams } from "next/navigation"; import { HorizontalBarChart } from "@/components/graphs/horizontal-bar-chart"; import { BarDataPoint } from "@/components/graphs/types"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn"; -import { mapProviderFiltersForFindings } from "@/lib/provider-helpers"; import { calculatePercentage } from "@/lib/utils"; import { SEVERITY_FILTER_MAP } from "@/types/severities"; @@ -31,8 +30,6 @@ export const RiskSeverityChart = ({ // Build the URL with current filters plus severity and muted const params = new URLSearchParams(searchParams.toString()); - mapProviderFiltersForFindings(params); - const severity = SEVERITY_FILTER_MAP[dataPoint.name]; if (severity) { params.set("filter[severity__in]", severity); diff --git a/ui/app/(prowler)/_overview/severity-over-time/_components/finding-severity-over-time.tsx b/ui/app/(prowler)/_overview/severity-over-time/_components/finding-severity-over-time.tsx index 3990f52b31..619e8631fd 100644 --- a/ui/app/(prowler)/_overview/severity-over-time/_components/finding-severity-over-time.tsx +++ b/ui/app/(prowler)/_overview/severity-over-time/_components/finding-severity-over-time.tsx @@ -66,7 +66,7 @@ export const FindingSeverityOverTime = ({ params.set("filter[provider_type__in]", providerType); } if (providerId) { - params.set("filter[provider__in]", providerId); + params.set("filter[provider_id__in]", providerId); } router.push(`/findings?${params.toString()}`); diff --git a/ui/app/(prowler)/_overview/status-chart/_components/status-chart.tsx b/ui/app/(prowler)/_overview/status-chart/_components/status-chart.tsx index 4615410d80..8f6540ecb4 100644 --- a/ui/app/(prowler)/_overview/status-chart/_components/status-chart.tsx +++ b/ui/app/(prowler)/_overview/status-chart/_components/status-chart.tsx @@ -13,7 +13,6 @@ import { CardVariant, ResourceStatsCard, } from "@/components/shadcn"; -import { mapProviderFiltersForFindings } from "@/lib/provider-helpers"; import { calculatePercentage } from "@/lib/utils"; interface FindingsData { total: number; @@ -39,8 +38,6 @@ export const StatusChart = ({ // Build the URL with current filters plus status and muted const params = new URLSearchParams(searchParams.toString()); - mapProviderFiltersForFindings(params); - // Add status filter based on which segment was clicked if (dataPoint.name === "Fail Findings") { params.set("filter[status__in]", "FAIL"); diff --git a/ui/app/(prowler)/_overview/watchlist/_components/service-watchlist.tsx b/ui/app/(prowler)/_overview/watchlist/_components/service-watchlist.tsx index 71fc7e2146..b39adfbd2b 100644 --- a/ui/app/(prowler)/_overview/watchlist/_components/service-watchlist.tsx +++ b/ui/app/(prowler)/_overview/watchlist/_components/service-watchlist.tsx @@ -4,7 +4,6 @@ import { useRouter, useSearchParams } from "next/navigation"; import { useState } from "react"; import { ServiceOverview } from "@/actions/overview"; -import { mapProviderFiltersForFindings } from "@/lib/provider-helpers"; import { SortToggleButton } from "./sort-toggle-button"; import { WatchlistCard, WatchlistItem } from "./watchlist-card"; @@ -29,9 +28,6 @@ export const ServiceWatchlist = ({ items }: { items: ServiceOverview[] }) => { const handleItemClick = (item: WatchlistItem) => { const params = new URLSearchParams(searchParams.toString()); - - mapProviderFiltersForFindings(params); - params.set("filter[service__in]", item.key); params.set("filter[status__in]", "FAIL"); router.push(`/findings?${params.toString()}`); diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx index 7fbb2e47c1..0818356f31 100644 --- a/ui/app/(prowler)/findings/page.tsx +++ b/ui/app/(prowler)/findings/page.tsx @@ -85,6 +85,7 @@ export default async function Findings({ return ( { + const searchParams = useSearchParams(); const { clearAllFilters, hasFilters } = useUrlFilters(); + // Count active filters (excluding search) + const filterCount = Array.from(searchParams.keys()).filter( + (key) => key.startsWith("filter[") && key !== "filter[search]", + ).length; + if (!hasFilters()) { return null; } + const displayText = showCount ? `Clear Filters (${filterCount})` : text; + return ( - ); }; diff --git a/ui/components/filters/custom-date-picker.tsx b/ui/components/filters/custom-date-picker.tsx index ff3a961d12..c2fcc8a432 100644 --- a/ui/components/filters/custom-date-picker.tsx +++ b/ui/components/filters/custom-date-picker.tsx @@ -1,120 +1,93 @@ "use client"; -import { Button, ButtonGroup } from "@heroui/button"; -import { DatePicker } from "@heroui/date-picker"; -import { - getLocalTimeZone, - parseDate, - startOfMonth, - startOfWeek, - today, -} from "@internationalized/date"; -import { useLocale } from "@react-aria/i18n"; -import type { DateValue } from "@react-types/datepicker"; +import { format } from "date-fns"; +import { CalendarIcon, ChevronDown } from "lucide-react"; import { useSearchParams } from "next/navigation"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; +import { Calendar } from "@/components/shadcn/calendar"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/shadcn/popover"; import { useUrlFilters } from "@/hooks/use-url-filters"; +import { cn } from "@/lib/utils"; export const CustomDatePicker = () => { const searchParams = useSearchParams(); const { updateFilter } = useUrlFilters(); + const [open, setOpen] = useState(false); - const [value, setValue] = useState(() => { + const [date, setDate] = useState(() => { const dateParam = searchParams.get("filter[inserted_at]"); - if (!dateParam) return null; + if (!dateParam) return undefined; try { - return parseDate(dateParam); + return new Date(dateParam); } catch { - return null; + return undefined; } }); - const { locale } = useLocale(); - - const now = today(getLocalTimeZone()); - const nextWeek = startOfWeek(now.add({ weeks: 1 }), locale); - const nextMonth = startOfMonth(now.add({ months: 1 })); - - const applyDateFilter = (date: DateValue | null) => { - if (date) { - updateFilter("inserted_at", date.toString()); + const applyDateFilter = (selectedDate: Date | undefined) => { + if (selectedDate) { + // Format as YYYY-MM-DD for the API + updateFilter("inserted_at", format(selectedDate, "yyyy-MM-dd")); } else { updateFilter("inserted_at", null); } }; - const initialRender = useRef(true); - + // Sync local state with URL params (e.g., when Clear Filters is clicked) useEffect(() => { - if (initialRender.current) { - initialRender.current = false; - return; - } - const params = new URLSearchParams(searchParams.toString()); - if (params.size === 0) { - setValue(null); + const dateParam = searchParams.get("filter[inserted_at]"); + if (!dateParam) { + setDate(undefined); + } else { + try { + setDate(new Date(dateParam)); + } catch { + setDate(undefined); + } } }, [searchParams]); - const handleDateChange = (newValue: DateValue | null) => { - setValue(newValue); - applyDateFilter(newValue); + const handleDateSelect = (newDate: Date | undefined) => { + setDate(newDate); + applyDateFilter(newDate); + setOpen(false); }; return ( -
- *]:!rounded-lg", - selectorButton: "text-bg-button-secondary shrink-0", - input: - "text-bg-button-secondary placeholder:text-bg-button-secondary text-sm", - innerWrapper: "[&]:!rounded-lg", - inputWrapper: - "!border-border-input-primary !bg-bg-input-primary dark:!bg-input/30 dark:hover:!bg-input/50 hover:!bg-bg-neutral-secondary !border [&]:!rounded-lg !shadow-xs !transition-[color,box-shadow] focus-within:!border-border-input-primary-press focus-within:!ring-1 focus-within:!ring-border-input-primary-press focus-within:!ring-offset-1 !h-10 !px-4 !py-3 !outline-none", - segment: "text-bg-button-secondary", - }} - popoverProps={{ - classNames: { - content: - "border-border-input-primary bg-bg-input-primary border rounded-lg", - }, - }} - CalendarTopContent={ - - - - - - } - calendarProps={{ - focusedValue: value || undefined, - onFocusChange: setValue, - nextButtonProps: { - variant: "bordered", - }, - prevButtonProps: { - variant: "bordered", - }, - }} - value={value} - onChange={handleDateChange} - /> -
+ + + + + + + + ); }; diff --git a/ui/components/filters/data-filters.ts b/ui/components/filters/data-filters.ts index 5464b09559..0baf89a3df 100644 --- a/ui/components/filters/data-filters.ts +++ b/ui/components/filters/data-filters.ts @@ -75,13 +75,6 @@ export const filterFindings = [ values: ["PASS", "FAIL", "MANUAL"], index: 1, }, - { - key: FilterType.PROVIDER_TYPE, - labelCheckboxGroup: "Cloud Provider", - values: [...PROVIDER_TYPES], - valueLabelMapping: PROVIDER_TYPE_MAPPING, - index: 5, - }, { key: FilterType.DELTA, labelCheckboxGroup: "Delta", diff --git a/ui/components/filters/filter-controls.tsx b/ui/components/filters/filter-controls.tsx index fa149fbcec..5099f05a2a 100644 --- a/ui/components/filters/filter-controls.tsx +++ b/ui/components/filters/filter-controls.tsx @@ -1,7 +1,6 @@ "use client"; import { Spacer } from "@heroui/spacer"; -import React from "react"; import { FilterOption } from "@/types"; @@ -23,7 +22,7 @@ export interface FilterControlsProps { customFilters?: FilterOption[]; } -export const FilterControls: React.FC = ({ +export const FilterControls = ({ search = false, providers = false, date = false, @@ -31,7 +30,7 @@ export const FilterControls: React.FC = ({ accounts = false, mutedFindings = false, customFilters, -}) => { +}: FilterControlsProps) => { return (
@@ -44,8 +43,12 @@ export const FilterControls: React.FC = ({ {mutedFindings && }
- - {customFilters && } + {customFilters && customFilters.length > 0 && ( + <> + + + + )} ); }; diff --git a/ui/components/findings/findings-filters.tsx b/ui/components/findings/findings-filters.tsx index 8bf51da877..571a976086 100644 --- a/ui/components/findings/findings-filters.tsx +++ b/ui/components/findings/findings-filters.tsx @@ -1,12 +1,25 @@ "use client"; +import { ChevronDown } from "lucide-react"; +import { useState } from "react"; + +import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector"; +import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector"; +import { ClearFiltersButton } from "@/components/filters/clear-filters-button"; +import { CustomCheckboxMutedFindings } from "@/components/filters/custom-checkbox-muted-findings"; +import { CustomDatePicker } from "@/components/filters/custom-date-picker"; import { filterFindings } from "@/components/filters/data-filters"; -import { FilterControls } from "@/components/filters/filter-controls"; +import { Button } from "@/components/shadcn"; +import { ExpandableSection } from "@/components/ui/expandable-section"; +import { DataTableFilterCustom } from "@/components/ui/table"; import { useRelatedFilters } from "@/hooks"; import { getCategoryLabel } from "@/lib/categories"; import { FilterEntity, FilterType, ScanEntity, ScanProps } from "@/types"; +import { ProviderProps } from "@/types/providers"; interface FindingsFiltersProps { + /** Provider data for ProviderTypeSelector and AccountsSelector */ + providers: ProviderProps[]; providerIds: string[]; providerDetails: { [id: string]: FilterEntity }[]; completedScans: ScanProps[]; @@ -19,6 +32,7 @@ interface FindingsFiltersProps { } export const FindingsFilters = ({ + providers, providerIds, providerDetails, completedScanIds, @@ -28,7 +42,9 @@ export const FindingsFilters = ({ uniqueResourceTypes, uniqueCategories, }: FindingsFiltersProps) => { - const { availableProviderIds, availableScans } = useRelatedFilters({ + const [isExpanded, setIsExpanded] = useState(false); + + const { availableScans } = useRelatedFilters({ providerIds, providerDetails, completedScanIds, @@ -36,55 +52,81 @@ export const FindingsFilters = ({ enableScanRelation: true, }); + // Custom filters for the expandable section (removed Provider - now using AccountsSelector) + const customFilters = [ + ...filterFindings, + { + 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.CATEGORY, + labelCheckboxGroup: "Category", + values: uniqueCategories, + labelFormatter: getCategoryLabel, + index: 5, + }, + { + key: FilterType.SCAN, + labelCheckboxGroup: "Scan ID", + values: availableScans, + valueLabelMapping: scanDetails, + index: 7, + }, + ]; + + const hasCustomFilters = customFilters.length > 0; + return ( - <> - - +
+ {/* First row: Provider selectors + Muted checkbox + More Filters button + Clear Filters */} +
+
+ +
+
+ +
+ + {hasCustomFilters && ( + + )} + +
+ + {/* Expandable filters section */} + {hasCustomFilters && ( + + } + hideClearButton + /> + + )} +
); }; diff --git a/ui/components/findings/table/column-findings.tsx b/ui/components/findings/table/column-findings.tsx index f2cbbbd074..dca9087745 100644 --- a/ui/components/findings/table/column-findings.tsx +++ b/ui/components/findings/table/column-findings.tsx @@ -2,25 +2,14 @@ import { ColumnDef, RowSelectionState } from "@tanstack/react-table"; import { Database } from "lucide-react"; -import Link from "next/link"; import { useSearchParams } from "next/navigation"; -import { DataTableRowDetails } from "@/components/findings/table"; -import { DataTableRowActions } from "@/components/findings/table/data-table-row-actions"; -import { InfoIcon, MutedIcon } from "@/components/icons"; import { - Checkbox, - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/shadcn"; -import { - DateWithTime, - EntityInfo, - SnippetChip, -} from "@/components/ui/entities"; -import { TriggerSheet } from "@/components/ui/sheet"; + DataTableRowActions, + FindingDetail, +} from "@/components/findings/table"; +import { Checkbox } from "@/components/shadcn"; +import { DateWithTime, SnippetChip } from "@/components/ui/entities"; import { DataTableColumnHeader, SeverityBadge, @@ -28,7 +17,10 @@ import { } from "@/components/ui/table"; import { FindingProps, ProviderType } from "@/types"; -import { DeltaIndicator } from "./delta-indicator"; +// TODO: PROWLER-379 - Enable ImpactedResourcesCell when backend supports grouped findings +// import { ImpactedResourcesCell } from "./impacted-resources-cell"; +import { DeltaValues, NotificationIndicator } from "./notification-indicator"; +import { ProviderIconCell } from "./provider-icon-cell"; const getFindingsData = (row: { original: FindingProps }) => { return row.original; @@ -58,10 +50,12 @@ const getProviderData = ( ); }; -const FindingDetailsCell = ({ row }: { row: any }) => { +// Component for finding title that opens the detail drawer +const FindingTitleCell = ({ row }: { row: { original: FindingProps } }) => { const searchParams = useSearchParams(); const findingId = searchParams.get("id"); const isOpen = findingId === row.original.id; + const { checktitle } = row.original.attributes.check_metadata; const handleOpenChange = (open: boolean) => { const params = new URLSearchParams(searchParams); @@ -76,22 +70,18 @@ const FindingDetailsCell = ({ row }: { row: any }) => { }; return ( -
- - } - title="Finding Details" - description="View the finding details" - defaultOpen={isOpen} - onOpenChange={handleOpenChange} - > - - -
+ +

+ {checktitle} +

+ + } + /> ); }; @@ -106,11 +96,35 @@ export function getColumnFindings( selectedCount > 0 && selectedCount === selectableRowCount; const isSomeSelected = selectedCount > 0 && selectedCount < selectableRowCount; + return [ + // Notification column - shows new/changed/muted indicators + { + id: "notification", + header: () => null, + cell: ({ row }) => { + const finding = row.original; + const isMuted = finding.attributes.muted; + const mutedReason = finding.attributes.muted_reason; + const delta = finding.attributes.delta as + | (typeof DeltaValues)[keyof typeof DeltaValues] + | undefined; + + return ( + + ); + }, + enableSorting: false, + enableHiding: false, + }, + // Select column { id: "select", header: ({ table }) => { - // Use state calculated from rowSelection to force re-render const headerChecked = isAllSelected ? true : isSomeSelected @@ -118,14 +132,13 @@ export function getColumnFindings( : false; return ( -
+
table.toggleAllPageRowsSelected(checked === true) } aria-label="Select all" - // Disable when no rows are selectable (all muted) disabled={selectableRowCount === 0} />
@@ -134,46 +147,13 @@ export function getColumnFindings( cell: ({ row }) => { const finding = row.original; const isMuted = finding.attributes.muted; - const mutedReason = finding.attributes.muted_reason; - - // Show muted icon with tooltip for muted findings - if (isMuted) { - const ruleName = mutedReason || "Unknown rule"; - - return ( -
- - - -
- -
-
- - - - Mute rule: - - {ruleName} - - -
-
-
- ); - } - - // Use rowSelection directly instead of row.getIsSelected() - // This ensures re-render when selection state changes const isSelected = !!rowSelection[row.id]; return ( -
+
row.toggleSelected(checked === true) } @@ -185,41 +165,33 @@ export function getColumnFindings( enableSorting: false, enableHiding: false, }, + // Status column { - id: "moreInfo", + accessorKey: "status", header: ({ column }) => ( - + ), - cell: ({ row }) => , - enableSorting: false, + cell: ({ row }) => { + const { + attributes: { status }, + } = getFindingsData(row); + + return ; + }, }, + // Finding column - clickable to open detail sheet { accessorKey: "check", header: ({ column }) => ( ), - cell: ({ row }) => { - const { checktitle } = getFindingsMetadata(row); - const { delta } = row.original.attributes; - - return ( -
-
- {delta === "new" || delta === "changed" ? ( - - ) : null} -

- {checktitle} -

-
-
- ); - }, + cell: ({ row }) => , }, + // Resource name column { accessorKey: "resourceName", header: ({ column }) => ( @@ -238,12 +210,13 @@ export function getColumnFindings( }, enableSorting: false, }, + // Severity column { accessorKey: "severity", header: ({ column }) => ( ), @@ -254,74 +227,20 @@ export function getColumnFindings( return ; }, }, + // Provider column { - accessorKey: "status", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const { - attributes: { status }, - } = getFindingsData(row); - - return ; - }, - }, - { - accessorKey: "updated_at", + accessorKey: "provider", header: ({ column }) => ( - + ), cell: ({ row }) => { - const { - attributes: { updated_at }, - } = getFindingsData(row); - return ( -
- -
- ); - }, - }, - // { - // accessorKey: "scanName", - // header: "Scan Name", - // cell: ({ row }) => { - // const name = getScanData(row, "name"); - - // return ( - //

- // {typeof name === "string" || typeof name === "number" - // ? name - // : "Invalid data"} - //

- // ); - // }, - // }, - { - accessorKey: "region", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const region = getResourceData(row, "region"); + const provider = getProviderData(row, "provider"); - return ( -
- {typeof region === "string" ? region : "Invalid region"} -
- ); + return ; }, enableSorting: false, }, + // Service column { accessorKey: "service", header: ({ column }) => ( @@ -329,40 +248,47 @@ export function getColumnFindings( ), cell: ({ row }) => { const { servicename } = getFindingsMetadata(row); - return

{servicename}

; + return ( +

+ {servicename} +

+ ); }, enableSorting: false, }, + // TODO: PROWLER-379 - Enable Impacted Resources column when backend supports grouped findings + // { + // accessorKey: "impactedResources", + // header: ({ column }) => ( + // + // ), + // cell: () => { + // return ; + // }, + // enableSorting: false, + // }, + // Time column { - accessorKey: "cloudProvider", + accessorKey: "updated_at", header: ({ column }) => ( - + ), cell: ({ row }) => { - const provider = getProviderData(row, "provider"); - const alias = getProviderData(row, "alias"); - const uid = getProviderData(row, "uid"); - - return ( - <> - - - ); + const { + attributes: { updated_at }, + } = getFindingsData(row); + return ; }, - enableSorting: false, }, + // Actions column - dropdown with Mute/Jira options { id: "actions", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - return ; - }, + header: () =>
, + cell: ({ row }) => , enableSorting: false, }, ]; diff --git a/ui/components/findings/table/data-table-row-actions.tsx b/ui/components/findings/table/data-table-row-actions.tsx index 585a4098d0..e465930413 100644 --- a/ui/components/findings/table/data-table-row-actions.tsx +++ b/ui/components/findings/table/data-table-row-actions.tsx @@ -16,7 +16,6 @@ import { MuteFindingsModal } from "@/components/findings/mute-findings-modal"; import { SendToJiraModal } from "@/components/findings/send-to-jira-modal"; import { VerticalDotsIcon } from "@/components/icons"; import { JiraIcon } from "@/components/icons/services/IconServices"; -import { Button } from "@/components/shadcn"; import type { FindingProps } from "@/types/components"; import { FindingsSelectionContext } from "./findings-selection-context"; @@ -92,22 +91,16 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { onComplete={handleMuteComplete} /> -
+
- - + + { asChild > diff --git a/ui/components/findings/table/finding-detail.tsx b/ui/components/findings/table/finding-detail.tsx index 28a72fbe8c..62a2b17c37 100644 --- a/ui/components/findings/table/finding-detail.tsx +++ b/ui/components/findings/table/finding-detail.tsx @@ -1,22 +1,37 @@ "use client"; import { Snippet } from "@heroui/snippet"; -import { Tooltip } from "@heroui/tooltip"; -import { ExternalLink, Link } from "lucide-react"; +import { ExternalLink, Link, X } from "lucide-react"; +import { usePathname, useSearchParams } from "next/navigation"; +import type { ReactNode } from "react"; import ReactMarkdown from "react-markdown"; import { - Card, - CardAction, - CardContent, - CardHeader, - CardTitle, + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, + DrawerTrigger, + InfoField, + Tabs, + TabsContent, + TabsList, + TabsTrigger, + Tooltip, + TooltipContent, + TooltipTrigger, } from "@/components/shadcn"; import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet"; import { CustomLink } from "@/components/ui/custom/custom-link"; -import { EntityInfo, InfoField } from "@/components/ui/entities"; +import { EntityInfo } from "@/components/ui/entities"; import { DateWithTime } from "@/components/ui/entities/date-with-time"; import { SeverityBadge } from "@/components/ui/table/severity-badge"; +import { + FindingStatus, + StatusFindingBadge, +} from "@/components/ui/table/status-finding-badge"; import { buildGitFileUrl, extractLineRangeFromUid } from "@/lib/iac-utils"; import { FindingProps, ProviderType } from "@/types"; @@ -50,20 +65,35 @@ const formatDuration = (seconds: number) => { return parts.join(" "); }; +interface FindingDetailProps { + findingDetails: FindingProps; + trigger?: ReactNode; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; +} + export const FindingDetail = ({ findingDetails, -}: { - findingDetails: FindingProps; -}) => { + trigger, + open, + defaultOpen = false, + onOpenChange, +}: FindingDetailProps) => { const finding = findingDetails; const attributes = finding.attributes; const resource = finding.relationships.resource.attributes; const scan = finding.relationships.scan.attributes; const providerDetails = finding.relationships.provider.attributes; - const currentUrl = new URL(window.location.href); - const params = new URLSearchParams(currentUrl.search); - params.set("id", findingDetails.id); - const url = `${window.location.origin}${currentUrl.pathname}?${params.toString()}`; + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const copyFindingUrl = () => { + const params = new URLSearchParams(searchParams.toString()); + params.set("id", findingDetails.id); + const url = `${window.location.origin}${pathname}?${params.toString()}`; + navigator.clipboard.writeText(url); + }; // Build Git URL for IaC findings const gitUrl = @@ -76,49 +106,65 @@ export const FindingDetail = ({ ) : null; - return ( -
+ const content = ( +
{/* Header */} -
-
-

- {renderValue(attributes.check_metadata.checktitle)} - +
+ {/* Row 1: Status badges */} +
+ + + {attributes.delta && ( +
+ + + {attributes.delta} + +
+ )} + +
+ + {/* Row 2: Title with copy link */} +

+ {renderValue(attributes.check_metadata.checktitle)} + + - -

-
-
- + + Copy finding link to clipboard + +

+ + {/* Row 3: First Seen */} +
+
- {/* Check Metadata */} - - - Finding Details -
- {renderValue(attributes.status)} -
-
- + {/* Tabs */} + + + General + Resources + Scans + + +

+ Here is an overview of this finding: +

+ + {/* General Tab */} +
{resource.region} - - +
+ +
+ + - {attributes.delta && ( - -
- - {attributes.delta} -
-
- )} - - + + + + +
- - - - - - - - - - - - {attributes.status === "FAIL" && ( @@ -189,7 +217,7 @@ export const FindingDetail = ({ {attributes.check_metadata.remediation && (
-

+

Remediation Details

@@ -267,30 +295,32 @@ export const FindingDetail = ({ {attributes.check_metadata.categories?.join(", ") || "none"} - - + - {/* Resource Details */} - - - Resource Details + {/* Resources Tab */} + {providerDetails.provider === "iac" && gitUrl && ( - - -
- - +
+ + + + + View in Repository + + + + Go to Resource in the Repository + - +
)} - - +
{renderValue(resource.name)} @@ -316,9 +346,13 @@ export const FindingDetail = ({
+ + + + {resource.tags && Object.entries(resource.tags).length > 0 && (
-

+

Tags

@@ -339,15 +373,10 @@ export const FindingDetail = ({
- - - - {/* Add new Scan Details section */} - - - Scan Details - - + + + {/* Scans Tab */} +
{scan.name || "N/A"} @@ -383,8 +412,36 @@ export const FindingDetail = ({ )}
-
-
+ +
); + + // If no trigger, render content directly (inline mode) + if (!trigger) { + return content; + } + + // With trigger, wrap in Drawer + return ( + + {trigger} + + + Finding Details + View the finding details + + + + Close + + {content} + + + ); }; diff --git a/ui/components/findings/table/findings-table-with-selection.tsx b/ui/components/findings/table/findings-table-with-selection.tsx index 0f3f5a9225..c3720758c9 100644 --- a/ui/components/findings/table/findings-table-with-selection.tsx +++ b/ui/components/findings/table/findings-table-with-selection.tsx @@ -97,6 +97,7 @@ export function FindingsTableWithSelection({ rowSelection={rowSelection} onRowSelectionChange={setRowSelection} getRowCanSelect={getRowCanSelect} + showSearch /> {selectedFindingIds.length > 0 && ( diff --git a/ui/components/findings/table/impacted-resources-cell.tsx b/ui/components/findings/table/impacted-resources-cell.tsx new file mode 100644 index 0000000000..8ad9ccd530 --- /dev/null +++ b/ui/components/findings/table/impacted-resources-cell.tsx @@ -0,0 +1,84 @@ +import { Check, Flag } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +export const TriageStatusValues = { + IN_PROGRESS: "in_progress", + RESOLVED: "resolved", + NONE: "none", +} as const; + +export type TriageStatus = + (typeof TriageStatusValues)[keyof typeof TriageStatusValues]; + +interface TriageBadgeProps { + status: TriageStatus; + count: number; +} + +const TriageBadge = ({ status, count }: TriageBadgeProps) => { + if (status === TriageStatusValues.NONE) { + return null; + } + + const isInProgress = status === TriageStatusValues.IN_PROGRESS; + + return ( + + {isInProgress ? ( + + ) : ( + + )} + {count} + + {isInProgress ? "In-progress" : "Resolved"} + + + ); +}; + +interface ImpactedResourcesCellProps { + impacted: number; + total: number; + inProgress?: number; + resolved?: number; +} + +export const ImpactedResourcesCell = ({ + impacted, + total, + inProgress = 0, + resolved = 0, +}: ImpactedResourcesCellProps) => { + return ( +
+ + {impacted} + of + {total} + + + {inProgress > 0 && ( + + )} + + {resolved > 0 && ( + + )} +
+ ); +}; diff --git a/ui/components/findings/table/index.ts b/ui/components/findings/table/index.ts index de8098d501..e0c203e9e1 100644 --- a/ui/components/findings/table/index.ts +++ b/ui/components/findings/table/index.ts @@ -4,4 +4,8 @@ export * from "./data-table-row-details"; export * from "./finding-detail"; export * from "./findings-selection-context"; export * from "./findings-table-with-selection"; +// TODO: PROWLER-379 - Re-export when backend supports grouped findings +// export * from "./impacted-resources-cell"; +export * from "./notification-indicator"; +export * from "./provider-icon-cell"; export * from "./skeleton-table-findings"; diff --git a/ui/components/findings/table/notification-indicator.tsx b/ui/components/findings/table/notification-indicator.tsx new file mode 100644 index 0000000000..4e040a784d --- /dev/null +++ b/ui/components/findings/table/notification-indicator.tsx @@ -0,0 +1,100 @@ +import Link from "next/link"; + +import { MutedIcon } from "@/components/icons"; +import { Button } from "@/components/shadcn"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; +import { DOCS_URLS } from "@/lib/external-urls"; +import { cn } from "@/lib/utils"; + +export const DeltaValues = { + NEW: "new", + CHANGED: "changed", + NONE: "none", +} as const; + +export type DeltaType = (typeof DeltaValues)[keyof typeof DeltaValues]; + +interface NotificationIndicatorProps { + delta?: DeltaType; + isMuted?: boolean; + mutedReason?: string; +} + +export const NotificationIndicator = ({ + delta, + isMuted = false, + mutedReason, +}: NotificationIndicatorProps) => { + // Muted takes precedence over delta + if (isMuted) { + const ruleName = mutedReason || "Unknown rule"; + + return ( + + +
+ +
+
+ + + Mute rule: + {ruleName} + + +
+ ); + } + + // Show dot with tooltip for new or changed findings + if (delta === DeltaValues.NEW || delta === DeltaValues.CHANGED) { + return ( + + +
+ + +
+ + {delta === DeltaValues.NEW + ? "New finding." + : "Status changed since the previous scan."} + + +
+
+ + ); + } + + // No indicator - return minimal width placeholder + return
; +}; diff --git a/ui/components/findings/table/provider-icon-cell.tsx b/ui/components/findings/table/provider-icon-cell.tsx new file mode 100644 index 0000000000..c99d833cbc --- /dev/null +++ b/ui/components/findings/table/provider-icon-cell.tsx @@ -0,0 +1,52 @@ +import { + AlibabaCloudProviderBadge, + AWSProviderBadge, + AzureProviderBadge, + GCPProviderBadge, + GitHubProviderBadge, + IacProviderBadge, + KS8ProviderBadge, + M365ProviderBadge, + MongoDBAtlasProviderBadge, + OracleCloudProviderBadge, +} from "@/components/icons/providers-badge"; +import { ProviderType } from "@/types"; + +const PROVIDER_ICONS = { + aws: AWSProviderBadge, + azure: AzureProviderBadge, + gcp: GCPProviderBadge, + kubernetes: KS8ProviderBadge, + m365: M365ProviderBadge, + github: GitHubProviderBadge, + iac: IacProviderBadge, + oraclecloud: OracleCloudProviderBadge, + mongodbatlas: MongoDBAtlasProviderBadge, + alibabacloud: AlibabaCloudProviderBadge, +} as const; + +interface ProviderIconCellProps { + provider: ProviderType; + size?: number; +} + +export const ProviderIconCell = ({ + provider, + size = 26, +}: ProviderIconCellProps) => { + const IconComponent = PROVIDER_ICONS[provider]; + + if (!IconComponent) { + return ( +
+ ? +
+ ); + } + + return ( +
+ +
+ ); +}; diff --git a/ui/components/findings/table/skeleton-table-findings.tsx b/ui/components/findings/table/skeleton-table-findings.tsx index 4436f22f60..d7eccad760 100644 --- a/ui/components/findings/table/skeleton-table-findings.tsx +++ b/ui/components/findings/table/skeleton-table-findings.tsx @@ -1,39 +1,147 @@ -import React from "react"; - -import { Card } from "@/components/shadcn/card/card"; import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; +const SkeletonTableRow = () => { + return ( + + {/* Notification dot */} + +
+ +
+ + {/* Checkbox */} + +
+ + {/* Status */} + + + + {/* Finding - multiline text */} + +
+ + +
+ + {/* Resource name chip */} + +
+ + + +
+ + {/* Severity */} + +
+ + +
+ + {/* Provider icon */} + + + + {/* Service */} + + + + {/* Time */} + +
+ + +
+ + {/* Actions */} + + + + + ); +}; + export const SkeletonTableFindings = () => { - const columns = 7; - const rows = 4; + const rows = 10; return ( - - {/* Table headers */} -
- {Array.from({ length: columns }).map((_, index) => ( - - ))} +
+ {/* Toolbar: Search + Total entries */} +
+ {/* Search icon button */} + + {/* Total entries */} +
- {/* Table body */} -
- {Array.from({ length: rows }).map((_, rowIndex) => ( -
- {Array.from({ length: columns }).map((_, colIndex) => ( - - ))} + {/* Table */} + + + + {/* Notification - empty header */} + + {/* Status */} + + {/* Finding */} + + {/* Resource name */} + + {/* Severity */} + + {/* Provider */} + + {/* Service */} + + {/* Time */} + + {/* Actions - empty header */} + + + + {Array.from({ length: rows }).map((_, i) => ( + + ))} + +
+ {/* Checkbox */} + +
+
+ + + + + + + + + + + + + + +
+ + {/* Pagination */} +
+ {/* Rows per page */} +
+ + +
+ {/* Page info + navigation */} +
+ +
+ + + +
- ))} +
- +
); }; diff --git a/ui/components/graphs/sankey-chart.tsx b/ui/components/graphs/sankey-chart.tsx index a562f6a823..fe7708e7a1 100644 --- a/ui/components/graphs/sankey-chart.tsx +++ b/ui/components/graphs/sankey-chart.tsx @@ -7,7 +7,6 @@ import { Rectangle, ResponsiveContainer, Sankey, Tooltip } from "recharts"; import { PROVIDER_ICONS } from "@/components/icons/providers-badge"; import { initializeChartColors } from "@/lib/charts/colors"; -import { mapProviderFiltersForFindings } from "@/lib/provider-helpers"; import { PROVIDER_DISPLAY_NAMES } from "@/types/providers"; import { SEVERITY_FILTER_MAP } from "@/types/severities"; @@ -463,9 +462,6 @@ export function SankeyChart({ const severityFilter = SEVERITY_FILTER_MAP[nodeName]; if (severityFilter) { const params = new URLSearchParams(searchParams.toString()); - - mapProviderFiltersForFindings(params); - params.set("filter[severity__in]", severityFilter); params.set("filter[status__in]", "FAIL"); params.set("filter[muted]", "false"); @@ -480,12 +476,10 @@ export function SankeyChart({ if (severityFilter) { const params = new URLSearchParams(searchParams.toString()); - mapProviderFiltersForFindings(params); - // Always set provider_type filter based on the clicked link's source (provider) // This ensures clicking "AWS → High" filters by AWS even when no global filter is set - const hasProviderIdFilter = searchParams.has("filter[provider_id__in]"); - if (providerType && !hasProviderIdFilter) { + const hasProviderFilter = searchParams.has("filter[provider_id__in]"); + if (providerType && !hasProviderFilter) { params.set("filter[provider_type__in]", providerType); } diff --git a/ui/components/graphs/threat-map.tsx b/ui/components/graphs/threat-map.tsx index f103ef35c2..498a2bf5c3 100644 --- a/ui/components/graphs/threat-map.tsx +++ b/ui/components/graphs/threat-map.tsx @@ -12,7 +12,6 @@ import { useRouter, useSearchParams } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import { Card } from "@/components/shadcn/card/card"; -import { mapProviderFiltersForFindings } from "@/lib/provider-helpers"; import { HorizontalBarChart } from "./horizontal-bar-chart"; import { @@ -399,7 +398,6 @@ export function ThreatMap({ providerType?: string, ) => { const params = new URLSearchParams(searchParams.toString()); - mapProviderFiltersForFindings(params); if (providerType) params.set("filter[provider_type__in]", providerType); params.set("filter[region__in]", regionCode); params.set("filter[status__in]", status); diff --git a/ui/components/shadcn/calendar.tsx b/ui/components/shadcn/calendar.tsx new file mode 100644 index 0000000000..07634a8d4a --- /dev/null +++ b/ui/components/shadcn/calendar.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { DayPicker } from "react-day-picker"; + +import { buttonVariants } from "@/components/shadcn/button/button"; +import { cn } from "@/lib/utils"; + +export type CalendarProps = React.ComponentProps; + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + .day-range-end)]:rounded-r-full [&:has(>.day-range-start)]:rounded-l-full first:[&:has([aria-selected])]:rounded-l-full last:[&:has([aria-selected])]:rounded-r-full" + : "[&:has([aria-selected])]:rounded-full", + ), + day_button: cn( + buttonVariants({ variant: "ghost" }), + "size-8 p-0 font-normal rounded-full aria-selected:opacity-100", + ), + range_start: "day-range-start rounded-l-full", + range_end: "day-range-end rounded-r-full", + selected: + "rounded-full bg-button-primary text-zinc-950 hover:bg-button-primary hover:text-zinc-950 focus:bg-button-primary focus:text-zinc-950", + today: "bg-bg-neutral-tertiary text-text-neutral-primary rounded-full", + outside: + "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground", + disabled: "text-muted-foreground opacity-50", + range_middle: + "aria-selected:bg-accent aria-selected:text-accent-foreground", + hidden: "invisible", + ...classNames, + }} + components={{ + Chevron: ({ orientation }) => { + const Icon = orientation === "left" ? ChevronLeft : ChevronRight; + return ; + }, + }} + {...props} + /> + ); +} +Calendar.displayName = "Calendar"; + +export { Calendar }; diff --git a/ui/components/shadcn/checkbox/checkbox.tsx b/ui/components/shadcn/checkbox/checkbox.tsx index 93977e2026..43da49eb3e 100644 --- a/ui/components/shadcn/checkbox/checkbox.tsx +++ b/ui/components/shadcn/checkbox/checkbox.tsx @@ -13,7 +13,16 @@ function Checkbox({ - + ); diff --git a/ui/components/shadcn/drawer.tsx b/ui/components/shadcn/drawer.tsx new file mode 100644 index 0000000000..239ff1592f --- /dev/null +++ b/ui/components/shadcn/drawer.tsx @@ -0,0 +1,133 @@ +"use client"; + +import type { ComponentProps } from "react"; +import { Drawer as DrawerPrimitive } from "vaul"; + +import { cn } from "@/lib/utils"; + +function Drawer({ ...props }: ComponentProps) { + return ; +} + +function DrawerTrigger({ + ...props +}: ComponentProps) { + return ; +} + +function DrawerPortal({ + ...props +}: ComponentProps) { + return ; +} + +function DrawerClose({ + ...props +}: ComponentProps) { + return ; +} + +function DrawerOverlay({ + className, + ...props +}: ComponentProps) { + return ( + + ); +} + +function DrawerContent({ + className, + children, + ...props +}: ComponentProps) { + return ( + + + +
+ {children} + + + ); +} + +function DrawerHeader({ className, ...props }: ComponentProps<"div">) { + return ( +
+ ); +} + +function DrawerFooter({ className, ...props }: ComponentProps<"div">) { + return ( +
+ ); +} + +function DrawerTitle({ + className, + ...props +}: ComponentProps) { + return ( + + ); +} + +function DrawerDescription({ + className, + ...props +}: ComponentProps) { + return ( + + ); +} + +export { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerOverlay, + DrawerPortal, + DrawerTitle, + DrawerTrigger, +}; diff --git a/ui/components/shadcn/index.ts b/ui/components/shadcn/index.ts index 88ff8d80ac..fdafd282b9 100644 --- a/ui/components/shadcn/index.ts +++ b/ui/components/shadcn/index.ts @@ -7,7 +7,9 @@ export * from "./card/resource-stats-card/resource-stats-card-content"; export * from "./card/resource-stats-card/resource-stats-card-header"; export * from "./checkbox/checkbox"; export * from "./combobox"; +export * from "./drawer"; export * from "./dropdown/dropdown"; +export * from "./info-field"; export * from "./input/input"; export * from "./search-input/search-input"; export * from "./select/multiselect"; diff --git a/ui/components/shadcn/info-field/index.ts b/ui/components/shadcn/info-field/index.ts new file mode 100644 index 0000000000..83097f6852 --- /dev/null +++ b/ui/components/shadcn/info-field/index.ts @@ -0,0 +1 @@ +export * from "./info-field"; diff --git a/ui/components/shadcn/info-field/info-field.tsx b/ui/components/shadcn/info-field/info-field.tsx new file mode 100644 index 0000000000..2f45957e99 --- /dev/null +++ b/ui/components/shadcn/info-field/info-field.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { InfoIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +import { Tooltip, TooltipContent, TooltipTrigger } from "../tooltip"; + +const INFO_FIELD_VARIANTS = { + default: "default", + simple: "simple", + transparent: "transparent", +} as const; + +type InfoFieldVariant = + (typeof INFO_FIELD_VARIANTS)[keyof typeof INFO_FIELD_VARIANTS]; + +interface InfoFieldProps { + label: string; + children: ReactNode; + variant?: InfoFieldVariant; + className?: string; + tooltipContent?: string; + inline?: boolean; +} + +export function InfoField({ + label, + children, + variant = "default", + tooltipContent, + className, + inline = false, +}: InfoFieldProps) { + const labelContent = ( + + {label} + {inline && ":"} + {tooltipContent && ( + + + + + + + {tooltipContent} + + )} + + ); + + if (inline) { + return ( +
+ + {labelContent} + +
{children}
+
+ ); + } + + return ( +
+ + {labelContent} + + + {variant === "simple" ? ( +
+ {children} +
+ ) : variant === "transparent" ? ( +
{children}
+ ) : ( +
+ {children} +
+ )} +
+ ); +} diff --git a/ui/components/shadcn/select/select.tsx b/ui/components/shadcn/select/select.tsx index 8c2b12d283..06b41e90fa 100644 --- a/ui/components/shadcn/select/select.tsx +++ b/ui/components/shadcn/select/select.tsx @@ -46,10 +46,12 @@ function SelectValue({ function SelectTrigger({ className, size = "default", + iconSize = "default", children, ...props }: ComponentProps & { size?: "sm" | "default"; + iconSize?: "sm" | "default"; }) { return (