mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 20:42:02 +00:00
feat(ui): add new findings table (#9699)
This commit is contained in:
@@ -7,6 +7,7 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
### Added
|
||||
- `/api/v1/overviews/compliance-watchlist` to retrieve the compliance watchlist [(#9596)](https://github.com/prowler-cloud/prowler/pull/9596)
|
||||
- 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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -143,8 +143,12 @@ class BaseScanProviderFilter(FilterSet):
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
@@ -4992,8 +5075,9 @@ paths:
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
type: string
|
||||
x-spec-enum-id: eca8c51e6bd28935
|
||||
x-spec-enum-id: 684bf4173d2b754f
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
- azure
|
||||
- gcp
|
||||
@@ -5003,7 +5087,6 @@ paths:
|
||||
- m365
|
||||
- mongodbatlas
|
||||
- oraclecloud
|
||||
- alibabacloud
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -5021,8 +5104,9 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: eca8c51e6bd28935
|
||||
x-spec-enum-id: 684bf4173d2b754f
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
- azure
|
||||
- gcp
|
||||
@@ -5032,7 +5116,6 @@ paths:
|
||||
- m365
|
||||
- mongodbatlas
|
||||
- oraclecloud
|
||||
- alibabacloud
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -5185,8 +5268,9 @@ paths:
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
type: string
|
||||
x-spec-enum-id: eca8c51e6bd28935
|
||||
x-spec-enum-id: 684bf4173d2b754f
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
- azure
|
||||
- gcp
|
||||
@@ -5196,7 +5280,6 @@ paths:
|
||||
- m365
|
||||
- mongodbatlas
|
||||
- oraclecloud
|
||||
- alibabacloud
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -5214,8 +5297,9 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: eca8c51e6bd28935
|
||||
x-spec-enum-id: 684bf4173d2b754f
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
- azure
|
||||
- gcp
|
||||
@@ -5225,7 +5309,6 @@ paths:
|
||||
- m365
|
||||
- mongodbatlas
|
||||
- oraclecloud
|
||||
- alibabacloud
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -5374,6 +5457,7 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
- azure
|
||||
- gcp
|
||||
@@ -5383,7 +5467,6 @@ paths:
|
||||
- m365
|
||||
- mongodbatlas
|
||||
- oraclecloud
|
||||
- alibabacloud
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -5402,6 +5485,7 @@ paths:
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
- azure
|
||||
- gcp
|
||||
@@ -5411,7 +5495,6 @@ paths:
|
||||
- m365
|
||||
- mongodbatlas
|
||||
- oraclecloud
|
||||
- alibabacloud
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -5600,8 +5683,9 @@ paths:
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
type: string
|
||||
x-spec-enum-id: eca8c51e6bd28935
|
||||
x-spec-enum-id: 684bf4173d2b754f
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
- azure
|
||||
- gcp
|
||||
@@ -5611,7 +5695,6 @@ paths:
|
||||
- m365
|
||||
- mongodbatlas
|
||||
- oraclecloud
|
||||
- alibabacloud
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -5629,8 +5712,9 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: eca8c51e6bd28935
|
||||
x-spec-enum-id: 684bf4173d2b754f
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
- azure
|
||||
- gcp
|
||||
@@ -5640,7 +5724,6 @@ paths:
|
||||
- m365
|
||||
- mongodbatlas
|
||||
- oraclecloud
|
||||
- alibabacloud
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -5776,8 +5859,9 @@ paths:
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
type: string
|
||||
x-spec-enum-id: eca8c51e6bd28935
|
||||
x-spec-enum-id: 684bf4173d2b754f
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
- azure
|
||||
- gcp
|
||||
@@ -5787,7 +5871,6 @@ paths:
|
||||
- m365
|
||||
- mongodbatlas
|
||||
- oraclecloud
|
||||
- alibabacloud
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -5805,8 +5888,9 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: eca8c51e6bd28935
|
||||
x-spec-enum-id: 684bf4173d2b754f
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
- azure
|
||||
- gcp
|
||||
@@ -5816,7 +5900,6 @@ paths:
|
||||
- m365
|
||||
- mongodbatlas
|
||||
- oraclecloud
|
||||
- alibabacloud
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -6599,8 +6682,9 @@ paths:
|
||||
name: filter[provider]
|
||||
schema:
|
||||
type: string
|
||||
x-spec-enum-id: eca8c51e6bd28935
|
||||
x-spec-enum-id: 684bf4173d2b754f
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
- azure
|
||||
- gcp
|
||||
@@ -6610,7 +6694,6 @@ paths:
|
||||
- m365
|
||||
- mongodbatlas
|
||||
- oraclecloud
|
||||
- alibabacloud
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -6628,8 +6711,9 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: eca8c51e6bd28935
|
||||
x-spec-enum-id: 684bf4173d2b754f
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
- azure
|
||||
- gcp
|
||||
@@ -6639,7 +6723,6 @@ paths:
|
||||
- m365
|
||||
- mongodbatlas
|
||||
- oraclecloud
|
||||
- alibabacloud
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -6659,8 +6742,9 @@ paths:
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
type: string
|
||||
x-spec-enum-id: eca8c51e6bd28935
|
||||
x-spec-enum-id: 684bf4173d2b754f
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
- azure
|
||||
- gcp
|
||||
@@ -6670,7 +6754,6 @@ paths:
|
||||
- m365
|
||||
- mongodbatlas
|
||||
- oraclecloud
|
||||
- alibabacloud
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -6688,8 +6771,9 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: eca8c51e6bd28935
|
||||
x-spec-enum-id: 684bf4173d2b754f
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
- azure
|
||||
- gcp
|
||||
@@ -6699,7 +6783,6 @@ paths:
|
||||
- m365
|
||||
- mongodbatlas
|
||||
- oraclecloud
|
||||
- alibabacloud
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -7313,8 +7396,9 @@ paths:
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
type: string
|
||||
x-spec-enum-id: eca8c51e6bd28935
|
||||
x-spec-enum-id: 684bf4173d2b754f
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
- azure
|
||||
- gcp
|
||||
@@ -7324,7 +7408,6 @@ paths:
|
||||
- m365
|
||||
- mongodbatlas
|
||||
- oraclecloud
|
||||
- alibabacloud
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -7342,8 +7425,9 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: eca8c51e6bd28935
|
||||
x-spec-enum-id: 684bf4173d2b754f
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
- azure
|
||||
- gcp
|
||||
@@ -7353,7 +7437,6 @@ paths:
|
||||
- m365
|
||||
- mongodbatlas
|
||||
- oraclecloud
|
||||
- alibabacloud
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -7700,8 +7783,9 @@ paths:
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
type: string
|
||||
x-spec-enum-id: eca8c51e6bd28935
|
||||
x-spec-enum-id: 684bf4173d2b754f
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
- azure
|
||||
- gcp
|
||||
@@ -7711,7 +7795,6 @@ paths:
|
||||
- m365
|
||||
- mongodbatlas
|
||||
- oraclecloud
|
||||
- alibabacloud
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -7729,8 +7812,9 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: eca8c51e6bd28935
|
||||
x-spec-enum-id: 684bf4173d2b754f
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
- azure
|
||||
- gcp
|
||||
@@ -7740,7 +7824,6 @@ paths:
|
||||
- m365
|
||||
- mongodbatlas
|
||||
- oraclecloud
|
||||
- alibabacloud
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -7982,8 +8065,9 @@ paths:
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
type: string
|
||||
x-spec-enum-id: eca8c51e6bd28935
|
||||
x-spec-enum-id: 684bf4173d2b754f
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
- azure
|
||||
- gcp
|
||||
@@ -7993,7 +8077,6 @@ paths:
|
||||
- m365
|
||||
- mongodbatlas
|
||||
- oraclecloud
|
||||
- alibabacloud
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -8011,8 +8094,9 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: eca8c51e6bd28935
|
||||
x-spec-enum-id: 684bf4173d2b754f
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
- azure
|
||||
- gcp
|
||||
@@ -8022,7 +8106,6 @@ paths:
|
||||
- m365
|
||||
- mongodbatlas
|
||||
- oraclecloud
|
||||
- alibabacloud
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -8270,8 +8353,9 @@ paths:
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
type: string
|
||||
x-spec-enum-id: eca8c51e6bd28935
|
||||
x-spec-enum-id: 684bf4173d2b754f
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
- azure
|
||||
- gcp
|
||||
@@ -8281,7 +8365,6 @@ paths:
|
||||
- m365
|
||||
- mongodbatlas
|
||||
- oraclecloud
|
||||
- alibabacloud
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -8299,8 +8382,9 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: eca8c51e6bd28935
|
||||
x-spec-enum-id: 684bf4173d2b754f
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
- azure
|
||||
- gcp
|
||||
@@ -8310,7 +8394,6 @@ paths:
|
||||
- m365
|
||||
- mongodbatlas
|
||||
- oraclecloud
|
||||
- alibabacloud
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -9121,8 +9204,9 @@ paths:
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
type: string
|
||||
x-spec-enum-id: eca8c51e6bd28935
|
||||
x-spec-enum-id: 684bf4173d2b754f
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
- azure
|
||||
- gcp
|
||||
@@ -9132,7 +9216,6 @@ paths:
|
||||
- m365
|
||||
- mongodbatlas
|
||||
- oraclecloud
|
||||
- alibabacloud
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -9150,8 +9233,9 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: eca8c51e6bd28935
|
||||
x-spec-enum-id: 684bf4173d2b754f
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
- azure
|
||||
- gcp
|
||||
@@ -9161,7 +9245,6 @@ paths:
|
||||
- m365
|
||||
- mongodbatlas
|
||||
- oraclecloud
|
||||
- alibabacloud
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -15717,6 +15800,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
|
||||
@@ -16727,7 +16848,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
|
||||
@@ -16844,7 +16965,7 @@ components:
|
||||
- oraclecloud
|
||||
- alibabacloud
|
||||
type: string
|
||||
x-spec-enum-id: eca8c51e6bd28935
|
||||
x-spec-enum-id: 684bf4173d2b754f
|
||||
description: |-
|
||||
Type of provider to create.
|
||||
|
||||
@@ -16906,7 +17027,7 @@ components:
|
||||
- oraclecloud
|
||||
- alibabacloud
|
||||
type: string
|
||||
x-spec-enum-id: eca8c51e6bd28935
|
||||
x-spec-enum-id: 684bf4173d2b754f
|
||||
description: |-
|
||||
Type of provider to create.
|
||||
|
||||
@@ -17682,6 +17803,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
|
||||
@@ -18007,6 +18166,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
|
||||
@@ -18346,6 +18543,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
|
||||
|
||||
@@ -4110,6 +4110,37 @@ class TestFindingViewSet:
|
||||
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 @@ class TestFindingViewSet:
|
||||
== 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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()}`;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
+1
-1
@@ -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()}`);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()}`);
|
||||
|
||||
@@ -85,6 +85,7 @@ export default async function Findings({
|
||||
return (
|
||||
<ContentLayout title="Findings" icon="lucide:tag">
|
||||
<FindingsFilters
|
||||
providers={providersData?.data || []}
|
||||
providerIds={providerIds}
|
||||
providerDetails={providerDetails}
|
||||
completedScans={completedScans || []}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { XCircle } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
|
||||
@@ -10,22 +11,36 @@ export interface ClearFiltersButtonProps {
|
||||
className?: string;
|
||||
text?: string;
|
||||
ariaLabel?: string;
|
||||
/** Show the count of active filters */
|
||||
showCount?: boolean;
|
||||
/** Use link style (text only, no button background) */
|
||||
variant?: "link" | "default";
|
||||
}
|
||||
|
||||
export const ClearFiltersButton = ({
|
||||
text = "Clear all filters",
|
||||
ariaLabel = "Reset",
|
||||
showCount = false,
|
||||
variant = "link",
|
||||
}: ClearFiltersButtonProps) => {
|
||||
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 (
|
||||
<Button aria-label={ariaLabel} onClick={clearAllFilters} variant="link">
|
||||
<Button aria-label={ariaLabel} onClick={clearAllFilters} variant={variant}>
|
||||
<XCircle className="mr-0.5 size-4" />
|
||||
{text}
|
||||
{displayText}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<DateValue | null>(() => {
|
||||
const [date, setDate] = useState<Date | undefined>(() => {
|
||||
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 (
|
||||
<div className="flex w-full flex-col md:gap-2">
|
||||
<DatePicker
|
||||
style={{
|
||||
borderRadius: "0.5rem",
|
||||
}}
|
||||
aria-label="Select a Date"
|
||||
classNames={{
|
||||
base: "w-full [&]:!rounded-lg [&>*]:!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={
|
||||
<ButtonGroup
|
||||
fullWidth
|
||||
className="bg-bg-neutral-secondary [&>button]:border-border-neutral-secondary [&>button]:text-bg-button-secondary px-3 pt-3 pb-2"
|
||||
radius="full"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
<Button onPress={() => handleDateChange(now)}>Today</Button>
|
||||
<Button onPress={() => handleDateChange(nextWeek)}>
|
||||
Next week
|
||||
</Button>
|
||||
<Button onPress={() => handleDateChange(nextMonth)}>
|
||||
Next month
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
}
|
||||
calendarProps={{
|
||||
focusedValue: value || undefined,
|
||||
onFocusChange: setValue,
|
||||
nextButtonProps: {
|
||||
variant: "bordered",
|
||||
},
|
||||
prevButtonProps: {
|
||||
variant: "bordered",
|
||||
},
|
||||
}}
|
||||
value={value}
|
||||
onChange={handleDateChange}
|
||||
/>
|
||||
</div>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"border-border-input-primary bg-bg-input-primary text-bg-button-secondary dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press flex h-[52px] w-full items-center justify-between gap-2 rounded-lg border px-4 py-3 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-1 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
!date && "text-bg-button-secondary",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<CalendarIcon className="text-bg-button-secondary size-5 opacity-70" />
|
||||
{date ? format(date, "PPP") : "Pick a date"}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"text-bg-button-secondary size-6 shrink-0 opacity-70 transition-transform duration-200",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="border-border-input-primary bg-bg-input-primary w-auto p-0"
|
||||
align="start"
|
||||
>
|
||||
<Calendar mode="single" selected={date} onSelect={handleDateSelect} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<FilterControlsProps> = ({
|
||||
export const FilterControls = ({
|
||||
search = false,
|
||||
providers = false,
|
||||
date = false,
|
||||
@@ -31,7 +30,7 @@ export const FilterControls: React.FC<FilterControlsProps> = ({
|
||||
accounts = false,
|
||||
mutedFindings = false,
|
||||
customFilters,
|
||||
}) => {
|
||||
}: FilterControlsProps) => {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col items-start gap-4 md:flex-row md:items-center">
|
||||
@@ -44,8 +43,12 @@ export const FilterControls: React.FC<FilterControlsProps> = ({
|
||||
{mutedFindings && <CustomCheckboxMutedFindings />}
|
||||
</div>
|
||||
</div>
|
||||
<Spacer y={8} />
|
||||
{customFilters && <DataTableFilterCustom filters={customFilters} />}
|
||||
{customFilters && customFilters.length > 0 && (
|
||||
<>
|
||||
<Spacer y={8} />
|
||||
<DataTableFilterCustom filters={customFilters} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<FilterControls
|
||||
search
|
||||
date
|
||||
mutedFindings
|
||||
customFilters={[
|
||||
...filterFindings,
|
||||
{
|
||||
key: FilterType.PROVIDER,
|
||||
labelCheckboxGroup: "Provider",
|
||||
values: availableProviderIds,
|
||||
valueLabelMapping: providerDetails,
|
||||
index: 6,
|
||||
},
|
||||
{
|
||||
key: FilterType.REGION,
|
||||
labelCheckboxGroup: "Regions",
|
||||
values: uniqueRegions,
|
||||
index: 3,
|
||||
},
|
||||
{
|
||||
key: FilterType.SERVICE,
|
||||
labelCheckboxGroup: "Services",
|
||||
values: uniqueServices,
|
||||
index: 4,
|
||||
},
|
||||
{
|
||||
key: FilterType.RESOURCE_TYPE,
|
||||
labelCheckboxGroup: "Resource Type",
|
||||
values: uniqueResourceTypes,
|
||||
index: 8,
|
||||
},
|
||||
{
|
||||
key: FilterType.CATEGORY,
|
||||
labelCheckboxGroup: "Category",
|
||||
values: uniqueCategories,
|
||||
labelFormatter: getCategoryLabel,
|
||||
index: 5,
|
||||
},
|
||||
{
|
||||
key: FilterType.SCAN,
|
||||
labelCheckboxGroup: "Scan ID",
|
||||
values: availableScans,
|
||||
valueLabelMapping: scanDetails,
|
||||
index: 7,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
<div className="flex flex-col">
|
||||
{/* First row: Provider selectors + Muted checkbox + More Filters button + Clear Filters */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
|
||||
<ProviderTypeSelector providers={providers} />
|
||||
</div>
|
||||
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
|
||||
<AccountsSelector providers={providers} />
|
||||
</div>
|
||||
<CustomCheckboxMutedFindings />
|
||||
{hasCustomFilters && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{isExpanded ? "Less Filters" : "More Filters"}
|
||||
<ChevronDown
|
||||
className={`size-4 transition-transform duration-300 ${isExpanded ? "rotate-180" : "rotate-0"}`}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
<ClearFiltersButton showCount />
|
||||
</div>
|
||||
|
||||
{/* Expandable filters section */}
|
||||
{hasCustomFilters && (
|
||||
<ExpandableSection isExpanded={isExpanded}>
|
||||
<DataTableFilterCustom
|
||||
filters={customFilters}
|
||||
prependElement={<CustomDatePicker />}
|
||||
hideClearButton
|
||||
/>
|
||||
</ExpandableSection>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex max-w-10 justify-center">
|
||||
<TriggerSheet
|
||||
triggerComponent={
|
||||
<InfoIcon className="text-button-primary" size={16} />
|
||||
}
|
||||
title="Finding Details"
|
||||
description="View the finding details"
|
||||
defaultOpen={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<DataTableRowDetails
|
||||
entityId={row.original.id}
|
||||
findingDetails={row.original}
|
||||
/>
|
||||
</TriggerSheet>
|
||||
</div>
|
||||
<FindingDetail
|
||||
findingDetails={row.original}
|
||||
defaultOpen={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
trigger={
|
||||
<div className="max-w-[500px]">
|
||||
<p className="text-text-neutral-primary hover:text-button-tertiary cursor-pointer text-left text-sm break-words whitespace-normal hover:underline">
|
||||
{checktitle}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<NotificationIndicator
|
||||
delta={delta}
|
||||
isMuted={isMuted}
|
||||
mutedReason={mutedReason}
|
||||
/>
|
||||
);
|
||||
},
|
||||
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 (
|
||||
<div className="flex w-6 items-center justify-center">
|
||||
<div className="ml-1 flex w-6 items-center justify-center pr-4">
|
||||
<Checkbox
|
||||
checked={headerChecked}
|
||||
onCheckedChange={(checked) =>
|
||||
table.toggleAllPageRowsSelected(checked === true)
|
||||
}
|
||||
aria-label="Select all"
|
||||
// Disable when no rows are selectable (all muted)
|
||||
disabled={selectableRowCount === 0}
|
||||
/>
|
||||
</div>
|
||||
@@ -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 (
|
||||
<div className="flex w-6 items-center justify-center">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="border-system-severity-critical/40 cursor-pointer rounded-full border p-0.5">
|
||||
<MutedIcon className="text-system-severity-critical size-3.5" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<Link
|
||||
href="/mutelist"
|
||||
className="text-button-tertiary hover:text-button-tertiary-hover flex items-center gap-1 text-xs underline-offset-4"
|
||||
>
|
||||
<span className="text-text-neutral-primary">
|
||||
Mute rule:
|
||||
</span>
|
||||
<span className="max-w-[150px] truncate">{ruleName}</span>
|
||||
</Link>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Use rowSelection directly instead of row.getIsSelected()
|
||||
// This ensures re-render when selection state changes
|
||||
const isSelected = !!rowSelection[row.id];
|
||||
|
||||
return (
|
||||
<div className="flex w-6 items-center justify-center">
|
||||
<div className="ml-1 flex w-6 items-center justify-center pr-4">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
disabled={isMuted}
|
||||
onCheckedChange={(checked) =>
|
||||
row.toggleSelected(checked === true)
|
||||
}
|
||||
@@ -185,41 +165,33 @@ export function getColumnFindings(
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
// Status column
|
||||
{
|
||||
id: "moreInfo",
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Details" />
|
||||
<DataTableColumnHeader column={column} title="Status" param="status" />
|
||||
),
|
||||
cell: ({ row }) => <FindingDetailsCell row={row} />,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const {
|
||||
attributes: { status },
|
||||
} = getFindingsData(row);
|
||||
|
||||
return <StatusFindingBadge status={status} />;
|
||||
},
|
||||
},
|
||||
// Finding column - clickable to open detail sheet
|
||||
{
|
||||
accessorKey: "check",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
column={column}
|
||||
title={"Finding"}
|
||||
title="Finding"
|
||||
param="check_id"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const { checktitle } = getFindingsMetadata(row);
|
||||
const { delta } = row.original.attributes;
|
||||
|
||||
return (
|
||||
<div className="3xl:max-w-[660px] flex max-w-[410px] flex-row items-center gap-2">
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
{delta === "new" || delta === "changed" ? (
|
||||
<DeltaIndicator delta={delta} />
|
||||
) : null}
|
||||
<p className="text-sm break-words whitespace-normal">
|
||||
{checktitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => <FindingTitleCell row={row} />,
|
||||
},
|
||||
// Resource name column
|
||||
{
|
||||
accessorKey: "resourceName",
|
||||
header: ({ column }) => (
|
||||
@@ -238,12 +210,13 @@ export function getColumnFindings(
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
// Severity column
|
||||
{
|
||||
accessorKey: "severity",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
column={column}
|
||||
title={"Severity"}
|
||||
title="Severity"
|
||||
param="severity"
|
||||
/>
|
||||
),
|
||||
@@ -254,74 +227,20 @@ export function getColumnFindings(
|
||||
return <SeverityBadge severity={severity} />;
|
||||
},
|
||||
},
|
||||
// Provider column
|
||||
{
|
||||
accessorKey: "status",
|
||||
accessorKey: "provider",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
column={column}
|
||||
title={"Status"}
|
||||
param="status"
|
||||
/>
|
||||
<DataTableColumnHeader column={column} title="Provider" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const {
|
||||
attributes: { status },
|
||||
} = getFindingsData(row);
|
||||
const provider = getProviderData(row, "provider");
|
||||
|
||||
return <StatusFindingBadge status={status} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "updated_at",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
column={column}
|
||||
title={"Last seen"}
|
||||
param="updated_at"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const {
|
||||
attributes: { updated_at },
|
||||
} = getFindingsData(row);
|
||||
return (
|
||||
<div className="w-[100px]">
|
||||
<DateWithTime dateTime={updated_at} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
// {
|
||||
// accessorKey: "scanName",
|
||||
// header: "Scan Name",
|
||||
// cell: ({ row }) => {
|
||||
// const name = getScanData(row, "name");
|
||||
|
||||
// return (
|
||||
// <p className="text-small">
|
||||
// {typeof name === "string" || typeof name === "number"
|
||||
// ? name
|
||||
// : "Invalid data"}
|
||||
// </p>
|
||||
// );
|
||||
// },
|
||||
// },
|
||||
{
|
||||
accessorKey: "region",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Region" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const region = getResourceData(row, "region");
|
||||
|
||||
return (
|
||||
<div className="w-[80px] text-xs">
|
||||
{typeof region === "string" ? region : "Invalid region"}
|
||||
</div>
|
||||
);
|
||||
return <ProviderIconCell provider={provider as ProviderType} />;
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
// Service column
|
||||
{
|
||||
accessorKey: "service",
|
||||
header: ({ column }) => (
|
||||
@@ -329,40 +248,47 @@ export function getColumnFindings(
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const { servicename } = getFindingsMetadata(row);
|
||||
return <p className="max-w-96 truncate text-xs">{servicename}</p>;
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "cloudProvider",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Cloud Provider" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const provider = getProviderData(row, "provider");
|
||||
const alias = getProviderData(row, "alias");
|
||||
const uid = getProviderData(row, "uid");
|
||||
|
||||
return (
|
||||
<>
|
||||
<EntityInfo
|
||||
cloudProvider={provider as ProviderType}
|
||||
entityAlias={alias as string}
|
||||
entityId={uid as string}
|
||||
/>
|
||||
</>
|
||||
<p className="text-text-neutral-primary max-w-[100px] truncate text-sm">
|
||||
{servicename}
|
||||
</p>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
// TODO: PROWLER-379 - Enable Impacted Resources column when backend supports grouped findings
|
||||
// {
|
||||
// accessorKey: "impactedResources",
|
||||
// header: ({ column }) => (
|
||||
// <DataTableColumnHeader column={column} title="Impacted Resources" />
|
||||
// ),
|
||||
// cell: () => {
|
||||
// return <ImpactedResourcesCell impacted={1} total={1} />;
|
||||
// },
|
||||
// enableSorting: false,
|
||||
// },
|
||||
// Time column
|
||||
{
|
||||
id: "actions",
|
||||
accessorKey: "updated_at",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Actions" />
|
||||
<DataTableColumnHeader
|
||||
column={column}
|
||||
title="Time"
|
||||
param="updated_at"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return <DataTableRowActions row={row} />;
|
||||
const {
|
||||
attributes: { updated_at },
|
||||
} = getFindingsData(row);
|
||||
return <DateWithTime dateTime={updated_at} />;
|
||||
},
|
||||
},
|
||||
// Actions column - dropdown with Mute/Jira options
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <div className="w-10" />,
|
||||
cell: ({ row }) => <DataTableRowActions row={row} />,
|
||||
enableSorting: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-center px-2">
|
||||
<div className="flex items-center justify-end">
|
||||
<Dropdown
|
||||
className="border-border-neutral-secondary bg-bg-neutral-secondary border shadow-xl"
|
||||
placement="bottom"
|
||||
>
|
||||
<DropdownTrigger>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="size-7 rounded-full"
|
||||
>
|
||||
<VerticalDotsIcon
|
||||
size={16}
|
||||
className="text-text-neutral-secondary"
|
||||
/>
|
||||
</Button>
|
||||
<DropdownTrigger aria-label="Finding actions">
|
||||
<VerticalDotsIcon
|
||||
size={20}
|
||||
className="text-text-neutral-secondary"
|
||||
/>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
closeOnSelect
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Tooltip } from "@heroui/tooltip";
|
||||
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { DOCS_URLS } from "@/lib/external-urls";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DeltaIndicatorProps {
|
||||
@@ -26,7 +27,7 @@ export const DeltaIndicator = ({ delta }: DeltaIndicatorProps) => {
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href="https://docs.prowler.com/user-guide/tutorials/prowler-app#step-8:-analyze-the-findings"
|
||||
href={DOCS_URLS.FINDINGS_ANALYSIS}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-6 rounded-lg">
|
||||
const content = (
|
||||
<div className="flex min-w-0 flex-col gap-4 rounded-lg">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="dark:text-prowler-theme-pale/90 line-clamp-2 flex items-center gap-2 text-lg leading-tight font-medium text-gray-800">
|
||||
{renderValue(attributes.check_metadata.checktitle)}
|
||||
<Tooltip content="Copy finding link to clipboard" size="sm">
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(url)}
|
||||
className="text-bg-data-info inline-flex cursor-pointer transition-opacity hover:opacity-80"
|
||||
aria-label="Copy finding link to clipboard"
|
||||
>
|
||||
<Link size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Row 1: Status badges */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<StatusFindingBadge status={attributes.status as FindingStatus} />
|
||||
<SeverityBadge severity={attributes.severity || "-"} />
|
||||
{attributes.delta && (
|
||||
<div className="flex items-center gap-1 capitalize">
|
||||
<DeltaIndicator delta={attributes.delta} />
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
{attributes.delta}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Muted
|
||||
isMuted={attributes.muted}
|
||||
mutedReason={attributes.muted_reason || ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Title with copy link */}
|
||||
<h2 className="text-text-neutral-primary line-clamp-2 flex items-center gap-2 text-lg leading-tight font-medium">
|
||||
{renderValue(attributes.check_metadata.checktitle)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={copyFindingUrl}
|
||||
className="text-bg-data-info inline-flex cursor-pointer transition-opacity hover:opacity-80"
|
||||
aria-label="Copy finding link to clipboard"
|
||||
>
|
||||
<Link size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy finding link to clipboard</TooltipContent>
|
||||
</Tooltip>
|
||||
</h2>
|
||||
|
||||
{/* Row 3: First Seen */}
|
||||
<div className="text-text-neutral-tertiary text-sm">
|
||||
<DateWithTime inline dateTime={attributes.first_seen_at || "-"} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Check Metadata */}
|
||||
<Card variant="base" padding="lg">
|
||||
<CardHeader className="flex items-center justify-between">
|
||||
<CardTitle>Finding Details</CardTitle>
|
||||
<div
|
||||
className={`rounded-lg px-3 py-1 text-sm font-semibold ${
|
||||
attributes.status === "PASS"
|
||||
? "bg-green-100 text-green-600"
|
||||
: attributes.status === "MANUAL"
|
||||
? "bg-gray-100 text-gray-600"
|
||||
: "text-system-severity-critical bg-red-100"
|
||||
}`}
|
||||
>
|
||||
{renderValue(attributes.status)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="general" className="w-full">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
<TabsTrigger value="resources">Resources</TabsTrigger>
|
||||
<TabsTrigger value="scans">Scans</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<p className="text-text-neutral-primary mb-4 text-sm">
|
||||
Here is an overview of this finding:
|
||||
</p>
|
||||
|
||||
{/* General Tab */}
|
||||
<TabsContent value="general" className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<EntityInfo
|
||||
cloudProvider={providerDetails.provider as ProviderType}
|
||||
@@ -130,37 +176,19 @@ export const FindingDetail = ({
|
||||
{attributes.check_metadata.servicename}
|
||||
</InfoField>
|
||||
<InfoField label="Region">{resource.region}</InfoField>
|
||||
<InfoField label="First Seen">
|
||||
<DateWithTime inline dateTime={attributes.first_seen_at || "-"} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<InfoField label="Check ID" variant="simple">
|
||||
<CodeSnippet value={attributes.check_id} />
|
||||
</InfoField>
|
||||
{attributes.delta && (
|
||||
<InfoField
|
||||
label="Delta"
|
||||
tooltipContent="Indicates whether the finding is new (NEW), has changed status (CHANGED), or remains unchanged (NONE) compared to previous scans."
|
||||
className="capitalize"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<DeltaIndicator delta={attributes.delta} />
|
||||
{attributes.delta}
|
||||
</div>
|
||||
</InfoField>
|
||||
)}
|
||||
<InfoField label="Severity" variant="simple">
|
||||
<SeverityBadge severity={attributes.severity || "-"} />
|
||||
<InfoField label="Finding ID" variant="simple">
|
||||
<CodeSnippet value={findingDetails.id} />
|
||||
</InfoField>
|
||||
<InfoField label="Finding UID" variant="simple">
|
||||
<CodeSnippet value={attributes.uid} />
|
||||
</InfoField>
|
||||
</div>
|
||||
<InfoField label="Finding ID" variant="simple">
|
||||
<CodeSnippet value={findingDetails.id} />
|
||||
</InfoField>
|
||||
<InfoField label="Check ID" variant="simple">
|
||||
<CodeSnippet value={attributes.check_id} />
|
||||
</InfoField>
|
||||
<InfoField label="Finding UID" variant="simple">
|
||||
<CodeSnippet value={attributes.uid} />
|
||||
</InfoField>
|
||||
<InfoField label="Resource ID" variant="simple">
|
||||
<CodeSnippet value={resource.uid} />
|
||||
</InfoField>
|
||||
|
||||
{attributes.status === "FAIL" && (
|
||||
<InfoField label="Risk" variant="simple">
|
||||
@@ -189,7 +217,7 @@ export const FindingDetail = ({
|
||||
|
||||
{attributes.check_metadata.remediation && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h4 className="dark:text-prowler-theme-pale/90 text-sm font-bold text-gray-700">
|
||||
<h4 className="text-text-neutral-primary text-sm font-bold">
|
||||
Remediation Details
|
||||
</h4>
|
||||
|
||||
@@ -267,30 +295,32 @@ export const FindingDetail = ({
|
||||
<InfoField label="Categories">
|
||||
{attributes.check_metadata.categories?.join(", ") || "none"}
|
||||
</InfoField>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Resource Details */}
|
||||
<Card variant="base" padding="lg">
|
||||
<CardHeader>
|
||||
<CardTitle>Resource Details</CardTitle>
|
||||
{/* Resources Tab */}
|
||||
<TabsContent value="resources" className="flex flex-col gap-4">
|
||||
{providerDetails.provider === "iac" && gitUrl && (
|
||||
<CardAction>
|
||||
<Tooltip content="Go to Resource in the Repository" size="sm">
|
||||
<a
|
||||
href={gitUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-bg-data-info inline-flex cursor-pointer"
|
||||
aria-label="Open resource in repository"
|
||||
>
|
||||
<ExternalLink size={16} className="inline" />
|
||||
</a>
|
||||
<div className="flex justify-end">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={gitUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-bg-data-info inline-flex items-center gap-1 text-sm"
|
||||
aria-label="Open resource in repository"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
View in Repository
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Go to Resource in the Repository
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</CardAction>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Resource Name">
|
||||
{renderValue(resource.name)}
|
||||
@@ -316,9 +346,13 @@ export const FindingDetail = ({
|
||||
</InfoField>
|
||||
</div>
|
||||
|
||||
<InfoField label="Resource ID" variant="simple">
|
||||
<CodeSnippet value={resource.uid} />
|
||||
</InfoField>
|
||||
|
||||
{resource.tags && Object.entries(resource.tags).length > 0 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h4 className="text-sm font-bold text-gray-500 dark:text-gray-400">
|
||||
<h4 className="text-text-neutral-secondary text-sm font-bold">
|
||||
Tags
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
@@ -339,15 +373,10 @@ export const FindingDetail = ({
|
||||
<DateWithTime inline dateTime={resource.updated_at || "-"} />
|
||||
</InfoField>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Add new Scan Details section */}
|
||||
<Card variant="base" padding="lg">
|
||||
<CardHeader>
|
||||
<CardTitle>Scan Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{/* Scans Tab */}
|
||||
<TabsContent value="scans" className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<InfoField label="Scan Name">{scan.name || "N/A"}</InfoField>
|
||||
<InfoField label="Resources Scanned">
|
||||
@@ -383,8 +412,36 @@ export const FindingDetail = ({
|
||||
</InfoField>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
// If no trigger, render content directly (inline mode)
|
||||
if (!trigger) {
|
||||
return content;
|
||||
}
|
||||
|
||||
// With trigger, wrap in Drawer
|
||||
return (
|
||||
<Drawer
|
||||
direction="right"
|
||||
open={open}
|
||||
defaultOpen={defaultOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||
<DrawerContent className="minimal-scrollbar 3xl:w-1/3 h-full w-full overflow-x-hidden overflow-y-auto p-6 md:w-1/2 md:max-w-none">
|
||||
<DrawerHeader className="sr-only">
|
||||
<DrawerTitle>Finding Details</DrawerTitle>
|
||||
<DrawerDescription>View the finding details</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<DrawerClose className="ring-offset-background focus:ring-ring absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none">
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DrawerClose>
|
||||
{content}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -97,6 +97,7 @@ export function FindingsTableWithSelection({
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={setRowSelection}
|
||||
getRowCanSelect={getRowCanSelect}
|
||||
showSearch
|
||||
/>
|
||||
|
||||
{selectedFindingIds.length > 0 && (
|
||||
|
||||
@@ -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 (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-sm",
|
||||
"border-border-tag-primary bg-bg-tag-primary text-text-neutral-primary",
|
||||
)}
|
||||
>
|
||||
{isInProgress ? (
|
||||
<Flag className="size-3 fill-sky-400 text-sky-400" />
|
||||
) : (
|
||||
<Check className="size-3 text-green-300" />
|
||||
)}
|
||||
<span className="font-bold">{count}</span>
|
||||
<span className="font-normal">
|
||||
{isInProgress ? "In-progress" : "Resolved"}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
interface ImpactedResourcesCellProps {
|
||||
impacted: number;
|
||||
total: number;
|
||||
inProgress?: number;
|
||||
resolved?: number;
|
||||
}
|
||||
|
||||
export const ImpactedResourcesCell = ({
|
||||
impacted,
|
||||
total,
|
||||
inProgress = 0,
|
||||
resolved = 0,
|
||||
}: ImpactedResourcesCellProps) => {
|
||||
return (
|
||||
<div className="flex items-center gap-6">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-sm",
|
||||
"border-border-tag-primary bg-bg-tag-primary text-text-neutral-primary",
|
||||
)}
|
||||
>
|
||||
<span className="font-bold">{impacted}</span>
|
||||
<span className="font-normal">of</span>
|
||||
<span className="font-bold">{total}</span>
|
||||
</span>
|
||||
|
||||
{inProgress > 0 && (
|
||||
<TriageBadge
|
||||
status={TriageStatusValues.IN_PROGRESS}
|
||||
count={inProgress}
|
||||
/>
|
||||
)}
|
||||
|
||||
{resolved > 0 && (
|
||||
<TriageBadge status={TriageStatusValues.RESOLVED} count={resolved} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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 (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="ml-1 flex cursor-pointer items-center justify-center">
|
||||
<MutedIcon className="text-bg-data-muted size-2" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<Link
|
||||
href="/mutelist"
|
||||
className="text-button-tertiary hover:text-button-tertiary-hover flex items-center gap-1 text-xs underline-offset-4"
|
||||
>
|
||||
<span className="text-text-neutral-primary">Mute rule:</span>
|
||||
<span className="max-w-[150px] truncate">{ruleName}</span>
|
||||
</Link>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// Show dot with tooltip for new or changed findings
|
||||
if (delta === DeltaValues.NEW || delta === DeltaValues.CHANGED) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"ml-1 size-1.5 cursor-pointer rounded-full",
|
||||
delta === DeltaValues.NEW
|
||||
? "bg-system-severity-high"
|
||||
: "bg-system-severity-low",
|
||||
)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<span>
|
||||
{delta === DeltaValues.NEW
|
||||
? "New finding."
|
||||
: "Status changed since the previous scan."}
|
||||
</span>
|
||||
<Button
|
||||
aria-label="Learn more about findings"
|
||||
variant="link"
|
||||
size="default"
|
||||
className="text-button-primary h-auto min-w-0 p-0 text-xs"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href={DOCS_URLS.FINDINGS_ANALYSIS}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// No indicator - return minimal width placeholder
|
||||
return <div className="w-2" />;
|
||||
};
|
||||
@@ -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 (
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-white">
|
||||
<span className="text-text-neutral-secondary text-xs">?</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-8 items-center justify-center overflow-hidden rounded-md bg-white">
|
||||
<IconComponent width={size} height={size} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,39 +1,147 @@
|
||||
import React from "react";
|
||||
|
||||
import { Card } from "@/components/shadcn/card/card";
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
|
||||
export const SkeletonTableFindings = () => {
|
||||
const columns = 7;
|
||||
const rows = 4;
|
||||
|
||||
const SkeletonTableRow = () => {
|
||||
return (
|
||||
<Card variant="base" padding="md" className="flex flex-col gap-4">
|
||||
{/* Table headers */}
|
||||
<div className="flex gap-4">
|
||||
{Array.from({ length: columns }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={`header-${index}`}
|
||||
className="h-8"
|
||||
style={{ width: `${100 / columns}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Table body */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: rows }).map((_, rowIndex) => (
|
||||
<div key={`row-${rowIndex}`} className="flex gap-4">
|
||||
{Array.from({ length: columns }).map((_, colIndex) => (
|
||||
<Skeleton
|
||||
key={`cell-${rowIndex}-${colIndex}`}
|
||||
className="h-12"
|
||||
style={{ width: `${100 / columns}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<tr className="border-border-neutral-secondary border-b">
|
||||
{/* Notification dot */}
|
||||
<td className="py-4 pr-2">
|
||||
<div className="flex h-full items-center">
|
||||
<Skeleton className="size-1.5 rounded-full" />
|
||||
</div>
|
||||
</td>
|
||||
{/* Checkbox */}
|
||||
<td className="px-2 py-4">
|
||||
<div className="bg-bg-input-primary border-border-input-primary size-6 rounded-sm border shadow-[0_1px_2px_0_rgba(0,0,0,0.1)]" />
|
||||
</td>
|
||||
{/* Status */}
|
||||
<td className="px-3 py-4">
|
||||
<Skeleton className="h-6 w-11 rounded-md" />
|
||||
</td>
|
||||
{/* Finding - multiline text */}
|
||||
<td className="w-[300px] px-3 py-4">
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-4 w-full rounded" />
|
||||
<Skeleton className="h-4 w-4/5 rounded" />
|
||||
</div>
|
||||
</td>
|
||||
{/* Resource name chip */}
|
||||
<td className="px-3 py-4">
|
||||
<div className="bg-bg-neutral-tertiary flex h-8 w-28 items-center gap-2 rounded-lg px-2">
|
||||
<Skeleton className="size-4 rounded" />
|
||||
<Skeleton className="h-3.5 w-16 rounded" />
|
||||
<Skeleton className="ml-auto size-3.5 rounded" />
|
||||
</div>
|
||||
</td>
|
||||
{/* Severity */}
|
||||
<td className="px-3 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-2 rounded-full" />
|
||||
<Skeleton className="h-4 w-12 rounded" />
|
||||
</div>
|
||||
</td>
|
||||
{/* Provider icon */}
|
||||
<td className="px-3 py-4">
|
||||
<Skeleton className="size-9 rounded-lg" />
|
||||
</td>
|
||||
{/* Service */}
|
||||
<td className="px-3 py-4">
|
||||
<Skeleton className="h-4 w-20 rounded" />
|
||||
</td>
|
||||
{/* Time */}
|
||||
<td className="px-3 py-4">
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-4 w-24 rounded" />
|
||||
<Skeleton className="h-3 w-20 rounded" />
|
||||
</div>
|
||||
</td>
|
||||
{/* Actions */}
|
||||
<td className="px-2 py-4">
|
||||
<Skeleton className="size-6 rounded" />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
export const SkeletonTableFindings = () => {
|
||||
const rows = 10;
|
||||
|
||||
return (
|
||||
<div className="rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary flex w-full flex-col gap-4 overflow-hidden border p-4">
|
||||
{/* Toolbar: Search + Total entries */}
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Search icon button */}
|
||||
<Skeleton className="size-10 rounded-md" />
|
||||
{/* Total entries */}
|
||||
<Skeleton className="h-4 w-28 rounded" />
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-border-neutral-secondary border-b">
|
||||
{/* Notification - empty header */}
|
||||
<th className="w-6 py-3" />
|
||||
{/* Checkbox */}
|
||||
<th className="w-10 px-2 py-3">
|
||||
<div className="bg-bg-input-primary border-border-input-primary size-6 rounded-sm border shadow-[0_1px_2px_0_rgba(0,0,0,0.1)]" />
|
||||
</th>
|
||||
{/* Status */}
|
||||
<th className="px-3 py-3 text-left">
|
||||
<Skeleton className="h-4 w-12 rounded" />
|
||||
</th>
|
||||
{/* Finding */}
|
||||
<th className="w-[300px] px-3 py-3 text-left">
|
||||
<Skeleton className="h-4 w-14 rounded" />
|
||||
</th>
|
||||
{/* Resource name */}
|
||||
<th className="px-3 py-3 text-left">
|
||||
<Skeleton className="h-4 w-24 rounded" />
|
||||
</th>
|
||||
{/* Severity */}
|
||||
<th className="px-3 py-3 text-left">
|
||||
<Skeleton className="h-4 w-14 rounded" />
|
||||
</th>
|
||||
{/* Provider */}
|
||||
<th className="px-3 py-3 text-left">
|
||||
<Skeleton className="h-4 w-14 rounded" />
|
||||
</th>
|
||||
{/* Service */}
|
||||
<th className="px-3 py-3 text-left">
|
||||
<Skeleton className="h-4 w-12 rounded" />
|
||||
</th>
|
||||
{/* Time */}
|
||||
<th className="px-3 py-3 text-left">
|
||||
<Skeleton className="h-4 w-10 rounded" />
|
||||
</th>
|
||||
{/* Actions - empty header */}
|
||||
<th className="w-10 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<SkeletonTableRow key={i} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
{/* Rows per page */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-24 rounded" />
|
||||
<Skeleton className="h-9 w-16 rounded-md" />
|
||||
</div>
|
||||
{/* Page info + navigation */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-4 w-24 rounded" />
|
||||
<div className="flex gap-1">
|
||||
<Skeleton className="size-9 rounded-md" />
|
||||
<Skeleton className="size-9 rounded-md" />
|
||||
<Skeleton className="size-9 rounded-md" />
|
||||
<Skeleton className="size-9 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<typeof DayPicker>;
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: CalendarProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("p-3", className)}
|
||||
classNames={{
|
||||
months: "flex flex-col sm:flex-row space-y-4",
|
||||
month: "space-y-4 px-6",
|
||||
month_caption: "flex justify-center pt-1 relative items-center",
|
||||
caption_label: "text-sm font-medium",
|
||||
nav: "space-x-1 flex items-center",
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"size-7 bg-transparent opacity-50 hover:opacity-100 absolute left-3",
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"size-7 bg-transparent opacity-50 hover:opacity-100 absolute right-3",
|
||||
),
|
||||
month_grid: "w-full border-collapse space-y-1",
|
||||
weekdays: "flex",
|
||||
weekday:
|
||||
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
|
||||
week: "flex w-full mt-2",
|
||||
day: cn(
|
||||
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-button-primary [&:has([aria-selected])]:rounded-full [&:has([aria-selected].day-outside)]:bg-button-primary/50 [&:has([aria-selected].day-range-end)]:rounded-r-full",
|
||||
props.mode === "range"
|
||||
? "[&:has(>.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 <Icon className="size-4" />;
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Calendar.displayName = "Calendar";
|
||||
|
||||
export { Calendar };
|
||||
@@ -13,7 +13,16 @@ function Checkbox({
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
// Base styles - 24x24px
|
||||
"peer size-6 shrink-0 rounded-sm border transition-all outline-none",
|
||||
// Default state
|
||||
"bg-bg-input-primary border-border-input-primary shadow-[0_1px_2px_0_rgba(0,0,0,0.1)]",
|
||||
// Checked state
|
||||
"data-[state=checked]:bg-button-primary data-[state=checked]:border-button-primary data-[state=checked]:text-white",
|
||||
// Focus state
|
||||
"focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press/50 focus-visible:ring-2",
|
||||
// Disabled state
|
||||
"disabled:bg-bg-input-primary/50 disabled:border-border-input-primary/50 disabled:cursor-not-allowed disabled:opacity-40 disabled:shadow-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -22,7 +31,7 @@ function Checkbox({
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
<CheckIcon className="size-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
|
||||
@@ -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<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerTrigger({
|
||||
...props
|
||||
}: ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerPortal({
|
||||
...props
|
||||
}: ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerClose({
|
||||
...props
|
||||
}: ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||
return (
|
||||
<DrawerPortal data-slot="drawer-portal">
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
className={cn(
|
||||
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||
"data-[vaul-drawer-direction=right]:border-l-border-neutral-secondary data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:border-l",
|
||||
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:border-r",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-header"
|
||||
className={cn(
|
||||
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot="drawer-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
data-slot="drawer-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
DrawerPortal,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./info-field";
|
||||
@@ -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 = (
|
||||
<span className="flex items-center gap-1">
|
||||
{label}
|
||||
{inline && ":"}
|
||||
{tooltipContent && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex cursor-pointer items-center">
|
||||
<InfoIcon className="text-bg-data-info size-3" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltipContent}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<span className="text-text-neutral-tertiary text-xs font-bold">
|
||||
{labelContent}
|
||||
</span>
|
||||
<div className="text-text-neutral-primary text-sm">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1", className)}>
|
||||
<span className="text-text-neutral-tertiary text-xs font-bold">
|
||||
{labelContent}
|
||||
</span>
|
||||
|
||||
{variant === "simple" ? (
|
||||
<div className="text-text-neutral-primary text-sm break-all">
|
||||
{children}
|
||||
</div>
|
||||
) : variant === "transparent" ? (
|
||||
<div className="text-text-neutral-primary text-sm">{children}</div>
|
||||
) : (
|
||||
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary text-text-neutral-primary rounded-lg border px-3 py-2 text-sm">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -46,10 +46,12 @@ function SelectValue({
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
iconSize = "default",
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
iconSize?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
@@ -64,7 +66,10 @@ function SelectTrigger({
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon
|
||||
className="text-bg-button-secondary size-6"
|
||||
className={cn(
|
||||
"text-bg-button-secondary",
|
||||
iconSize === "sm" ? "size-4" : "size-6",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</SelectPrimitive.Icon>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { format, parseISO } from "date-fns";
|
||||
import React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DateWithTimeProps {
|
||||
dateTime: string | null; // e.g., "2024-07-17T09:55:14.191475Z"
|
||||
@@ -7,11 +8,11 @@ interface DateWithTimeProps {
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
export const DateWithTime: React.FC<DateWithTimeProps> = ({
|
||||
export const DateWithTime = ({
|
||||
dateTime,
|
||||
showTime = true,
|
||||
inline = false,
|
||||
}) => {
|
||||
}: DateWithTimeProps) => {
|
||||
if (!dateTime) return <span>--</span>;
|
||||
|
||||
try {
|
||||
@@ -23,25 +24,33 @@ export const DateWithTime: React.FC<DateWithTimeProps> = ({
|
||||
}
|
||||
|
||||
const formattedDate = format(date, "MMM dd, yyyy");
|
||||
const formattedTime = format(date, "p");
|
||||
const formattedTime = format(date, "h:mma");
|
||||
const timezone =
|
||||
Intl.DateTimeFormat()
|
||||
.resolvedOptions()
|
||||
.timeZone.split("/")
|
||||
.pop()
|
||||
?.substring(0, 3)
|
||||
.toUpperCase() || "";
|
||||
|
||||
return (
|
||||
<div className="mw-fit py-[2px]">
|
||||
<div
|
||||
className={`flex ${inline ? "flex-row items-center gap-2" : "flex-col"}`}
|
||||
>
|
||||
<span className="text-xs font-semibold whitespace-nowrap">
|
||||
{formattedDate}
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-1",
|
||||
inline ? "flex-row items-center" : "flex-col",
|
||||
)}
|
||||
>
|
||||
<span className="text-text-neutral-primary text-sm whitespace-nowrap">
|
||||
{formattedDate}
|
||||
</span>
|
||||
{showTime && (
|
||||
<span className="text-text-neutral-tertiary text-xs font-medium whitespace-nowrap">
|
||||
{formattedTime} {timezone}
|
||||
</span>
|
||||
{showTime && (
|
||||
<span className="text-xs whitespace-nowrap text-gray-500">
|
||||
{formattedTime}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return <span>-</span>;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ExpandableSectionProps {
|
||||
isExpanded: boolean;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Animated expandable section using CSS grid for smooth height transitions.
|
||||
* Animates from height 0 to auto content height.
|
||||
*/
|
||||
export function ExpandableSection({
|
||||
isExpanded,
|
||||
children,
|
||||
className,
|
||||
}: ExpandableSectionProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-[grid-template-rows] duration-300 ease-in-out",
|
||||
isExpanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className={cn("pt-4", !isExpanded && "invisible")}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -65,12 +65,12 @@ export const DataTableColumnHeader = <TData, TValue>({
|
||||
currentSortParam === "" ||
|
||||
(currentSortParam !== param && currentSortParam !== `-${param}`)
|
||||
) {
|
||||
return <ChevronsLeftRightIcon size={14} className="ml-2 rotate-90" />;
|
||||
return <ChevronsLeftRightIcon size={14} className="ml-1 rotate-90" />;
|
||||
}
|
||||
return currentSortParam === `-${param}` ? (
|
||||
<ArrowDownIcon size={12} className="ml-2" />
|
||||
<ArrowDownIcon size={12} className="ml-1" />
|
||||
) : (
|
||||
<ArrowUpIcon size={12} className="ml-2" />
|
||||
<ArrowUpIcon size={12} className="ml-1" />
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -27,10 +27,16 @@ import { ProviderConnectionStatus } from "@/types/providers";
|
||||
|
||||
export interface DataTableFilterCustomProps {
|
||||
filters: FilterOption[];
|
||||
/** Optional element to render at the start of the filters grid */
|
||||
prependElement?: React.ReactNode;
|
||||
/** Hide the clear filters button and active badges (useful when parent manages this) */
|
||||
hideClearButton?: boolean;
|
||||
}
|
||||
|
||||
export const DataTableFilterCustom = ({
|
||||
filters,
|
||||
prependElement,
|
||||
hideClearButton = false,
|
||||
}: DataTableFilterCustomProps) => {
|
||||
const { updateFilter } = useUrlFilters();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -134,6 +140,7 @@ export const DataTableFilterCustom = ({
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
{prependElement}
|
||||
{sortedFilters().map((filter) => {
|
||||
const selectedValues = getSelectedValues(filter);
|
||||
|
||||
@@ -168,10 +175,12 @@ export const DataTableFilterCustom = ({
|
||||
</MultiSelect>
|
||||
);
|
||||
})}
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<ActiveFilterBadges />
|
||||
<ClearFiltersButton />
|
||||
</div>
|
||||
{!hideClearButton && (
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<ActiveFilterBadges />
|
||||
<ClearFiltersButton />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
DoubleArrowLeftIcon,
|
||||
DoubleArrowRightIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
ChevronFirst,
|
||||
ChevronLast,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/shadcn/select/select";
|
||||
import { getPaginationInfo } from "@/lib";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MetaDataProps } from "@/types";
|
||||
|
||||
interface DataTablePaginationProps {
|
||||
@@ -25,11 +26,11 @@ interface DataTablePaginationProps {
|
||||
disableScroll?: boolean;
|
||||
}
|
||||
|
||||
const baseLinkClass =
|
||||
"relative block rounded border-0 bg-transparent px-3 py-1.5 text-button-primary outline-none transition-all duration-300 hover:bg-gray-200 hover:text-gray-800 focus:shadow-none";
|
||||
|
||||
const disabledLinkClass =
|
||||
"text-gray-300 dark:text-gray-600 hover:bg-transparent hover:text-gray-300 dark:hover:text-gray-600 cursor-default pointer-events-none";
|
||||
const NAV_BUTTON_STYLES = {
|
||||
base: "flex items-center justify-center rounded-full p-3 transition-colors",
|
||||
enabled: "text-text-neutral-secondary hover:text-white",
|
||||
disabled: "text-text-neutral-tertiary cursor-not-allowed pointer-events-none",
|
||||
} as const;
|
||||
|
||||
export function DataTablePagination({
|
||||
metadata,
|
||||
@@ -38,7 +39,7 @@ export function DataTablePagination({
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const initialPageSize = searchParams.get("pageSize") ?? "10";
|
||||
const initialPageSize = searchParams.get("pageSize") ?? "50";
|
||||
|
||||
const [selectedPageSize, setSelectedPageSize] = useState(initialPageSize);
|
||||
|
||||
@@ -73,17 +74,14 @@ export function DataTablePagination({
|
||||
const isLastPage = currentPage === totalPages;
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col-reverse items-center justify-between gap-4 overflow-auto p-1 sm:flex-row sm:gap-8">
|
||||
<div className="text-sm whitespace-nowrap">
|
||||
{totalEntries} entries in total
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-end gap-6 py-1.5">
|
||||
{totalEntries > 10 && (
|
||||
<div className="flex flex-col-reverse items-center gap-4 sm:flex-row sm:gap-6 lg:gap-8">
|
||||
<>
|
||||
{/* Rows per page selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium whitespace-nowrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-text-neutral-secondary text-xs font-medium whitespace-nowrap">
|
||||
Rows per page
|
||||
</p>
|
||||
</span>
|
||||
<Select
|
||||
value={selectedPageSize}
|
||||
onValueChange={(value) => {
|
||||
@@ -113,7 +111,10 @@ export function DataTablePagination({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[6rem]">
|
||||
<SelectTrigger
|
||||
iconSize="sm"
|
||||
className="bg-bg-neutral-tertiary border-border-neutral-tertiary !h-auto !w-auto !min-w-0 !gap-1 !rounded-full !px-[19px] !py-[9px] !text-xs !font-medium backdrop-blur-[46px]"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
@@ -129,68 +130,92 @@ export function DataTablePagination({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center justify-center text-sm font-medium">
|
||||
Page {currentPage} of {totalPages}
|
||||
|
||||
{/* Page info and navigation */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-text-neutral-secondary text-xs font-medium">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
aria-label="Go to first page"
|
||||
className={cn(
|
||||
NAV_BUTTON_STYLES.base,
|
||||
isFirstPage
|
||||
? NAV_BUTTON_STYLES.disabled
|
||||
: NAV_BUTTON_STYLES.enabled,
|
||||
)}
|
||||
href={
|
||||
isFirstPage
|
||||
? pathname + "?" + searchParams.toString()
|
||||
: createPageUrl(1)
|
||||
}
|
||||
scroll={!disableScroll}
|
||||
aria-disabled={isFirstPage}
|
||||
onClick={(e) => isFirstPage && e.preventDefault()}
|
||||
>
|
||||
<ChevronFirst className="size-6" aria-hidden="true" />
|
||||
</Link>
|
||||
<Link
|
||||
aria-label="Go to previous page"
|
||||
className={cn(
|
||||
NAV_BUTTON_STYLES.base,
|
||||
isFirstPage
|
||||
? NAV_BUTTON_STYLES.disabled
|
||||
: NAV_BUTTON_STYLES.enabled,
|
||||
)}
|
||||
href={
|
||||
isFirstPage
|
||||
? pathname + "?" + searchParams.toString()
|
||||
: createPageUrl(currentPage - 1)
|
||||
}
|
||||
scroll={!disableScroll}
|
||||
aria-disabled={isFirstPage}
|
||||
onClick={(e) => isFirstPage && e.preventDefault()}
|
||||
>
|
||||
<ChevronLeft className="size-6" aria-hidden="true" />
|
||||
</Link>
|
||||
<Link
|
||||
aria-label="Go to next page"
|
||||
className={cn(
|
||||
NAV_BUTTON_STYLES.base,
|
||||
isLastPage
|
||||
? NAV_BUTTON_STYLES.disabled
|
||||
: NAV_BUTTON_STYLES.enabled,
|
||||
)}
|
||||
href={
|
||||
isLastPage
|
||||
? pathname + "?" + searchParams.toString()
|
||||
: createPageUrl(currentPage + 1)
|
||||
}
|
||||
scroll={!disableScroll}
|
||||
aria-disabled={isLastPage}
|
||||
onClick={(e) => isLastPage && e.preventDefault()}
|
||||
>
|
||||
<ChevronRight className="size-6" aria-hidden="true" />
|
||||
</Link>
|
||||
<Link
|
||||
aria-label="Go to last page"
|
||||
className={cn(
|
||||
NAV_BUTTON_STYLES.base,
|
||||
isLastPage
|
||||
? NAV_BUTTON_STYLES.disabled
|
||||
: NAV_BUTTON_STYLES.enabled,
|
||||
)}
|
||||
href={
|
||||
isLastPage
|
||||
? pathname + "?" + searchParams.toString()
|
||||
: createPageUrl(totalPages)
|
||||
}
|
||||
scroll={!disableScroll}
|
||||
aria-disabled={isLastPage}
|
||||
onClick={(e) => isLastPage && e.preventDefault()}
|
||||
>
|
||||
<ChevronLast className="size-6" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
aria-label="Go to first page"
|
||||
className={`${baseLinkClass} ${isFirstPage ? disabledLinkClass : ""}`}
|
||||
href={
|
||||
isFirstPage
|
||||
? pathname + "?" + searchParams.toString()
|
||||
: createPageUrl(1)
|
||||
}
|
||||
scroll={!disableScroll}
|
||||
aria-disabled={isFirstPage}
|
||||
onClick={(e) => isFirstPage && e.preventDefault()}
|
||||
>
|
||||
<DoubleArrowLeftIcon className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
<Link
|
||||
aria-label="Go to previous page"
|
||||
className={`${baseLinkClass} ${isFirstPage ? disabledLinkClass : ""}`}
|
||||
href={
|
||||
isFirstPage
|
||||
? pathname + "?" + searchParams.toString()
|
||||
: createPageUrl(currentPage - 1)
|
||||
}
|
||||
scroll={!disableScroll}
|
||||
aria-disabled={isFirstPage}
|
||||
onClick={(e) => isFirstPage && e.preventDefault()}
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
<Link
|
||||
aria-label="Go to next page"
|
||||
className={`${baseLinkClass} ${isLastPage ? disabledLinkClass : ""}`}
|
||||
href={
|
||||
isLastPage
|
||||
? pathname + "?" + searchParams.toString()
|
||||
: createPageUrl(currentPage + 1)
|
||||
}
|
||||
scroll={!disableScroll}
|
||||
aria-disabled={isLastPage}
|
||||
onClick={(e) => isLastPage && e.preventDefault()}
|
||||
>
|
||||
<ChevronRightIcon className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
<Link
|
||||
aria-label="Go to last page"
|
||||
className={`${baseLinkClass} ${isLastPage ? disabledLinkClass : ""}`}
|
||||
href={
|
||||
isLastPage
|
||||
? pathname + "?" + searchParams.toString()
|
||||
: createPageUrl(totalPages)
|
||||
}
|
||||
scroll={!disableScroll}
|
||||
aria-disabled={isLastPage}
|
||||
onClick={(e) => isLastPage && e.preventDefault()}
|
||||
>
|
||||
<DoubleArrowRightIcon className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { LoaderCircleIcon, SearchIcon } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
|
||||
import { Input } from "@/components/shadcn/input/input";
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
export const DataTableSearch = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const { updateFilter } = useUrlFilters();
|
||||
const [value, setValue] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const id = useId();
|
||||
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Keep expanded if there's a value or input is focused
|
||||
const shouldStayExpanded = value.length > 0 || isFocused;
|
||||
|
||||
// Sync with URL on mount
|
||||
useEffect(() => {
|
||||
const searchFromUrl = searchParams.get("filter[search]") || "";
|
||||
setValue(searchFromUrl);
|
||||
// If there's a search value, start expanded
|
||||
if (searchFromUrl) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// Handle input change with debounce
|
||||
const handleChange = (newValue: string) => {
|
||||
setValue(newValue);
|
||||
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (newValue) {
|
||||
setIsLoading(true);
|
||||
debounceTimeoutRef.current = setTimeout(() => {
|
||||
updateFilter("search", newValue);
|
||||
setIsLoading(false);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
updateFilter("search", null);
|
||||
}
|
||||
};
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setIsExpanded(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (!shouldStayExpanded) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsFocused(true);
|
||||
setIsExpanded(true);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsFocused(false);
|
||||
if (!value) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIconClick = () => {
|
||||
setIsExpanded(true);
|
||||
// Focus input after expansion animation starts
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-center transition-all duration-300 ease-in-out",
|
||||
isExpanded ? "w-64" : "w-10",
|
||||
)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{/* Collapsed state - just icon button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleIconClick}
|
||||
className={cn(
|
||||
"border-border-neutral-tertiary bg-bg-neutral-tertiary absolute left-0 flex size-10 items-center justify-center rounded-md border transition-opacity duration-200",
|
||||
isExpanded ? "pointer-events-none opacity-0" : "opacity-100",
|
||||
)}
|
||||
aria-label="Open search"
|
||||
>
|
||||
<SearchIcon className="text-text-neutral-tertiary size-4" />
|
||||
</button>
|
||||
|
||||
{/* Expanded state - full input */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full transition-opacity duration-200",
|
||||
isExpanded ? "opacity-100" : "pointer-events-none opacity-0",
|
||||
)}
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<SearchIcon className="text-text-neutral-tertiary size-4" />
|
||||
</div>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
type="search"
|
||||
placeholder="Search..."
|
||||
value={value}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
className="border-border-neutral-tertiary bg-bg-neutral-tertiary focus:border-border-input-primary-pressed pr-9 pl-9 focus:ring-0 focus:ring-offset-0 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none [&::-webkit-search-results-button]:appearance-none [&::-webkit-search-results-decoration]:appearance-none"
|
||||
/>
|
||||
{isLoading && (
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<LoaderCircleIcon className="text-text-neutral-tertiary size-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { DataTablePagination } from "@/components/ui/table/data-table-pagination";
|
||||
import { DataTableSearch } from "@/components/ui/table/data-table-search";
|
||||
import { FilterOption, MetaDataProps } from "@/types";
|
||||
|
||||
interface DataTableProviderProps<TData, TValue> {
|
||||
@@ -38,6 +39,8 @@ interface DataTableProviderProps<TData, TValue> {
|
||||
onRowSelectionChange?: OnChangeFn<RowSelectionState>;
|
||||
/** Function to determine if a row can be selected */
|
||||
getRowCanSelect?: (row: Row<TData>) => boolean;
|
||||
/** Show search bar in the table toolbar */
|
||||
showSearch?: boolean;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
@@ -49,6 +52,7 @@ export function DataTable<TData, TValue>({
|
||||
rowSelection,
|
||||
onRowSelectionChange,
|
||||
getRowCanSelect,
|
||||
showSearch = false,
|
||||
}: DataTableProviderProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
@@ -79,66 +83,71 @@ export function DataTable<TData, TValue>({
|
||||
? Object.keys(rowSelection).filter((k) => rowSelection[k]).length
|
||||
: 0;
|
||||
|
||||
// Format total entries count
|
||||
const totalEntries = metadata?.pagination?.count ?? 0;
|
||||
const formattedTotal = totalEntries.toLocaleString();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col justify-between gap-4 overflow-auto border p-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={`${headerGroup.id}-${selectionKey}`}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{metadata && (
|
||||
<div className="flex w-full items-center gap-2 py-4">
|
||||
<DataTablePagination
|
||||
metadata={metadata}
|
||||
disableScroll={disableScroll}
|
||||
/>
|
||||
<div className="minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col justify-between gap-4 overflow-auto border p-4">
|
||||
{/* Table Toolbar */}
|
||||
{(showSearch || metadata) && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{showSearch && <DataTableSearch />}</div>
|
||||
{metadata && (
|
||||
<span className="text-text-neutral-secondary text-sm">
|
||||
{formattedTotal} Total Entries
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={`${headerGroup.id}-${selectionKey}`}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{metadata && (
|
||||
<DataTablePagination
|
||||
metadata={metadata}
|
||||
disableScroll={disableScroll}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from "./data-table";
|
||||
export * from "./data-table-column-header";
|
||||
export * from "./data-table-filter-custom";
|
||||
export * from "./data-table-pagination";
|
||||
export * from "./data-table-search";
|
||||
export * from "./severity-badge";
|
||||
export * from "./status-badge";
|
||||
export * from "./status-finding-badge";
|
||||
|
||||
@@ -1,53 +1,46 @@
|
||||
import { Chip } from "@heroui/chip";
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { AlertIcon } from "@/components/icons";
|
||||
|
||||
type Severity = "informational" | "low" | "medium" | "high" | "critical";
|
||||
|
||||
const severityIconMap = {
|
||||
critical: <AlertIcon size={14} className="mr-1" />,
|
||||
export const SeverityValues = {
|
||||
INFORMATIONAL: "informational",
|
||||
LOW: "low",
|
||||
MEDIUM: "medium",
|
||||
HIGH: "high",
|
||||
CRITICAL: "critical",
|
||||
} as const;
|
||||
|
||||
const getSeverityColor = (
|
||||
severity: Severity,
|
||||
): "danger" | "warning" | "default" => {
|
||||
switch (severity) {
|
||||
case "critical":
|
||||
return "danger";
|
||||
case "high":
|
||||
return "danger";
|
||||
case "medium":
|
||||
return "warning";
|
||||
case "low":
|
||||
return "default";
|
||||
default:
|
||||
return "default"; // this is a fallback, though unnecessary due to typing
|
||||
}
|
||||
};
|
||||
export type Severity = (typeof SeverityValues)[keyof typeof SeverityValues];
|
||||
|
||||
const getSeverityIcon = (severity: Severity): React.ReactNode | null => {
|
||||
return severity === "critical" ? severityIconMap.critical : null;
|
||||
};
|
||||
const SEVERITY_CHIP_COLORS = {
|
||||
critical: "bg-bg-data-critical",
|
||||
high: "bg-bg-data-high",
|
||||
medium: "bg-bg-data-medium",
|
||||
low: "bg-bg-data-low",
|
||||
informational: "bg-bg-data-info",
|
||||
} as const;
|
||||
|
||||
export const SeverityBadge = ({ severity }: { severity: Severity }) => {
|
||||
const color = getSeverityColor(severity);
|
||||
const SEVERITY_DISPLAY_NAMES = {
|
||||
critical: "Critical",
|
||||
high: "High",
|
||||
medium: "Medium",
|
||||
low: "Low",
|
||||
informational: "Info",
|
||||
} as const;
|
||||
|
||||
interface SeverityBadgeProps {
|
||||
severity: Severity;
|
||||
}
|
||||
|
||||
export const SeverityBadge = ({ severity }: SeverityBadgeProps) => {
|
||||
const chipColor =
|
||||
SEVERITY_CHIP_COLORS[severity] || SEVERITY_CHIP_COLORS.informational;
|
||||
const displayName = SEVERITY_DISPLAY_NAMES[severity] || severity;
|
||||
|
||||
return (
|
||||
<Chip
|
||||
className={clsx("text-default-600 gap-1 border-none capitalize", {
|
||||
"bg-system-severity-critical text-white dark:text-white":
|
||||
severity === "critical",
|
||||
})}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
color={color}
|
||||
endContent={getSeverityIcon(severity)}
|
||||
>
|
||||
<span className="text-text-neutral-primary text-xs font-light tracking-wide">
|
||||
{severity}
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={cn("size-3 rounded", chipColor)} />
|
||||
<span className="text-text-neutral-primary text-sm capitalize">
|
||||
{displayName}
|
||||
</span>
|
||||
</Chip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,42 +1,47 @@
|
||||
import { Chip } from "@heroui/chip";
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type FindingStatus = "FAIL" | "PASS" | "MANUAL" | "MUTED";
|
||||
export const FindingStatusValues = {
|
||||
FAIL: "FAIL",
|
||||
PASS: "PASS",
|
||||
MANUAL: "MANUAL",
|
||||
MUTED: "MUTED",
|
||||
} as const;
|
||||
|
||||
const statusColorMap: Record<
|
||||
FindingStatus,
|
||||
"danger" | "warning" | "success" | "default"
|
||||
> = {
|
||||
FAIL: "danger",
|
||||
PASS: "success",
|
||||
MANUAL: "warning",
|
||||
MUTED: "default",
|
||||
};
|
||||
export type FindingStatus =
|
||||
(typeof FindingStatusValues)[keyof typeof FindingStatusValues];
|
||||
|
||||
export const StatusFindingBadge = ({
|
||||
status,
|
||||
size = "sm",
|
||||
value,
|
||||
...props
|
||||
}: {
|
||||
const STATUS_STYLES = {
|
||||
FAIL: "border-bg-fail text-bg-fail",
|
||||
PASS: "border-bg-pass text-bg-pass",
|
||||
MANUAL: "border-bg-warning text-bg-warning",
|
||||
MUTED: "border-text-neutral-tertiary text-text-neutral-tertiary",
|
||||
} as const;
|
||||
|
||||
interface StatusFindingBadgeProps {
|
||||
status: FindingStatus;
|
||||
size?: "sm" | "md" | "lg";
|
||||
value?: string | number;
|
||||
}) => {
|
||||
const color = statusColorMap[status];
|
||||
}
|
||||
|
||||
export const StatusFindingBadge = ({
|
||||
status,
|
||||
value,
|
||||
}: StatusFindingBadgeProps) => {
|
||||
const statusStyle = STATUS_STYLES[status] || STATUS_STYLES.MUTED;
|
||||
const displayText =
|
||||
status.charAt(0).toUpperCase() + status.slice(1).toLowerCase();
|
||||
|
||||
return (
|
||||
<Chip
|
||||
className="border-none px-2 py-0"
|
||||
size={size}
|
||||
variant="flat"
|
||||
color={color}
|
||||
{...props}
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded px-0 py-0.5",
|
||||
"border-x border-y-0",
|
||||
"min-w-[38px] text-center text-xs font-bold",
|
||||
statusStyle,
|
||||
)}
|
||||
>
|
||||
<span className="text-default-600 text-xs font-light tracking-wide">
|
||||
{status.charAt(0).toUpperCase() + status.slice(1).toLowerCase()}
|
||||
{value !== undefined && `: ${value}`}
|
||||
</span>
|
||||
</Chip>
|
||||
{displayText}
|
||||
{value !== undefined && `: ${value}`}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
import { cn } from "@heroui/theme";
|
||||
import * as React from "react";
|
||||
import {
|
||||
forwardRef,
|
||||
HTMLAttributes,
|
||||
TdHTMLAttributes,
|
||||
ThHTMLAttributes,
|
||||
} from "react";
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto rounded-lg">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Table = forwardRef<HTMLTableElement, HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto rounded-lg">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"w-full caption-bottom border-separate border-spacing-y-1 text-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
Table.displayName = "Table";
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
const TableHeader = forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead
|
||||
ref={ref}
|
||||
@@ -27,17 +35,17 @@ const TableHeader = React.forwardRef<
|
||||
));
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
const TableBody = forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn("", className)} {...props} />
|
||||
));
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
const TableFooter = forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
@@ -50,14 +58,17 @@ const TableFooter = React.forwardRef<
|
||||
));
|
||||
TableFooter.displayName = "TableFooter";
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
const TableRow = forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"transition-colors hover:bg-slate-100/50 data-[state=selected]:bg-slate-100 dark:hover:bg-slate-800/50 dark:data-[state=selected]:bg-slate-800",
|
||||
"transition-colors",
|
||||
"[&>td:first-child]:rounded-l-full [&>td:last-child]:rounded-r-full",
|
||||
"hover:bg-bg-neutral-tertiary",
|
||||
"data-[state=selected]:bg-bg-neutral-tertiary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -65,14 +76,21 @@ const TableRow = React.forwardRef<
|
||||
));
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
const TableHead = forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-bg-neutral-secondary text-tiny text-foreground-500 data-[hover=true]:text-foreground-400 data-[focus-visible=true]:outline-focus h-10 px-2 text-left align-middle font-semibold whitespace-nowrap outline-none first:rounded-l-lg last:rounded-r-lg data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-offset-2 rtl:text-right rtl:first:rounded-l-[unset] rtl:first:rounded-r-lg rtl:last:rounded-l-lg rtl:last:rounded-r-[unset] dark:text-slate-400 [&:has([role=checkbox])]:pr-0",
|
||||
"bg-bg-neutral-tertiary border-border-neutral-tertiary text-text-neutral-secondary border-y backdrop-blur-[46px]",
|
||||
"h-11 px-1.5 text-left align-middle text-xs font-medium whitespace-nowrap outline-none",
|
||||
"first:rounded-l-full first:border-l first:pl-1",
|
||||
"last:rounded-r-full last:border-r last:pr-3",
|
||||
"data-[hover=true]:text-foreground-400 data-[focus-visible=true]:outline-focus",
|
||||
"data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-offset-2",
|
||||
"rtl:text-right rtl:first:rounded-l-[unset] rtl:first:rounded-r-full rtl:last:rounded-l-full rtl:last:rounded-r-[unset]",
|
||||
"[&:has([role=checkbox])]:pr-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -80,14 +98,14 @@ const TableHead = React.forwardRef<
|
||||
));
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
const TableCell = forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-2.5 align-middle [&:has([role=checkbox])]:pr-0",
|
||||
"px-1.5 py-2 align-middle first:pl-3 last:pr-3 [&:has([role=checkbox])]:pr-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -95,9 +113,9 @@ const TableCell = React.forwardRef<
|
||||
));
|
||||
TableCell.displayName = "TableCell";
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
const TableCaption = forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
|
||||
@@ -479,6 +479,14 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-12-15T11:18:25.093Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "react-day-picker",
|
||||
"from": "9.13.0",
|
||||
"to": "9.13.0",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-01-05T16:21:23.125Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "react-dom",
|
||||
@@ -607,6 +615,14 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "vaul",
|
||||
"from": "1.1.2",
|
||||
"to": "1.1.2",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-01-05T16:21:23.125Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "world-atlas",
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { IntegrationType } from "../types/integrations";
|
||||
|
||||
// Documentation URLs
|
||||
export const DOCS_URLS = {
|
||||
FINDINGS_ANALYSIS:
|
||||
"https://docs.prowler.com/user-guide/tutorials/prowler-app#step-8:-analyze-the-findings",
|
||||
} as const;
|
||||
|
||||
export const getProviderHelpText = (provider: string) => {
|
||||
switch (provider) {
|
||||
case "aws":
|
||||
|
||||
@@ -5,46 +5,6 @@ import {
|
||||
ProviderType,
|
||||
} from "@/types/providers";
|
||||
|
||||
/**
|
||||
* Maps overview provider filters to findings page provider filters.
|
||||
* Converts provider_id__in to provider__in and removes provider_type__in
|
||||
* since provider__in is more specific.
|
||||
*/
|
||||
export const mapProviderFiltersForFindings = (
|
||||
params: URLSearchParams,
|
||||
): void => {
|
||||
const providerIds = params.get("filter[provider_id__in]");
|
||||
if (providerIds) {
|
||||
params.delete("filter[provider_id__in]");
|
||||
params.delete("filter[provider_type__in]");
|
||||
params.set("filter[provider__in]", providerIds);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps overview provider filters to findings page provider filters (object version).
|
||||
* Converts provider_id__in to provider__in and removes provider_type__in
|
||||
* since provider__in is more specific.
|
||||
*/
|
||||
export const mapProviderFiltersForFindingsObject = <
|
||||
T extends Record<string, unknown>,
|
||||
>(
|
||||
filters: T,
|
||||
): T => {
|
||||
const result = { ...filters };
|
||||
const providerIdKey = "filter[provider_id__in]";
|
||||
const providerTypeKey = "filter[provider_type__in]";
|
||||
const providerKey = "filter[provider__in]";
|
||||
|
||||
if (providerIdKey in result) {
|
||||
result[providerKey as keyof T] = result[providerIdKey as keyof T];
|
||||
delete result[providerIdKey as keyof T];
|
||||
delete result[providerTypeKey as keyof T];
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const extractProviderUIDs = (
|
||||
providersData: ProvidersApiResponse,
|
||||
): string[] => {
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
"next-themes": "0.2.1",
|
||||
"radix-ui": "1.4.2",
|
||||
"react": "19.2.2",
|
||||
"react-day-picker": "9.13.0",
|
||||
"react-dom": "19.2.2",
|
||||
"react-hook-form": "7.62.0",
|
||||
"react-markdown": "10.1.0",
|
||||
@@ -100,6 +101,7 @@
|
||||
"tw-animate-css": "1.4.0",
|
||||
"use-stick-to-bottom": "1.1.1",
|
||||
"uuid": "11.1.0",
|
||||
"vaul": "1.1.2",
|
||||
"world-atlas": "2.0.2",
|
||||
"zod": "4.1.11",
|
||||
"zustand": "5.0.8"
|
||||
|
||||
Generated
+44
@@ -198,6 +198,9 @@ importers:
|
||||
react:
|
||||
specifier: 19.2.2
|
||||
version: 19.2.2
|
||||
react-day-picker:
|
||||
specifier: 9.13.0
|
||||
version: 9.13.0(react@19.2.2)
|
||||
react-dom:
|
||||
specifier: 19.2.2
|
||||
version: 19.2.2(react@19.2.2)
|
||||
@@ -246,6 +249,9 @@ importers:
|
||||
uuid:
|
||||
specifier: 11.1.0
|
||||
version: 11.1.0
|
||||
vaul:
|
||||
specifier: 1.1.2
|
||||
version: 1.1.2(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
|
||||
world-atlas:
|
||||
specifier: 2.0.2
|
||||
version: 2.0.2
|
||||
@@ -723,6 +729,9 @@ packages:
|
||||
'@chevrotain/utils@11.0.3':
|
||||
resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==}
|
||||
|
||||
'@date-fns/tz@1.4.1':
|
||||
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
|
||||
|
||||
'@dotenvx/dotenvx@1.51.2':
|
||||
resolution: {integrity: sha512-+693mNflujDZxudSEqSNGpn92QgFhJlBn9q2mDQ9yGWyHuz3hZ8B5g3EXCwdAz4DMJAI+OFCIbfEFZS+YRdrEA==}
|
||||
hasBin: true
|
||||
@@ -5215,6 +5224,9 @@ packages:
|
||||
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
date-fns-jalali@4.1.0-0:
|
||||
resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==}
|
||||
|
||||
date-fns@4.1.0:
|
||||
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
|
||||
|
||||
@@ -7403,6 +7415,12 @@ packages:
|
||||
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
react-day-picker@9.13.0:
|
||||
resolution: {integrity: sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
react-dom@19.2.2:
|
||||
resolution: {integrity: sha512-fhyD2BLrew6qYf4NNtHff1rLXvzR25rq49p+FeqByOazc6TcSi2n8EYulo5C1PbH+1uBW++5S1SG7FcUU6mlDg==}
|
||||
peerDependencies:
|
||||
@@ -8267,6 +8285,12 @@ packages:
|
||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
vaul@1.1.2:
|
||||
resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==}
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||
|
||||
vfile-location@5.0.3:
|
||||
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
|
||||
|
||||
@@ -9248,6 +9272,8 @@ snapshots:
|
||||
|
||||
'@chevrotain/utils@11.0.3': {}
|
||||
|
||||
'@date-fns/tz@1.4.1': {}
|
||||
|
||||
'@dotenvx/dotenvx@1.51.2':
|
||||
dependencies:
|
||||
commander: 11.1.0
|
||||
@@ -14971,6 +14997,8 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
is-data-view: 1.0.2
|
||||
|
||||
date-fns-jalali@4.1.0-0: {}
|
||||
|
||||
date-fns@4.1.0: {}
|
||||
|
||||
dayjs@1.11.19: {}
|
||||
@@ -17538,6 +17566,13 @@ snapshots:
|
||||
iconv-lite: 0.7.1
|
||||
unpipe: 1.0.0
|
||||
|
||||
react-day-picker@9.13.0(react@19.2.2):
|
||||
dependencies:
|
||||
'@date-fns/tz': 1.4.1
|
||||
date-fns: 4.1.0
|
||||
date-fns-jalali: 4.1.0-0
|
||||
react: 19.2.2
|
||||
|
||||
react-dom@19.2.2(react@19.2.2):
|
||||
dependencies:
|
||||
react: 19.2.2
|
||||
@@ -18668,6 +18703,15 @@ snapshots:
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
vaul@1.1.2(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.2(react@19.2.2))(react@19.2.2):
|
||||
dependencies:
|
||||
'@radix-ui/react-dialog': 1.1.14(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
|
||||
react: 19.2.2
|
||||
react-dom: 19.2.2(react@19.2.2)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
|
||||
vfile-location@5.0.3:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
--bg-input-primary: var(--color-white);
|
||||
--border-input-primary: var(--color-slate-400);
|
||||
--border-input-primary-press: var(--color-slate-700);
|
||||
--border-input-primary-pressed: #A7F3D0;
|
||||
--border-input-primary-fill: var(--color-slate-500);
|
||||
|
||||
/* Text Colors */
|
||||
@@ -101,6 +102,7 @@
|
||||
--bg-input-primary: var(--color-neutral-900);
|
||||
--border-input-primary: var(--color-neutral-800);
|
||||
--border-input-primary-press: var(--color-neutral-800);
|
||||
--border-input-primary-pressed: #A7F3D0;
|
||||
--border-input-primary-fill: var(--color-neutral-300);
|
||||
|
||||
/* Text Colors */
|
||||
@@ -185,6 +187,7 @@
|
||||
--color-bg-input-primary: var(--bg-input-primary);
|
||||
--color-border-input-primary: var(--border-input-primary);
|
||||
--color-border-input-primary-press: var(--border-input-primary-press);
|
||||
--color-border-input-primary-pressed: var(--border-input-primary-pressed);
|
||||
--color-border-input-primary-fill: var(--border-input-primary-fill);
|
||||
|
||||
/* Neutral Map Colors */
|
||||
|
||||
Reference in New Issue
Block a user