mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-16 17:00:28 +01:00
Compare commits
5 Commits
refactor/t
...
debug-wal
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
501ad64b9e | ||
|
|
0648cd4e18 | ||
|
|
6d1d028d4c | ||
|
|
92660b457d | ||
|
|
8bfadbc197 |
@@ -403,27 +403,65 @@ components:
|
|||||||
required:
|
required:
|
||||||
- regions
|
- regions
|
||||||
type: object
|
type: object
|
||||||
CloudintegrationtypesAWSCollectionStrategy:
|
CloudintegrationtypesAWSCloudWatchLogsSubscription:
|
||||||
properties:
|
properties:
|
||||||
aws_logs:
|
filterPattern:
|
||||||
$ref: '#/components/schemas/CloudintegrationtypesAWSLogsStrategy'
|
type: string
|
||||||
aws_metrics:
|
logGroupNamePrefix:
|
||||||
$ref: '#/components/schemas/CloudintegrationtypesAWSMetricsStrategy'
|
type: string
|
||||||
s3_buckets:
|
required:
|
||||||
additionalProperties:
|
- logGroupNamePrefix
|
||||||
items:
|
- filterPattern
|
||||||
type: string
|
type: object
|
||||||
type: array
|
CloudintegrationtypesAWSCloudWatchMetricStreamFilter:
|
||||||
type: object
|
properties:
|
||||||
|
metricNames:
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
namespace:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- namespace
|
||||||
type: object
|
type: object
|
||||||
CloudintegrationtypesAWSConnectionArtifact:
|
CloudintegrationtypesAWSConnectionArtifact:
|
||||||
properties:
|
properties:
|
||||||
connectionURL:
|
connectionUrl:
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
- connectionURL
|
- connectionUrl
|
||||||
type: object
|
type: object
|
||||||
CloudintegrationtypesAWSConnectionArtifactRequest:
|
CloudintegrationtypesAWSIntegrationConfig:
|
||||||
|
properties:
|
||||||
|
enabledRegions:
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
telemetryCollectionStrategy:
|
||||||
|
$ref: '#/components/schemas/CloudintegrationtypesAWSTelemetryCollectionStrategy'
|
||||||
|
required:
|
||||||
|
- enabledRegions
|
||||||
|
- telemetryCollectionStrategy
|
||||||
|
type: object
|
||||||
|
CloudintegrationtypesAWSLogsCollectionStrategy:
|
||||||
|
properties:
|
||||||
|
subscriptions:
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/CloudintegrationtypesAWSCloudWatchLogsSubscription'
|
||||||
|
type: array
|
||||||
|
required:
|
||||||
|
- subscriptions
|
||||||
|
type: object
|
||||||
|
CloudintegrationtypesAWSMetricsCollectionStrategy:
|
||||||
|
properties:
|
||||||
|
streamFilters:
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/CloudintegrationtypesAWSCloudWatchMetricStreamFilter'
|
||||||
|
type: array
|
||||||
|
required:
|
||||||
|
- streamFilters
|
||||||
|
type: object
|
||||||
|
CloudintegrationtypesAWSPostableAccountConfig:
|
||||||
properties:
|
properties:
|
||||||
deploymentRegion:
|
deploymentRegion:
|
||||||
type: string
|
type: string
|
||||||
@@ -435,46 +473,6 @@ components:
|
|||||||
- deploymentRegion
|
- deploymentRegion
|
||||||
- regions
|
- regions
|
||||||
type: object
|
type: object
|
||||||
CloudintegrationtypesAWSIntegrationConfig:
|
|
||||||
properties:
|
|
||||||
enabledRegions:
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
type: array
|
|
||||||
telemetry:
|
|
||||||
$ref: '#/components/schemas/CloudintegrationtypesAWSCollectionStrategy'
|
|
||||||
required:
|
|
||||||
- enabledRegions
|
|
||||||
- telemetry
|
|
||||||
type: object
|
|
||||||
CloudintegrationtypesAWSLogsStrategy:
|
|
||||||
properties:
|
|
||||||
cloudwatch_logs_subscriptions:
|
|
||||||
items:
|
|
||||||
properties:
|
|
||||||
filter_pattern:
|
|
||||||
type: string
|
|
||||||
log_group_name_prefix:
|
|
||||||
type: string
|
|
||||||
type: object
|
|
||||||
nullable: true
|
|
||||||
type: array
|
|
||||||
type: object
|
|
||||||
CloudintegrationtypesAWSMetricsStrategy:
|
|
||||||
properties:
|
|
||||||
cloudwatch_metric_stream_filters:
|
|
||||||
items:
|
|
||||||
properties:
|
|
||||||
MetricNames:
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
type: array
|
|
||||||
Namespace:
|
|
||||||
type: string
|
|
||||||
type: object
|
|
||||||
nullable: true
|
|
||||||
type: array
|
|
||||||
type: object
|
|
||||||
CloudintegrationtypesAWSServiceConfig:
|
CloudintegrationtypesAWSServiceConfig:
|
||||||
properties:
|
properties:
|
||||||
logs:
|
logs:
|
||||||
@@ -486,7 +484,7 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
enabled:
|
enabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
s3_buckets:
|
s3Buckets:
|
||||||
additionalProperties:
|
additionalProperties:
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
@@ -498,6 +496,19 @@ components:
|
|||||||
enabled:
|
enabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
type: object
|
type: object
|
||||||
|
CloudintegrationtypesAWSTelemetryCollectionStrategy:
|
||||||
|
properties:
|
||||||
|
logs:
|
||||||
|
$ref: '#/components/schemas/CloudintegrationtypesAWSLogsCollectionStrategy'
|
||||||
|
metrics:
|
||||||
|
$ref: '#/components/schemas/CloudintegrationtypesAWSMetricsCollectionStrategy'
|
||||||
|
s3Buckets:
|
||||||
|
additionalProperties:
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
|
type: object
|
||||||
CloudintegrationtypesAccount:
|
CloudintegrationtypesAccount:
|
||||||
properties:
|
properties:
|
||||||
agentReport:
|
agentReport:
|
||||||
@@ -561,6 +572,26 @@ components:
|
|||||||
nullable: true
|
nullable: true
|
||||||
type: array
|
type: array
|
||||||
type: object
|
type: object
|
||||||
|
CloudintegrationtypesCloudIntegrationService:
|
||||||
|
nullable: true
|
||||||
|
properties:
|
||||||
|
cloudIntegrationId:
|
||||||
|
type: string
|
||||||
|
config:
|
||||||
|
$ref: '#/components/schemas/CloudintegrationtypesServiceConfig'
|
||||||
|
createdAt:
|
||||||
|
format: date-time
|
||||||
|
type: string
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
type:
|
||||||
|
$ref: '#/components/schemas/CloudintegrationtypesServiceID'
|
||||||
|
updatedAt:
|
||||||
|
format: date-time
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
type: object
|
||||||
CloudintegrationtypesCollectedLogAttribute:
|
CloudintegrationtypesCollectedLogAttribute:
|
||||||
properties:
|
properties:
|
||||||
name:
|
name:
|
||||||
@@ -581,13 +612,6 @@ components:
|
|||||||
unit:
|
unit:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
CloudintegrationtypesCollectionStrategy:
|
|
||||||
properties:
|
|
||||||
aws:
|
|
||||||
$ref: '#/components/schemas/CloudintegrationtypesAWSCollectionStrategy'
|
|
||||||
required:
|
|
||||||
- aws
|
|
||||||
type: object
|
|
||||||
CloudintegrationtypesConnectionArtifact:
|
CloudintegrationtypesConnectionArtifact:
|
||||||
properties:
|
properties:
|
||||||
aws:
|
aws:
|
||||||
@@ -595,12 +619,21 @@ components:
|
|||||||
required:
|
required:
|
||||||
- aws
|
- aws
|
||||||
type: object
|
type: object
|
||||||
CloudintegrationtypesConnectionArtifactRequest:
|
CloudintegrationtypesCredentials:
|
||||||
properties:
|
properties:
|
||||||
aws:
|
ingestionKey:
|
||||||
$ref: '#/components/schemas/CloudintegrationtypesAWSConnectionArtifactRequest'
|
type: string
|
||||||
|
ingestionUrl:
|
||||||
|
type: string
|
||||||
|
sigNozApiKey:
|
||||||
|
type: string
|
||||||
|
sigNozApiUrl:
|
||||||
|
type: string
|
||||||
required:
|
required:
|
||||||
- aws
|
- sigNozApiUrl
|
||||||
|
- sigNozApiKey
|
||||||
|
- ingestionUrl
|
||||||
|
- ingestionKey
|
||||||
type: object
|
type: object
|
||||||
CloudintegrationtypesDashboard:
|
CloudintegrationtypesDashboard:
|
||||||
properties:
|
properties:
|
||||||
@@ -626,7 +659,7 @@ components:
|
|||||||
nullable: true
|
nullable: true
|
||||||
type: array
|
type: array
|
||||||
type: object
|
type: object
|
||||||
CloudintegrationtypesGettableAccountWithArtifact:
|
CloudintegrationtypesGettableAccountWithConnectionArtifact:
|
||||||
properties:
|
properties:
|
||||||
connectionArtifact:
|
connectionArtifact:
|
||||||
$ref: '#/components/schemas/CloudintegrationtypesConnectionArtifact'
|
$ref: '#/components/schemas/CloudintegrationtypesConnectionArtifact'
|
||||||
@@ -645,7 +678,7 @@ components:
|
|||||||
required:
|
required:
|
||||||
- accounts
|
- accounts
|
||||||
type: object
|
type: object
|
||||||
CloudintegrationtypesGettableAgentCheckInResponse:
|
CloudintegrationtypesGettableAgentCheckIn:
|
||||||
properties:
|
properties:
|
||||||
account_id:
|
account_id:
|
||||||
type: string
|
type: string
|
||||||
@@ -694,12 +727,72 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
type: array
|
type: array
|
||||||
telemetry:
|
telemetry:
|
||||||
$ref: '#/components/schemas/CloudintegrationtypesAWSCollectionStrategy'
|
$ref: '#/components/schemas/CloudintegrationtypesOldAWSCollectionStrategy'
|
||||||
required:
|
required:
|
||||||
- enabled_regions
|
- enabled_regions
|
||||||
- telemetry
|
- telemetry
|
||||||
type: object
|
type: object
|
||||||
CloudintegrationtypesPostableAgentCheckInRequest:
|
CloudintegrationtypesOldAWSCollectionStrategy:
|
||||||
|
properties:
|
||||||
|
aws_logs:
|
||||||
|
$ref: '#/components/schemas/CloudintegrationtypesOldAWSLogsStrategy'
|
||||||
|
aws_metrics:
|
||||||
|
$ref: '#/components/schemas/CloudintegrationtypesOldAWSMetricsStrategy'
|
||||||
|
provider:
|
||||||
|
type: string
|
||||||
|
s3_buckets:
|
||||||
|
additionalProperties:
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
|
type: object
|
||||||
|
CloudintegrationtypesOldAWSLogsStrategy:
|
||||||
|
properties:
|
||||||
|
cloudwatch_logs_subscriptions:
|
||||||
|
items:
|
||||||
|
properties:
|
||||||
|
filter_pattern:
|
||||||
|
type: string
|
||||||
|
log_group_name_prefix:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
nullable: true
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
|
CloudintegrationtypesOldAWSMetricsStrategy:
|
||||||
|
properties:
|
||||||
|
cloudwatch_metric_stream_filters:
|
||||||
|
items:
|
||||||
|
properties:
|
||||||
|
MetricNames:
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
Namespace:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
nullable: true
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
|
CloudintegrationtypesPostableAccount:
|
||||||
|
properties:
|
||||||
|
config:
|
||||||
|
$ref: '#/components/schemas/CloudintegrationtypesPostableAccountConfig'
|
||||||
|
credentials:
|
||||||
|
$ref: '#/components/schemas/CloudintegrationtypesCredentials'
|
||||||
|
required:
|
||||||
|
- config
|
||||||
|
- credentials
|
||||||
|
type: object
|
||||||
|
CloudintegrationtypesPostableAccountConfig:
|
||||||
|
properties:
|
||||||
|
aws:
|
||||||
|
$ref: '#/components/schemas/CloudintegrationtypesAWSPostableAccountConfig'
|
||||||
|
required:
|
||||||
|
- aws
|
||||||
|
type: object
|
||||||
|
CloudintegrationtypesPostableAgentCheckIn:
|
||||||
properties:
|
properties:
|
||||||
account_id:
|
account_id:
|
||||||
type: string
|
type: string
|
||||||
@@ -727,6 +820,8 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
assets:
|
assets:
|
||||||
$ref: '#/components/schemas/CloudintegrationtypesAssets'
|
$ref: '#/components/schemas/CloudintegrationtypesAssets'
|
||||||
|
cloudIntegrationService:
|
||||||
|
$ref: '#/components/schemas/CloudintegrationtypesCloudIntegrationService'
|
||||||
dataCollected:
|
dataCollected:
|
||||||
$ref: '#/components/schemas/CloudintegrationtypesDataCollected'
|
$ref: '#/components/schemas/CloudintegrationtypesDataCollected'
|
||||||
icon:
|
icon:
|
||||||
@@ -735,12 +830,10 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
overview:
|
overview:
|
||||||
type: string
|
type: string
|
||||||
serviceConfig:
|
supportedSignals:
|
||||||
$ref: '#/components/schemas/CloudintegrationtypesServiceConfig'
|
|
||||||
supported_signals:
|
|
||||||
$ref: '#/components/schemas/CloudintegrationtypesSupportedSignals'
|
$ref: '#/components/schemas/CloudintegrationtypesSupportedSignals'
|
||||||
telemetryCollectionStrategy:
|
telemetryCollectionStrategy:
|
||||||
$ref: '#/components/schemas/CloudintegrationtypesCollectionStrategy'
|
$ref: '#/components/schemas/CloudintegrationtypesTelemetryCollectionStrategy'
|
||||||
title:
|
title:
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
@@ -749,9 +842,10 @@ components:
|
|||||||
- icon
|
- icon
|
||||||
- overview
|
- overview
|
||||||
- assets
|
- assets
|
||||||
- supported_signals
|
- supportedSignals
|
||||||
- dataCollected
|
- dataCollected
|
||||||
- telemetryCollectionStrategy
|
- telemetryCollectionStrategy
|
||||||
|
- cloudIntegrationService
|
||||||
type: object
|
type: object
|
||||||
CloudintegrationtypesServiceConfig:
|
CloudintegrationtypesServiceConfig:
|
||||||
properties:
|
properties:
|
||||||
@@ -760,6 +854,22 @@ components:
|
|||||||
required:
|
required:
|
||||||
- aws
|
- aws
|
||||||
type: object
|
type: object
|
||||||
|
CloudintegrationtypesServiceID:
|
||||||
|
enum:
|
||||||
|
- alb
|
||||||
|
- api-gateway
|
||||||
|
- dynamodb
|
||||||
|
- ec2
|
||||||
|
- ecs
|
||||||
|
- eks
|
||||||
|
- elasticache
|
||||||
|
- lambda
|
||||||
|
- msk
|
||||||
|
- rds
|
||||||
|
- s3sync
|
||||||
|
- sns
|
||||||
|
- sqs
|
||||||
|
type: string
|
||||||
CloudintegrationtypesServiceMetadata:
|
CloudintegrationtypesServiceMetadata:
|
||||||
properties:
|
properties:
|
||||||
enabled:
|
enabled:
|
||||||
@@ -783,6 +893,13 @@ components:
|
|||||||
metrics:
|
metrics:
|
||||||
type: boolean
|
type: boolean
|
||||||
type: object
|
type: object
|
||||||
|
CloudintegrationtypesTelemetryCollectionStrategy:
|
||||||
|
properties:
|
||||||
|
aws:
|
||||||
|
$ref: '#/components/schemas/CloudintegrationtypesAWSTelemetryCollectionStrategy'
|
||||||
|
required:
|
||||||
|
- aws
|
||||||
|
type: object
|
||||||
CloudintegrationtypesUpdatableAccount:
|
CloudintegrationtypesUpdatableAccount:
|
||||||
properties:
|
properties:
|
||||||
config:
|
config:
|
||||||
@@ -3081,7 +3198,7 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/CloudintegrationtypesPostableAgentCheckInRequest'
|
$ref: '#/components/schemas/CloudintegrationtypesPostableAgentCheckIn'
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
content:
|
content:
|
||||||
@@ -3089,7 +3206,7 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
properties:
|
properties:
|
||||||
data:
|
data:
|
||||||
$ref: '#/components/schemas/CloudintegrationtypesGettableAgentCheckInResponse'
|
$ref: '#/components/schemas/CloudintegrationtypesGettableAgentCheckIn'
|
||||||
status:
|
status:
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
@@ -3190,7 +3307,7 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/CloudintegrationtypesConnectionArtifactRequest'
|
$ref: '#/components/schemas/CloudintegrationtypesPostableAccount'
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
content:
|
content:
|
||||||
@@ -3198,7 +3315,7 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
properties:
|
properties:
|
||||||
data:
|
data:
|
||||||
$ref: '#/components/schemas/CloudintegrationtypesGettableAccountWithArtifact'
|
$ref: '#/components/schemas/CloudintegrationtypesGettableAccountWithConnectionArtifact'
|
||||||
status:
|
status:
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
@@ -3394,6 +3511,61 @@ paths:
|
|||||||
summary: Update account
|
summary: Update account
|
||||||
tags:
|
tags:
|
||||||
- cloudintegration
|
- cloudintegration
|
||||||
|
/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}/services/{service_id}:
|
||||||
|
put:
|
||||||
|
deprecated: false
|
||||||
|
description: This endpoint updates a service for the specified cloud provider
|
||||||
|
operationId: UpdateService
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: cloud_provider
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- in: path
|
||||||
|
name: service_id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CloudintegrationtypesUpdatableService'
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: No Content
|
||||||
|
"401":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RenderErrorResponse'
|
||||||
|
description: Unauthorized
|
||||||
|
"403":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RenderErrorResponse'
|
||||||
|
description: Forbidden
|
||||||
|
"500":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RenderErrorResponse'
|
||||||
|
description: Internal Server Error
|
||||||
|
security:
|
||||||
|
- api_key:
|
||||||
|
- ADMIN
|
||||||
|
- tokenizer:
|
||||||
|
- ADMIN
|
||||||
|
summary: Update service
|
||||||
|
tags:
|
||||||
|
- cloudintegration
|
||||||
/api/v1/cloud_integrations/{cloud_provider}/accounts/check_in:
|
/api/v1/cloud_integrations/{cloud_provider}/accounts/check_in:
|
||||||
post:
|
post:
|
||||||
deprecated: false
|
deprecated: false
|
||||||
@@ -3409,7 +3581,7 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/CloudintegrationtypesPostableAgentCheckInRequest'
|
$ref: '#/components/schemas/CloudintegrationtypesPostableAgentCheckIn'
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
content:
|
content:
|
||||||
@@ -3417,7 +3589,7 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
properties:
|
properties:
|
||||||
data:
|
data:
|
||||||
$ref: '#/components/schemas/CloudintegrationtypesGettableAgentCheckInResponse'
|
$ref: '#/components/schemas/CloudintegrationtypesGettableAgentCheckIn'
|
||||||
status:
|
status:
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
@@ -3451,6 +3623,59 @@ paths:
|
|||||||
summary: Agent check-in
|
summary: Agent check-in
|
||||||
tags:
|
tags:
|
||||||
- cloudintegration
|
- cloudintegration
|
||||||
|
/api/v1/cloud_integrations/{cloud_provider}/credentials:
|
||||||
|
get:
|
||||||
|
deprecated: false
|
||||||
|
description: This endpoint retrieves the connection credentials required for
|
||||||
|
integration
|
||||||
|
operationId: GetConnectionCredentials
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: cloud_provider
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/components/schemas/CloudintegrationtypesCredentials'
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- status
|
||||||
|
- data
|
||||||
|
type: object
|
||||||
|
description: OK
|
||||||
|
"401":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RenderErrorResponse'
|
||||||
|
description: Unauthorized
|
||||||
|
"403":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RenderErrorResponse'
|
||||||
|
description: Forbidden
|
||||||
|
"500":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RenderErrorResponse'
|
||||||
|
description: Internal Server Error
|
||||||
|
security:
|
||||||
|
- api_key:
|
||||||
|
- ADMIN
|
||||||
|
- tokenizer:
|
||||||
|
- ADMIN
|
||||||
|
summary: Get connection credentials
|
||||||
|
tags:
|
||||||
|
- cloudintegration
|
||||||
/api/v1/cloud_integrations/{cloud_provider}/services:
|
/api/v1/cloud_integrations/{cloud_provider}/services:
|
||||||
get:
|
get:
|
||||||
deprecated: false
|
deprecated: false
|
||||||
@@ -3561,55 +3786,6 @@ paths:
|
|||||||
summary: Get service
|
summary: Get service
|
||||||
tags:
|
tags:
|
||||||
- cloudintegration
|
- cloudintegration
|
||||||
put:
|
|
||||||
deprecated: false
|
|
||||||
description: This endpoint updates a service for the specified cloud provider
|
|
||||||
operationId: UpdateService
|
|
||||||
parameters:
|
|
||||||
- in: path
|
|
||||||
name: cloud_provider
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
- in: path
|
|
||||||
name: service_id
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
requestBody:
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/CloudintegrationtypesUpdatableService'
|
|
||||||
responses:
|
|
||||||
"204":
|
|
||||||
description: No Content
|
|
||||||
"401":
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/RenderErrorResponse'
|
|
||||||
description: Unauthorized
|
|
||||||
"403":
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/RenderErrorResponse'
|
|
||||||
description: Forbidden
|
|
||||||
"500":
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/RenderErrorResponse'
|
|
||||||
description: Internal Server Error
|
|
||||||
security:
|
|
||||||
- api_key:
|
|
||||||
- ADMIN
|
|
||||||
- tokenizer:
|
|
||||||
- ADMIN
|
|
||||||
summary: Update service
|
|
||||||
tags:
|
|
||||||
- cloudintegration
|
|
||||||
/api/v1/complete/google:
|
/api/v1/complete/google:
|
||||||
get:
|
get:
|
||||||
deprecated: false
|
deprecated: false
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ import type {
|
|||||||
AgentCheckInDeprecated200,
|
AgentCheckInDeprecated200,
|
||||||
AgentCheckInDeprecatedPathParameters,
|
AgentCheckInDeprecatedPathParameters,
|
||||||
AgentCheckInPathParameters,
|
AgentCheckInPathParameters,
|
||||||
CloudintegrationtypesConnectionArtifactRequestDTO,
|
CloudintegrationtypesPostableAccountDTO,
|
||||||
CloudintegrationtypesPostableAgentCheckInRequestDTO,
|
CloudintegrationtypesPostableAgentCheckInDTO,
|
||||||
CloudintegrationtypesUpdatableAccountDTO,
|
CloudintegrationtypesUpdatableAccountDTO,
|
||||||
CloudintegrationtypesUpdatableServiceDTO,
|
CloudintegrationtypesUpdatableServiceDTO,
|
||||||
CreateAccount200,
|
CreateAccount200,
|
||||||
@@ -33,6 +33,8 @@ import type {
|
|||||||
DisconnectAccountPathParameters,
|
DisconnectAccountPathParameters,
|
||||||
GetAccount200,
|
GetAccount200,
|
||||||
GetAccountPathParameters,
|
GetAccountPathParameters,
|
||||||
|
GetConnectionCredentials200,
|
||||||
|
GetConnectionCredentialsPathParameters,
|
||||||
GetService200,
|
GetService200,
|
||||||
GetServicePathParameters,
|
GetServicePathParameters,
|
||||||
ListAccounts200,
|
ListAccounts200,
|
||||||
@@ -51,14 +53,14 @@ import type {
|
|||||||
*/
|
*/
|
||||||
export const agentCheckInDeprecated = (
|
export const agentCheckInDeprecated = (
|
||||||
{ cloudProvider }: AgentCheckInDeprecatedPathParameters,
|
{ cloudProvider }: AgentCheckInDeprecatedPathParameters,
|
||||||
cloudintegrationtypesPostableAgentCheckInRequestDTO: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>,
|
cloudintegrationtypesPostableAgentCheckInDTO: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
) => {
|
) => {
|
||||||
return GeneratedAPIInstance<AgentCheckInDeprecated200>({
|
return GeneratedAPIInstance<AgentCheckInDeprecated200>({
|
||||||
url: `/api/v1/cloud-integrations/${cloudProvider}/agent-check-in`,
|
url: `/api/v1/cloud-integrations/${cloudProvider}/agent-check-in`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
data: cloudintegrationtypesPostableAgentCheckInRequestDTO,
|
data: cloudintegrationtypesPostableAgentCheckInDTO,
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -72,7 +74,7 @@ export const getAgentCheckInDeprecatedMutationOptions = <
|
|||||||
TError,
|
TError,
|
||||||
{
|
{
|
||||||
pathParams: AgentCheckInDeprecatedPathParameters;
|
pathParams: AgentCheckInDeprecatedPathParameters;
|
||||||
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
|
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
|
||||||
},
|
},
|
||||||
TContext
|
TContext
|
||||||
>;
|
>;
|
||||||
@@ -81,7 +83,7 @@ export const getAgentCheckInDeprecatedMutationOptions = <
|
|||||||
TError,
|
TError,
|
||||||
{
|
{
|
||||||
pathParams: AgentCheckInDeprecatedPathParameters;
|
pathParams: AgentCheckInDeprecatedPathParameters;
|
||||||
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
|
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
|
||||||
},
|
},
|
||||||
TContext
|
TContext
|
||||||
> => {
|
> => {
|
||||||
@@ -98,7 +100,7 @@ export const getAgentCheckInDeprecatedMutationOptions = <
|
|||||||
Awaited<ReturnType<typeof agentCheckInDeprecated>>,
|
Awaited<ReturnType<typeof agentCheckInDeprecated>>,
|
||||||
{
|
{
|
||||||
pathParams: AgentCheckInDeprecatedPathParameters;
|
pathParams: AgentCheckInDeprecatedPathParameters;
|
||||||
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
|
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
|
||||||
}
|
}
|
||||||
> = (props) => {
|
> = (props) => {
|
||||||
const { pathParams, data } = props ?? {};
|
const { pathParams, data } = props ?? {};
|
||||||
@@ -112,7 +114,7 @@ export const getAgentCheckInDeprecatedMutationOptions = <
|
|||||||
export type AgentCheckInDeprecatedMutationResult = NonNullable<
|
export type AgentCheckInDeprecatedMutationResult = NonNullable<
|
||||||
Awaited<ReturnType<typeof agentCheckInDeprecated>>
|
Awaited<ReturnType<typeof agentCheckInDeprecated>>
|
||||||
>;
|
>;
|
||||||
export type AgentCheckInDeprecatedMutationBody = BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
|
export type AgentCheckInDeprecatedMutationBody = BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
|
||||||
export type AgentCheckInDeprecatedMutationError = ErrorType<RenderErrorResponseDTO>;
|
export type AgentCheckInDeprecatedMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -128,7 +130,7 @@ export const useAgentCheckInDeprecated = <
|
|||||||
TError,
|
TError,
|
||||||
{
|
{
|
||||||
pathParams: AgentCheckInDeprecatedPathParameters;
|
pathParams: AgentCheckInDeprecatedPathParameters;
|
||||||
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
|
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
|
||||||
},
|
},
|
||||||
TContext
|
TContext
|
||||||
>;
|
>;
|
||||||
@@ -137,7 +139,7 @@ export const useAgentCheckInDeprecated = <
|
|||||||
TError,
|
TError,
|
||||||
{
|
{
|
||||||
pathParams: AgentCheckInDeprecatedPathParameters;
|
pathParams: AgentCheckInDeprecatedPathParameters;
|
||||||
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
|
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
|
||||||
},
|
},
|
||||||
TContext
|
TContext
|
||||||
> => {
|
> => {
|
||||||
@@ -255,14 +257,14 @@ export const invalidateListAccounts = async (
|
|||||||
*/
|
*/
|
||||||
export const createAccount = (
|
export const createAccount = (
|
||||||
{ cloudProvider }: CreateAccountPathParameters,
|
{ cloudProvider }: CreateAccountPathParameters,
|
||||||
cloudintegrationtypesConnectionArtifactRequestDTO: BodyType<CloudintegrationtypesConnectionArtifactRequestDTO>,
|
cloudintegrationtypesPostableAccountDTO: BodyType<CloudintegrationtypesPostableAccountDTO>,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
) => {
|
) => {
|
||||||
return GeneratedAPIInstance<CreateAccount200>({
|
return GeneratedAPIInstance<CreateAccount200>({
|
||||||
url: `/api/v1/cloud_integrations/${cloudProvider}/accounts`,
|
url: `/api/v1/cloud_integrations/${cloudProvider}/accounts`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
data: cloudintegrationtypesConnectionArtifactRequestDTO,
|
data: cloudintegrationtypesPostableAccountDTO,
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -276,7 +278,7 @@ export const getCreateAccountMutationOptions = <
|
|||||||
TError,
|
TError,
|
||||||
{
|
{
|
||||||
pathParams: CreateAccountPathParameters;
|
pathParams: CreateAccountPathParameters;
|
||||||
data: BodyType<CloudintegrationtypesConnectionArtifactRequestDTO>;
|
data: BodyType<CloudintegrationtypesPostableAccountDTO>;
|
||||||
},
|
},
|
||||||
TContext
|
TContext
|
||||||
>;
|
>;
|
||||||
@@ -285,7 +287,7 @@ export const getCreateAccountMutationOptions = <
|
|||||||
TError,
|
TError,
|
||||||
{
|
{
|
||||||
pathParams: CreateAccountPathParameters;
|
pathParams: CreateAccountPathParameters;
|
||||||
data: BodyType<CloudintegrationtypesConnectionArtifactRequestDTO>;
|
data: BodyType<CloudintegrationtypesPostableAccountDTO>;
|
||||||
},
|
},
|
||||||
TContext
|
TContext
|
||||||
> => {
|
> => {
|
||||||
@@ -302,7 +304,7 @@ export const getCreateAccountMutationOptions = <
|
|||||||
Awaited<ReturnType<typeof createAccount>>,
|
Awaited<ReturnType<typeof createAccount>>,
|
||||||
{
|
{
|
||||||
pathParams: CreateAccountPathParameters;
|
pathParams: CreateAccountPathParameters;
|
||||||
data: BodyType<CloudintegrationtypesConnectionArtifactRequestDTO>;
|
data: BodyType<CloudintegrationtypesPostableAccountDTO>;
|
||||||
}
|
}
|
||||||
> = (props) => {
|
> = (props) => {
|
||||||
const { pathParams, data } = props ?? {};
|
const { pathParams, data } = props ?? {};
|
||||||
@@ -316,7 +318,7 @@ export const getCreateAccountMutationOptions = <
|
|||||||
export type CreateAccountMutationResult = NonNullable<
|
export type CreateAccountMutationResult = NonNullable<
|
||||||
Awaited<ReturnType<typeof createAccount>>
|
Awaited<ReturnType<typeof createAccount>>
|
||||||
>;
|
>;
|
||||||
export type CreateAccountMutationBody = BodyType<CloudintegrationtypesConnectionArtifactRequestDTO>;
|
export type CreateAccountMutationBody = BodyType<CloudintegrationtypesPostableAccountDTO>;
|
||||||
export type CreateAccountMutationError = ErrorType<RenderErrorResponseDTO>;
|
export type CreateAccountMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -331,7 +333,7 @@ export const useCreateAccount = <
|
|||||||
TError,
|
TError,
|
||||||
{
|
{
|
||||||
pathParams: CreateAccountPathParameters;
|
pathParams: CreateAccountPathParameters;
|
||||||
data: BodyType<CloudintegrationtypesConnectionArtifactRequestDTO>;
|
data: BodyType<CloudintegrationtypesPostableAccountDTO>;
|
||||||
},
|
},
|
||||||
TContext
|
TContext
|
||||||
>;
|
>;
|
||||||
@@ -340,7 +342,7 @@ export const useCreateAccount = <
|
|||||||
TError,
|
TError,
|
||||||
{
|
{
|
||||||
pathParams: CreateAccountPathParameters;
|
pathParams: CreateAccountPathParameters;
|
||||||
data: BodyType<CloudintegrationtypesConnectionArtifactRequestDTO>;
|
data: BodyType<CloudintegrationtypesPostableAccountDTO>;
|
||||||
},
|
},
|
||||||
TContext
|
TContext
|
||||||
> => {
|
> => {
|
||||||
@@ -628,20 +630,117 @@ export const useUpdateAccount = <
|
|||||||
|
|
||||||
return useMutation(mutationOptions);
|
return useMutation(mutationOptions);
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* This endpoint updates a service for the specified cloud provider
|
||||||
|
* @summary Update service
|
||||||
|
*/
|
||||||
|
export const updateService = (
|
||||||
|
{ cloudProvider, id, serviceId }: UpdateServicePathParameters,
|
||||||
|
cloudintegrationtypesUpdatableServiceDTO: BodyType<CloudintegrationtypesUpdatableServiceDTO>,
|
||||||
|
) => {
|
||||||
|
return GeneratedAPIInstance<void>({
|
||||||
|
url: `/api/v1/cloud_integrations/${cloudProvider}/accounts/${id}/services/${serviceId}`,
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
data: cloudintegrationtypesUpdatableServiceDTO,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUpdateServiceMutationOptions = <
|
||||||
|
TError = ErrorType<RenderErrorResponseDTO>,
|
||||||
|
TContext = unknown
|
||||||
|
>(options?: {
|
||||||
|
mutation?: UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof updateService>>,
|
||||||
|
TError,
|
||||||
|
{
|
||||||
|
pathParams: UpdateServicePathParameters;
|
||||||
|
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
|
||||||
|
},
|
||||||
|
TContext
|
||||||
|
>;
|
||||||
|
}): UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof updateService>>,
|
||||||
|
TError,
|
||||||
|
{
|
||||||
|
pathParams: UpdateServicePathParameters;
|
||||||
|
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
|
||||||
|
},
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
const mutationKey = ['updateService'];
|
||||||
|
const { mutation: mutationOptions } = options
|
||||||
|
? options.mutation &&
|
||||||
|
'mutationKey' in options.mutation &&
|
||||||
|
options.mutation.mutationKey
|
||||||
|
? options
|
||||||
|
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||||
|
: { mutation: { mutationKey } };
|
||||||
|
|
||||||
|
const mutationFn: MutationFunction<
|
||||||
|
Awaited<ReturnType<typeof updateService>>,
|
||||||
|
{
|
||||||
|
pathParams: UpdateServicePathParameters;
|
||||||
|
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
|
||||||
|
}
|
||||||
|
> = (props) => {
|
||||||
|
const { pathParams, data } = props ?? {};
|
||||||
|
|
||||||
|
return updateService(pathParams, data);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateServiceMutationResult = NonNullable<
|
||||||
|
Awaited<ReturnType<typeof updateService>>
|
||||||
|
>;
|
||||||
|
export type UpdateServiceMutationBody = BodyType<CloudintegrationtypesUpdatableServiceDTO>;
|
||||||
|
export type UpdateServiceMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Update service
|
||||||
|
*/
|
||||||
|
export const useUpdateService = <
|
||||||
|
TError = ErrorType<RenderErrorResponseDTO>,
|
||||||
|
TContext = unknown
|
||||||
|
>(options?: {
|
||||||
|
mutation?: UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof updateService>>,
|
||||||
|
TError,
|
||||||
|
{
|
||||||
|
pathParams: UpdateServicePathParameters;
|
||||||
|
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
|
||||||
|
},
|
||||||
|
TContext
|
||||||
|
>;
|
||||||
|
}): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof updateService>>,
|
||||||
|
TError,
|
||||||
|
{
|
||||||
|
pathParams: UpdateServicePathParameters;
|
||||||
|
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
|
||||||
|
},
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
const mutationOptions = getUpdateServiceMutationOptions(options);
|
||||||
|
|
||||||
|
return useMutation(mutationOptions);
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* This endpoint is called by the deployed agent to check in
|
* This endpoint is called by the deployed agent to check in
|
||||||
* @summary Agent check-in
|
* @summary Agent check-in
|
||||||
*/
|
*/
|
||||||
export const agentCheckIn = (
|
export const agentCheckIn = (
|
||||||
{ cloudProvider }: AgentCheckInPathParameters,
|
{ cloudProvider }: AgentCheckInPathParameters,
|
||||||
cloudintegrationtypesPostableAgentCheckInRequestDTO: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>,
|
cloudintegrationtypesPostableAgentCheckInDTO: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
) => {
|
) => {
|
||||||
return GeneratedAPIInstance<AgentCheckIn200>({
|
return GeneratedAPIInstance<AgentCheckIn200>({
|
||||||
url: `/api/v1/cloud_integrations/${cloudProvider}/accounts/check_in`,
|
url: `/api/v1/cloud_integrations/${cloudProvider}/accounts/check_in`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
data: cloudintegrationtypesPostableAgentCheckInRequestDTO,
|
data: cloudintegrationtypesPostableAgentCheckInDTO,
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -655,7 +754,7 @@ export const getAgentCheckInMutationOptions = <
|
|||||||
TError,
|
TError,
|
||||||
{
|
{
|
||||||
pathParams: AgentCheckInPathParameters;
|
pathParams: AgentCheckInPathParameters;
|
||||||
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
|
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
|
||||||
},
|
},
|
||||||
TContext
|
TContext
|
||||||
>;
|
>;
|
||||||
@@ -664,7 +763,7 @@ export const getAgentCheckInMutationOptions = <
|
|||||||
TError,
|
TError,
|
||||||
{
|
{
|
||||||
pathParams: AgentCheckInPathParameters;
|
pathParams: AgentCheckInPathParameters;
|
||||||
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
|
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
|
||||||
},
|
},
|
||||||
TContext
|
TContext
|
||||||
> => {
|
> => {
|
||||||
@@ -681,7 +780,7 @@ export const getAgentCheckInMutationOptions = <
|
|||||||
Awaited<ReturnType<typeof agentCheckIn>>,
|
Awaited<ReturnType<typeof agentCheckIn>>,
|
||||||
{
|
{
|
||||||
pathParams: AgentCheckInPathParameters;
|
pathParams: AgentCheckInPathParameters;
|
||||||
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
|
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
|
||||||
}
|
}
|
||||||
> = (props) => {
|
> = (props) => {
|
||||||
const { pathParams, data } = props ?? {};
|
const { pathParams, data } = props ?? {};
|
||||||
@@ -695,7 +794,7 @@ export const getAgentCheckInMutationOptions = <
|
|||||||
export type AgentCheckInMutationResult = NonNullable<
|
export type AgentCheckInMutationResult = NonNullable<
|
||||||
Awaited<ReturnType<typeof agentCheckIn>>
|
Awaited<ReturnType<typeof agentCheckIn>>
|
||||||
>;
|
>;
|
||||||
export type AgentCheckInMutationBody = BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
|
export type AgentCheckInMutationBody = BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
|
||||||
export type AgentCheckInMutationError = ErrorType<RenderErrorResponseDTO>;
|
export type AgentCheckInMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -710,7 +809,7 @@ export const useAgentCheckIn = <
|
|||||||
TError,
|
TError,
|
||||||
{
|
{
|
||||||
pathParams: AgentCheckInPathParameters;
|
pathParams: AgentCheckInPathParameters;
|
||||||
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
|
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
|
||||||
},
|
},
|
||||||
TContext
|
TContext
|
||||||
>;
|
>;
|
||||||
@@ -719,7 +818,7 @@ export const useAgentCheckIn = <
|
|||||||
TError,
|
TError,
|
||||||
{
|
{
|
||||||
pathParams: AgentCheckInPathParameters;
|
pathParams: AgentCheckInPathParameters;
|
||||||
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
|
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
|
||||||
},
|
},
|
||||||
TContext
|
TContext
|
||||||
> => {
|
> => {
|
||||||
@@ -727,6 +826,114 @@ export const useAgentCheckIn = <
|
|||||||
|
|
||||||
return useMutation(mutationOptions);
|
return useMutation(mutationOptions);
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* This endpoint retrieves the connection credentials required for integration
|
||||||
|
* @summary Get connection credentials
|
||||||
|
*/
|
||||||
|
export const getConnectionCredentials = (
|
||||||
|
{ cloudProvider }: GetConnectionCredentialsPathParameters,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) => {
|
||||||
|
return GeneratedAPIInstance<GetConnectionCredentials200>({
|
||||||
|
url: `/api/v1/cloud_integrations/${cloudProvider}/credentials`,
|
||||||
|
method: 'GET',
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getGetConnectionCredentialsQueryKey = ({
|
||||||
|
cloudProvider,
|
||||||
|
}: GetConnectionCredentialsPathParameters) => {
|
||||||
|
return [`/api/v1/cloud_integrations/${cloudProvider}/credentials`] as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getGetConnectionCredentialsQueryOptions = <
|
||||||
|
TData = Awaited<ReturnType<typeof getConnectionCredentials>>,
|
||||||
|
TError = ErrorType<RenderErrorResponseDTO>
|
||||||
|
>(
|
||||||
|
{ cloudProvider }: GetConnectionCredentialsPathParameters,
|
||||||
|
options?: {
|
||||||
|
query?: UseQueryOptions<
|
||||||
|
Awaited<ReturnType<typeof getConnectionCredentials>>,
|
||||||
|
TError,
|
||||||
|
TData
|
||||||
|
>;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const { query: queryOptions } = options ?? {};
|
||||||
|
|
||||||
|
const queryKey =
|
||||||
|
queryOptions?.queryKey ??
|
||||||
|
getGetConnectionCredentialsQueryKey({ cloudProvider });
|
||||||
|
|
||||||
|
const queryFn: QueryFunction<
|
||||||
|
Awaited<ReturnType<typeof getConnectionCredentials>>
|
||||||
|
> = ({ signal }) => getConnectionCredentials({ cloudProvider }, signal);
|
||||||
|
|
||||||
|
return {
|
||||||
|
queryKey,
|
||||||
|
queryFn,
|
||||||
|
enabled: !!cloudProvider,
|
||||||
|
...queryOptions,
|
||||||
|
} as UseQueryOptions<
|
||||||
|
Awaited<ReturnType<typeof getConnectionCredentials>>,
|
||||||
|
TError,
|
||||||
|
TData
|
||||||
|
> & { queryKey: QueryKey };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetConnectionCredentialsQueryResult = NonNullable<
|
||||||
|
Awaited<ReturnType<typeof getConnectionCredentials>>
|
||||||
|
>;
|
||||||
|
export type GetConnectionCredentialsQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Get connection credentials
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useGetConnectionCredentials<
|
||||||
|
TData = Awaited<ReturnType<typeof getConnectionCredentials>>,
|
||||||
|
TError = ErrorType<RenderErrorResponseDTO>
|
||||||
|
>(
|
||||||
|
{ cloudProvider }: GetConnectionCredentialsPathParameters,
|
||||||
|
options?: {
|
||||||
|
query?: UseQueryOptions<
|
||||||
|
Awaited<ReturnType<typeof getConnectionCredentials>>,
|
||||||
|
TError,
|
||||||
|
TData
|
||||||
|
>;
|
||||||
|
},
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||||
|
const queryOptions = getGetConnectionCredentialsQueryOptions(
|
||||||
|
{ cloudProvider },
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
|
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||||
|
queryKey: QueryKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
query.queryKey = queryOptions.queryKey;
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Get connection credentials
|
||||||
|
*/
|
||||||
|
export const invalidateGetConnectionCredentials = async (
|
||||||
|
queryClient: QueryClient,
|
||||||
|
{ cloudProvider }: GetConnectionCredentialsPathParameters,
|
||||||
|
options?: InvalidateOptions,
|
||||||
|
): Promise<QueryClient> => {
|
||||||
|
await queryClient.invalidateQueries(
|
||||||
|
{ queryKey: getGetConnectionCredentialsQueryKey({ cloudProvider }) },
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
|
return queryClient;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This endpoint lists the services metadata for the specified cloud provider
|
* This endpoint lists the services metadata for the specified cloud provider
|
||||||
* @summary List services metadata
|
* @summary List services metadata
|
||||||
@@ -941,101 +1148,3 @@ export const invalidateGetService = async (
|
|||||||
|
|
||||||
return queryClient;
|
return queryClient;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* This endpoint updates a service for the specified cloud provider
|
|
||||||
* @summary Update service
|
|
||||||
*/
|
|
||||||
export const updateService = (
|
|
||||||
{ cloudProvider, serviceId }: UpdateServicePathParameters,
|
|
||||||
cloudintegrationtypesUpdatableServiceDTO: BodyType<CloudintegrationtypesUpdatableServiceDTO>,
|
|
||||||
) => {
|
|
||||||
return GeneratedAPIInstance<void>({
|
|
||||||
url: `/api/v1/cloud_integrations/${cloudProvider}/services/${serviceId}`,
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
data: cloudintegrationtypesUpdatableServiceDTO,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getUpdateServiceMutationOptions = <
|
|
||||||
TError = ErrorType<RenderErrorResponseDTO>,
|
|
||||||
TContext = unknown
|
|
||||||
>(options?: {
|
|
||||||
mutation?: UseMutationOptions<
|
|
||||||
Awaited<ReturnType<typeof updateService>>,
|
|
||||||
TError,
|
|
||||||
{
|
|
||||||
pathParams: UpdateServicePathParameters;
|
|
||||||
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
|
|
||||||
},
|
|
||||||
TContext
|
|
||||||
>;
|
|
||||||
}): UseMutationOptions<
|
|
||||||
Awaited<ReturnType<typeof updateService>>,
|
|
||||||
TError,
|
|
||||||
{
|
|
||||||
pathParams: UpdateServicePathParameters;
|
|
||||||
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
|
|
||||||
},
|
|
||||||
TContext
|
|
||||||
> => {
|
|
||||||
const mutationKey = ['updateService'];
|
|
||||||
const { mutation: mutationOptions } = options
|
|
||||||
? options.mutation &&
|
|
||||||
'mutationKey' in options.mutation &&
|
|
||||||
options.mutation.mutationKey
|
|
||||||
? options
|
|
||||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
|
||||||
: { mutation: { mutationKey } };
|
|
||||||
|
|
||||||
const mutationFn: MutationFunction<
|
|
||||||
Awaited<ReturnType<typeof updateService>>,
|
|
||||||
{
|
|
||||||
pathParams: UpdateServicePathParameters;
|
|
||||||
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
|
|
||||||
}
|
|
||||||
> = (props) => {
|
|
||||||
const { pathParams, data } = props ?? {};
|
|
||||||
|
|
||||||
return updateService(pathParams, data);
|
|
||||||
};
|
|
||||||
|
|
||||||
return { mutationFn, ...mutationOptions };
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UpdateServiceMutationResult = NonNullable<
|
|
||||||
Awaited<ReturnType<typeof updateService>>
|
|
||||||
>;
|
|
||||||
export type UpdateServiceMutationBody = BodyType<CloudintegrationtypesUpdatableServiceDTO>;
|
|
||||||
export type UpdateServiceMutationError = ErrorType<RenderErrorResponseDTO>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @summary Update service
|
|
||||||
*/
|
|
||||||
export const useUpdateService = <
|
|
||||||
TError = ErrorType<RenderErrorResponseDTO>,
|
|
||||||
TContext = unknown
|
|
||||||
>(options?: {
|
|
||||||
mutation?: UseMutationOptions<
|
|
||||||
Awaited<ReturnType<typeof updateService>>,
|
|
||||||
TError,
|
|
||||||
{
|
|
||||||
pathParams: UpdateServicePathParameters;
|
|
||||||
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
|
|
||||||
},
|
|
||||||
TContext
|
|
||||||
>;
|
|
||||||
}): UseMutationResult<
|
|
||||||
Awaited<ReturnType<typeof updateService>>,
|
|
||||||
TError,
|
|
||||||
{
|
|
||||||
pathParams: UpdateServicePathParameters;
|
|
||||||
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
|
|
||||||
},
|
|
||||||
TContext
|
|
||||||
> => {
|
|
||||||
const mutationOptions = getUpdateServiceMutationOptions(options);
|
|
||||||
|
|
||||||
return useMutation(mutationOptions);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -512,27 +512,58 @@ export interface CloudintegrationtypesAWSAccountConfigDTO {
|
|||||||
regions: string[];
|
regions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CloudintegrationtypesAWSCollectionStrategyDTOS3Buckets = {
|
export interface CloudintegrationtypesAWSCloudWatchLogsSubscriptionDTO {
|
||||||
[key: string]: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface CloudintegrationtypesAWSCollectionStrategyDTO {
|
|
||||||
aws_logs?: CloudintegrationtypesAWSLogsStrategyDTO;
|
|
||||||
aws_metrics?: CloudintegrationtypesAWSMetricsStrategyDTO;
|
|
||||||
/**
|
/**
|
||||||
* @type object
|
* @type string
|
||||||
*/
|
*/
|
||||||
s3_buckets?: CloudintegrationtypesAWSCollectionStrategyDTOS3Buckets;
|
filterPattern: string;
|
||||||
|
/**
|
||||||
|
* @type string
|
||||||
|
*/
|
||||||
|
logGroupNamePrefix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudintegrationtypesAWSCloudWatchMetricStreamFilterDTO {
|
||||||
|
/**
|
||||||
|
* @type array
|
||||||
|
*/
|
||||||
|
metricNames?: string[];
|
||||||
|
/**
|
||||||
|
* @type string
|
||||||
|
*/
|
||||||
|
namespace: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CloudintegrationtypesAWSConnectionArtifactDTO {
|
export interface CloudintegrationtypesAWSConnectionArtifactDTO {
|
||||||
/**
|
/**
|
||||||
* @type string
|
* @type string
|
||||||
*/
|
*/
|
||||||
connectionURL: string;
|
connectionUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CloudintegrationtypesAWSConnectionArtifactRequestDTO {
|
export interface CloudintegrationtypesAWSIntegrationConfigDTO {
|
||||||
|
/**
|
||||||
|
* @type array
|
||||||
|
*/
|
||||||
|
enabledRegions: string[];
|
||||||
|
telemetryCollectionStrategy: CloudintegrationtypesAWSTelemetryCollectionStrategyDTO;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudintegrationtypesAWSLogsCollectionStrategyDTO {
|
||||||
|
/**
|
||||||
|
* @type array
|
||||||
|
*/
|
||||||
|
subscriptions: CloudintegrationtypesAWSCloudWatchLogsSubscriptionDTO[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudintegrationtypesAWSMetricsCollectionStrategyDTO {
|
||||||
|
/**
|
||||||
|
* @type array
|
||||||
|
*/
|
||||||
|
streamFilters: CloudintegrationtypesAWSCloudWatchMetricStreamFilterDTO[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudintegrationtypesAWSPostableAccountConfigDTO {
|
||||||
/**
|
/**
|
||||||
* @type string
|
* @type string
|
||||||
*/
|
*/
|
||||||
@@ -543,56 +574,6 @@ export interface CloudintegrationtypesAWSConnectionArtifactRequestDTO {
|
|||||||
regions: string[];
|
regions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CloudintegrationtypesAWSIntegrationConfigDTO {
|
|
||||||
/**
|
|
||||||
* @type array
|
|
||||||
*/
|
|
||||||
enabledRegions: string[];
|
|
||||||
telemetry: CloudintegrationtypesAWSCollectionStrategyDTO;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CloudintegrationtypesAWSLogsStrategyDTOCloudwatchLogsSubscriptionsItem = {
|
|
||||||
/**
|
|
||||||
* @type string
|
|
||||||
*/
|
|
||||||
filter_pattern?: string;
|
|
||||||
/**
|
|
||||||
* @type string
|
|
||||||
*/
|
|
||||||
log_group_name_prefix?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface CloudintegrationtypesAWSLogsStrategyDTO {
|
|
||||||
/**
|
|
||||||
* @type array
|
|
||||||
* @nullable true
|
|
||||||
*/
|
|
||||||
cloudwatch_logs_subscriptions?:
|
|
||||||
| CloudintegrationtypesAWSLogsStrategyDTOCloudwatchLogsSubscriptionsItem[]
|
|
||||||
| null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CloudintegrationtypesAWSMetricsStrategyDTOCloudwatchMetricStreamFiltersItem = {
|
|
||||||
/**
|
|
||||||
* @type array
|
|
||||||
*/
|
|
||||||
MetricNames?: string[];
|
|
||||||
/**
|
|
||||||
* @type string
|
|
||||||
*/
|
|
||||||
Namespace?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface CloudintegrationtypesAWSMetricsStrategyDTO {
|
|
||||||
/**
|
|
||||||
* @type array
|
|
||||||
* @nullable true
|
|
||||||
*/
|
|
||||||
cloudwatch_metric_stream_filters?:
|
|
||||||
| CloudintegrationtypesAWSMetricsStrategyDTOCloudwatchMetricStreamFiltersItem[]
|
|
||||||
| null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CloudintegrationtypesAWSServiceConfigDTO {
|
export interface CloudintegrationtypesAWSServiceConfigDTO {
|
||||||
logs?: CloudintegrationtypesAWSServiceLogsConfigDTO;
|
logs?: CloudintegrationtypesAWSServiceLogsConfigDTO;
|
||||||
metrics?: CloudintegrationtypesAWSServiceMetricsConfigDTO;
|
metrics?: CloudintegrationtypesAWSServiceMetricsConfigDTO;
|
||||||
@@ -610,7 +591,7 @@ export interface CloudintegrationtypesAWSServiceLogsConfigDTO {
|
|||||||
/**
|
/**
|
||||||
* @type object
|
* @type object
|
||||||
*/
|
*/
|
||||||
s3_buckets?: CloudintegrationtypesAWSServiceLogsConfigDTOS3Buckets;
|
s3Buckets?: CloudintegrationtypesAWSServiceLogsConfigDTOS3Buckets;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CloudintegrationtypesAWSServiceMetricsConfigDTO {
|
export interface CloudintegrationtypesAWSServiceMetricsConfigDTO {
|
||||||
@@ -620,6 +601,19 @@ export interface CloudintegrationtypesAWSServiceMetricsConfigDTO {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CloudintegrationtypesAWSTelemetryCollectionStrategyDTOS3Buckets = {
|
||||||
|
[key: string]: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CloudintegrationtypesAWSTelemetryCollectionStrategyDTO {
|
||||||
|
logs?: CloudintegrationtypesAWSLogsCollectionStrategyDTO;
|
||||||
|
metrics?: CloudintegrationtypesAWSMetricsCollectionStrategyDTO;
|
||||||
|
/**
|
||||||
|
* @type object
|
||||||
|
*/
|
||||||
|
s3Buckets?: CloudintegrationtypesAWSTelemetryCollectionStrategyDTOS3Buckets;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CloudintegrationtypesAccountDTO {
|
export interface CloudintegrationtypesAccountDTO {
|
||||||
agentReport: CloudintegrationtypesAgentReportDTO;
|
agentReport: CloudintegrationtypesAgentReportDTO;
|
||||||
config: CloudintegrationtypesAccountConfigDTO;
|
config: CloudintegrationtypesAccountConfigDTO;
|
||||||
@@ -693,6 +687,32 @@ export interface CloudintegrationtypesAssetsDTO {
|
|||||||
dashboards?: CloudintegrationtypesDashboardDTO[] | null;
|
dashboards?: CloudintegrationtypesDashboardDTO[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @nullable
|
||||||
|
*/
|
||||||
|
export type CloudintegrationtypesCloudIntegrationServiceDTO = {
|
||||||
|
/**
|
||||||
|
* @type string
|
||||||
|
*/
|
||||||
|
cloudIntegrationId?: string;
|
||||||
|
config?: CloudintegrationtypesServiceConfigDTO;
|
||||||
|
/**
|
||||||
|
* @type string
|
||||||
|
* @format date-time
|
||||||
|
*/
|
||||||
|
createdAt?: Date;
|
||||||
|
/**
|
||||||
|
* @type string
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
type?: CloudintegrationtypesServiceIDDTO;
|
||||||
|
/**
|
||||||
|
* @type string
|
||||||
|
* @format date-time
|
||||||
|
*/
|
||||||
|
updatedAt?: Date;
|
||||||
|
} | null;
|
||||||
|
|
||||||
export interface CloudintegrationtypesCollectedLogAttributeDTO {
|
export interface CloudintegrationtypesCollectedLogAttributeDTO {
|
||||||
/**
|
/**
|
||||||
* @type string
|
* @type string
|
||||||
@@ -727,16 +747,27 @@ export interface CloudintegrationtypesCollectedMetricDTO {
|
|||||||
unit?: string;
|
unit?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CloudintegrationtypesCollectionStrategyDTO {
|
|
||||||
aws: CloudintegrationtypesAWSCollectionStrategyDTO;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CloudintegrationtypesConnectionArtifactDTO {
|
export interface CloudintegrationtypesConnectionArtifactDTO {
|
||||||
aws: CloudintegrationtypesAWSConnectionArtifactDTO;
|
aws: CloudintegrationtypesAWSConnectionArtifactDTO;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CloudintegrationtypesConnectionArtifactRequestDTO {
|
export interface CloudintegrationtypesCredentialsDTO {
|
||||||
aws: CloudintegrationtypesAWSConnectionArtifactRequestDTO;
|
/**
|
||||||
|
* @type string
|
||||||
|
*/
|
||||||
|
ingestionKey: string;
|
||||||
|
/**
|
||||||
|
* @type string
|
||||||
|
*/
|
||||||
|
ingestionUrl: string;
|
||||||
|
/**
|
||||||
|
* @type string
|
||||||
|
*/
|
||||||
|
sigNozApiKey: string;
|
||||||
|
/**
|
||||||
|
* @type string
|
||||||
|
*/
|
||||||
|
sigNozApiUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CloudintegrationtypesDashboardDTO {
|
export interface CloudintegrationtypesDashboardDTO {
|
||||||
@@ -768,7 +799,7 @@ export interface CloudintegrationtypesDataCollectedDTO {
|
|||||||
metrics?: CloudintegrationtypesCollectedMetricDTO[] | null;
|
metrics?: CloudintegrationtypesCollectedMetricDTO[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CloudintegrationtypesGettableAccountWithArtifactDTO {
|
export interface CloudintegrationtypesGettableAccountWithConnectionArtifactDTO {
|
||||||
connectionArtifact: CloudintegrationtypesConnectionArtifactDTO;
|
connectionArtifact: CloudintegrationtypesConnectionArtifactDTO;
|
||||||
/**
|
/**
|
||||||
* @type string
|
* @type string
|
||||||
@@ -783,7 +814,7 @@ export interface CloudintegrationtypesGettableAccountsDTO {
|
|||||||
accounts: CloudintegrationtypesAccountDTO[];
|
accounts: CloudintegrationtypesAccountDTO[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CloudintegrationtypesGettableAgentCheckInResponseDTO {
|
export interface CloudintegrationtypesGettableAgentCheckInDTO {
|
||||||
/**
|
/**
|
||||||
* @type string
|
* @type string
|
||||||
*/
|
*/
|
||||||
@@ -831,17 +862,85 @@ export type CloudintegrationtypesIntegrationConfigDTO = {
|
|||||||
* @type array
|
* @type array
|
||||||
*/
|
*/
|
||||||
enabled_regions: string[];
|
enabled_regions: string[];
|
||||||
telemetry: CloudintegrationtypesAWSCollectionStrategyDTO;
|
telemetry: CloudintegrationtypesOldAWSCollectionStrategyDTO;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
|
export type CloudintegrationtypesOldAWSCollectionStrategyDTOS3Buckets = {
|
||||||
|
[key: string]: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CloudintegrationtypesOldAWSCollectionStrategyDTO {
|
||||||
|
aws_logs?: CloudintegrationtypesOldAWSLogsStrategyDTO;
|
||||||
|
aws_metrics?: CloudintegrationtypesOldAWSMetricsStrategyDTO;
|
||||||
|
/**
|
||||||
|
* @type string
|
||||||
|
*/
|
||||||
|
provider?: string;
|
||||||
|
/**
|
||||||
|
* @type object
|
||||||
|
*/
|
||||||
|
s3_buckets?: CloudintegrationtypesOldAWSCollectionStrategyDTOS3Buckets;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CloudintegrationtypesOldAWSLogsStrategyDTOCloudwatchLogsSubscriptionsItem = {
|
||||||
|
/**
|
||||||
|
* @type string
|
||||||
|
*/
|
||||||
|
filter_pattern?: string;
|
||||||
|
/**
|
||||||
|
* @type string
|
||||||
|
*/
|
||||||
|
log_group_name_prefix?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CloudintegrationtypesOldAWSLogsStrategyDTO {
|
||||||
|
/**
|
||||||
|
* @type array
|
||||||
|
* @nullable true
|
||||||
|
*/
|
||||||
|
cloudwatch_logs_subscriptions?:
|
||||||
|
| CloudintegrationtypesOldAWSLogsStrategyDTOCloudwatchLogsSubscriptionsItem[]
|
||||||
|
| null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CloudintegrationtypesOldAWSMetricsStrategyDTOCloudwatchMetricStreamFiltersItem = {
|
||||||
|
/**
|
||||||
|
* @type array
|
||||||
|
*/
|
||||||
|
MetricNames?: string[];
|
||||||
|
/**
|
||||||
|
* @type string
|
||||||
|
*/
|
||||||
|
Namespace?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CloudintegrationtypesOldAWSMetricsStrategyDTO {
|
||||||
|
/**
|
||||||
|
* @type array
|
||||||
|
* @nullable true
|
||||||
|
*/
|
||||||
|
cloudwatch_metric_stream_filters?:
|
||||||
|
| CloudintegrationtypesOldAWSMetricsStrategyDTOCloudwatchMetricStreamFiltersItem[]
|
||||||
|
| null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudintegrationtypesPostableAccountDTO {
|
||||||
|
config: CloudintegrationtypesPostableAccountConfigDTO;
|
||||||
|
credentials: CloudintegrationtypesCredentialsDTO;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudintegrationtypesPostableAccountConfigDTO {
|
||||||
|
aws: CloudintegrationtypesAWSPostableAccountConfigDTO;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @nullable
|
* @nullable
|
||||||
*/
|
*/
|
||||||
export type CloudintegrationtypesPostableAgentCheckInRequestDTOData = {
|
export type CloudintegrationtypesPostableAgentCheckInDTOData = {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
export interface CloudintegrationtypesPostableAgentCheckInRequestDTO {
|
export interface CloudintegrationtypesPostableAgentCheckInDTO {
|
||||||
/**
|
/**
|
||||||
* @type string
|
* @type string
|
||||||
*/
|
*/
|
||||||
@@ -858,7 +957,7 @@ export interface CloudintegrationtypesPostableAgentCheckInRequestDTO {
|
|||||||
* @type object
|
* @type object
|
||||||
* @nullable true
|
* @nullable true
|
||||||
*/
|
*/
|
||||||
data: CloudintegrationtypesPostableAgentCheckInRequestDTOData;
|
data: CloudintegrationtypesPostableAgentCheckInDTOData;
|
||||||
/**
|
/**
|
||||||
* @type string
|
* @type string
|
||||||
*/
|
*/
|
||||||
@@ -871,6 +970,7 @@ export interface CloudintegrationtypesProviderIntegrationConfigDTO {
|
|||||||
|
|
||||||
export interface CloudintegrationtypesServiceDTO {
|
export interface CloudintegrationtypesServiceDTO {
|
||||||
assets: CloudintegrationtypesAssetsDTO;
|
assets: CloudintegrationtypesAssetsDTO;
|
||||||
|
cloudIntegrationService: CloudintegrationtypesCloudIntegrationServiceDTO;
|
||||||
dataCollected: CloudintegrationtypesDataCollectedDTO;
|
dataCollected: CloudintegrationtypesDataCollectedDTO;
|
||||||
/**
|
/**
|
||||||
* @type string
|
* @type string
|
||||||
@@ -884,9 +984,8 @@ export interface CloudintegrationtypesServiceDTO {
|
|||||||
* @type string
|
* @type string
|
||||||
*/
|
*/
|
||||||
overview: string;
|
overview: string;
|
||||||
serviceConfig?: CloudintegrationtypesServiceConfigDTO;
|
supportedSignals: CloudintegrationtypesSupportedSignalsDTO;
|
||||||
supported_signals: CloudintegrationtypesSupportedSignalsDTO;
|
telemetryCollectionStrategy: CloudintegrationtypesTelemetryCollectionStrategyDTO;
|
||||||
telemetryCollectionStrategy: CloudintegrationtypesCollectionStrategyDTO;
|
|
||||||
/**
|
/**
|
||||||
* @type string
|
* @type string
|
||||||
*/
|
*/
|
||||||
@@ -897,6 +996,21 @@ export interface CloudintegrationtypesServiceConfigDTO {
|
|||||||
aws: CloudintegrationtypesAWSServiceConfigDTO;
|
aws: CloudintegrationtypesAWSServiceConfigDTO;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum CloudintegrationtypesServiceIDDTO {
|
||||||
|
alb = 'alb',
|
||||||
|
'api-gateway' = 'api-gateway',
|
||||||
|
dynamodb = 'dynamodb',
|
||||||
|
ec2 = 'ec2',
|
||||||
|
ecs = 'ecs',
|
||||||
|
eks = 'eks',
|
||||||
|
elasticache = 'elasticache',
|
||||||
|
lambda = 'lambda',
|
||||||
|
msk = 'msk',
|
||||||
|
rds = 'rds',
|
||||||
|
s3sync = 's3sync',
|
||||||
|
sns = 'sns',
|
||||||
|
sqs = 'sqs',
|
||||||
|
}
|
||||||
export interface CloudintegrationtypesServiceMetadataDTO {
|
export interface CloudintegrationtypesServiceMetadataDTO {
|
||||||
/**
|
/**
|
||||||
* @type boolean
|
* @type boolean
|
||||||
@@ -927,6 +1041,10 @@ export interface CloudintegrationtypesSupportedSignalsDTO {
|
|||||||
metrics?: boolean;
|
metrics?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CloudintegrationtypesTelemetryCollectionStrategyDTO {
|
||||||
|
aws: CloudintegrationtypesAWSTelemetryCollectionStrategyDTO;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CloudintegrationtypesUpdatableAccountDTO {
|
export interface CloudintegrationtypesUpdatableAccountDTO {
|
||||||
config: CloudintegrationtypesAccountConfigDTO;
|
config: CloudintegrationtypesAccountConfigDTO;
|
||||||
}
|
}
|
||||||
@@ -3450,7 +3568,7 @@ export type AgentCheckInDeprecatedPathParameters = {
|
|||||||
cloudProvider: string;
|
cloudProvider: string;
|
||||||
};
|
};
|
||||||
export type AgentCheckInDeprecated200 = {
|
export type AgentCheckInDeprecated200 = {
|
||||||
data: CloudintegrationtypesGettableAgentCheckInResponseDTO;
|
data: CloudintegrationtypesGettableAgentCheckInDTO;
|
||||||
/**
|
/**
|
||||||
* @type string
|
* @type string
|
||||||
*/
|
*/
|
||||||
@@ -3472,7 +3590,7 @@ export type CreateAccountPathParameters = {
|
|||||||
cloudProvider: string;
|
cloudProvider: string;
|
||||||
};
|
};
|
||||||
export type CreateAccount200 = {
|
export type CreateAccount200 = {
|
||||||
data: CloudintegrationtypesGettableAccountWithArtifactDTO;
|
data: CloudintegrationtypesGettableAccountWithConnectionArtifactDTO;
|
||||||
/**
|
/**
|
||||||
* @type string
|
* @type string
|
||||||
*/
|
*/
|
||||||
@@ -3499,11 +3617,27 @@ export type UpdateAccountPathParameters = {
|
|||||||
cloudProvider: string;
|
cloudProvider: string;
|
||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
|
export type UpdateServicePathParameters = {
|
||||||
|
cloudProvider: string;
|
||||||
|
id: string;
|
||||||
|
serviceId: string;
|
||||||
|
};
|
||||||
export type AgentCheckInPathParameters = {
|
export type AgentCheckInPathParameters = {
|
||||||
cloudProvider: string;
|
cloudProvider: string;
|
||||||
};
|
};
|
||||||
export type AgentCheckIn200 = {
|
export type AgentCheckIn200 = {
|
||||||
data: CloudintegrationtypesGettableAgentCheckInResponseDTO;
|
data: CloudintegrationtypesGettableAgentCheckInDTO;
|
||||||
|
/**
|
||||||
|
* @type string
|
||||||
|
*/
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetConnectionCredentialsPathParameters = {
|
||||||
|
cloudProvider: string;
|
||||||
|
};
|
||||||
|
export type GetConnectionCredentials200 = {
|
||||||
|
data: CloudintegrationtypesCredentialsDTO;
|
||||||
/**
|
/**
|
||||||
* @type string
|
* @type string
|
||||||
*/
|
*/
|
||||||
@@ -3533,10 +3667,6 @@ export type GetService200 = {
|
|||||||
status: string;
|
status: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UpdateServicePathParameters = {
|
|
||||||
cloudProvider: string;
|
|
||||||
serviceId: string;
|
|
||||||
};
|
|
||||||
export type CreateSessionByGoogleCallback303 = {
|
export type CreateSessionByGoogleCallback303 = {
|
||||||
data: AuthtypesGettableTokenDTO;
|
data: AuthtypesGettableTokenDTO;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -28,6 +28,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In table/column view, keep action buttons visible at the viewport's right edge
|
||||||
|
.log-line-action-buttons.table-view-log-actions {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 8px;
|
||||||
|
left: auto;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
margin: 0;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
.lightMode {
|
.lightMode {
|
||||||
.log-line-action-buttons {
|
.log-line-action-buttons {
|
||||||
border: 1px solid var(--bg-vanilla-400);
|
border: 1px solid var(--bg-vanilla-400);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
.log-state-indicator {
|
.log-state-indicator {
|
||||||
|
padding-left: 8px;
|
||||||
|
|
||||||
.line {
|
.line {
|
||||||
margin: 0 8px;
|
margin: 0 8px;
|
||||||
min-height: 24px;
|
min-height: 24px;
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
import { Color } from '@signozhq/design-tokens';
|
|
||||||
|
|
||||||
import { LogType } from './LogStateIndicator';
|
|
||||||
|
|
||||||
export function getRowBackgroundColor(
|
|
||||||
isDarkMode: boolean,
|
|
||||||
logType?: string,
|
|
||||||
): string {
|
|
||||||
if (isDarkMode) {
|
|
||||||
switch (logType) {
|
|
||||||
case LogType.INFO:
|
|
||||||
return `${Color.BG_ROBIN_500}40`;
|
|
||||||
case LogType.WARN:
|
|
||||||
return `${Color.BG_AMBER_500}40`;
|
|
||||||
case LogType.ERROR:
|
|
||||||
return `${Color.BG_CHERRY_500}40`;
|
|
||||||
case LogType.TRACE:
|
|
||||||
return `${Color.BG_FOREST_400}40`;
|
|
||||||
case LogType.DEBUG:
|
|
||||||
return `${Color.BG_AQUA_500}40`;
|
|
||||||
case LogType.FATAL:
|
|
||||||
return `${Color.BG_SAKURA_500}40`;
|
|
||||||
default:
|
|
||||||
return `${Color.BG_ROBIN_500}40`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch (logType) {
|
|
||||||
case LogType.INFO:
|
|
||||||
return Color.BG_ROBIN_100;
|
|
||||||
case LogType.WARN:
|
|
||||||
return Color.BG_AMBER_100;
|
|
||||||
case LogType.ERROR:
|
|
||||||
return Color.BG_CHERRY_100;
|
|
||||||
case LogType.TRACE:
|
|
||||||
return Color.BG_FOREST_200;
|
|
||||||
case LogType.DEBUG:
|
|
||||||
return Color.BG_AQUA_100;
|
|
||||||
case LogType.FATAL:
|
|
||||||
return Color.BG_SAKURA_100;
|
|
||||||
default:
|
|
||||||
return Color.BG_VANILLA_300;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import type { ReactElement } from 'react';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import TanStackTable from 'components/TanStackTableView';
|
|
||||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
|
||||||
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
|
|
||||||
import { FontSize } from 'container/OptionsMenu/types';
|
|
||||||
import { FlatLogData } from 'lib/logs/flatLogData';
|
|
||||||
import { useTimezone } from 'providers/Timezone';
|
|
||||||
import { IField } from 'types/api/logs/fields';
|
|
||||||
import { ILog } from 'types/api/logs/log';
|
|
||||||
|
|
||||||
import type { TableColumnDef } from '../../TanStackTableView/types';
|
|
||||||
import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
|
|
||||||
|
|
||||||
type UseLogsTableColumnsProps = {
|
|
||||||
fields: IField[];
|
|
||||||
fontSize: FontSize;
|
|
||||||
appendTo?: 'center' | 'end';
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useLogsTableColumns({
|
|
||||||
fields,
|
|
||||||
fontSize,
|
|
||||||
appendTo = 'center',
|
|
||||||
}: UseLogsTableColumnsProps): TableColumnDef<ILog>[] {
|
|
||||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
|
||||||
|
|
||||||
return useMemo<TableColumnDef<ILog>[]>(() => {
|
|
||||||
const stateIndicatorCol: TableColumnDef<ILog> = {
|
|
||||||
id: 'state-indicator',
|
|
||||||
header: '',
|
|
||||||
pin: 'left',
|
|
||||||
enableMove: false,
|
|
||||||
enableResize: false,
|
|
||||||
enableRemove: false,
|
|
||||||
canBeHidden: false,
|
|
||||||
width: { fixed: 24 },
|
|
||||||
cell: ({ row }): ReactElement => (
|
|
||||||
<LogStateIndicator
|
|
||||||
fontSize={fontSize}
|
|
||||||
severityText={row.severity_text as string}
|
|
||||||
severityNumber={row.severity_number as number}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
const fieldColumns: TableColumnDef<ILog>[] = fields
|
|
||||||
.filter((f): boolean => !['id', 'body', 'timestamp'].includes(f.name))
|
|
||||||
.map(
|
|
||||||
(f): TableColumnDef<ILog> => ({
|
|
||||||
id: f.name,
|
|
||||||
header: f.name,
|
|
||||||
accessorFn: (log): unknown => FlatLogData(log)[f.name],
|
|
||||||
enableRemove: true,
|
|
||||||
width: { min: 192 },
|
|
||||||
cell: ({ value }): ReactElement => (
|
|
||||||
<TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const timestampCol: TableColumnDef<ILog> | null = fields.some(
|
|
||||||
(f) => f.name === 'timestamp',
|
|
||||||
)
|
|
||||||
? {
|
|
||||||
id: 'timestamp',
|
|
||||||
header: 'Timestamp',
|
|
||||||
accessorFn: (log): unknown => log.timestamp,
|
|
||||||
width: { default: 170, min: 170 },
|
|
||||||
cell: ({ value }): ReactElement => {
|
|
||||||
const ts = value as string | number;
|
|
||||||
const formatted =
|
|
||||||
typeof ts === 'string'
|
|
||||||
? formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.ISO_DATETIME_MS)
|
|
||||||
: formatTimezoneAdjustedTimestamp(
|
|
||||||
ts / 1e6,
|
|
||||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
|
||||||
);
|
|
||||||
return <TanStackTable.Text>{formatted}</TanStackTable.Text>;
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const bodyCol: TableColumnDef<ILog> | null = fields.some(
|
|
||||||
(f) => f.name === 'body',
|
|
||||||
)
|
|
||||||
? {
|
|
||||||
id: 'body',
|
|
||||||
header: 'Body',
|
|
||||||
accessorFn: (log): string => log.body,
|
|
||||||
canBeHidden: false,
|
|
||||||
width: { default: '100%', min: 640 },
|
|
||||||
cell: ({ value, isActive }): ReactElement => (
|
|
||||||
<TanStackTable.Text
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: getSanitizedLogBody(value as string, {
|
|
||||||
shouldEscapeHtml: true,
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
data-active={isActive}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return [
|
|
||||||
stateIndicatorCol,
|
|
||||||
...(timestampCol ? [timestampCol] : []),
|
|
||||||
...(appendTo === 'center' ? fieldColumns : []),
|
|
||||||
...(bodyCol ? [bodyCol] : []),
|
|
||||||
...(appendTo === 'end' ? fieldColumns : []),
|
|
||||||
];
|
|
||||||
}, [fields, appendTo, fontSize, formatTimezoneAdjustedTimestamp]);
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import { ComponentProps, memo } from 'react';
|
|
||||||
import { TableComponents } from 'react-virtuoso';
|
|
||||||
import cx from 'classnames';
|
|
||||||
|
|
||||||
import TanStackRowCells from './TanStackRow';
|
|
||||||
import {
|
|
||||||
useClearRowHovered,
|
|
||||||
useSetRowHovered,
|
|
||||||
} from './TanStackTableStateContext';
|
|
||||||
import { FlatItem, TableRowContext } from './types';
|
|
||||||
|
|
||||||
import tableStyles from './TanStackTable.module.scss';
|
|
||||||
|
|
||||||
type VirtuosoTableRowProps<TData> = ComponentProps<
|
|
||||||
NonNullable<
|
|
||||||
TableComponents<FlatItem<TData>, TableRowContext<TData>>['TableRow']
|
|
||||||
>
|
|
||||||
>;
|
|
||||||
|
|
||||||
function TanStackCustomTableRow<TData>({
|
|
||||||
item,
|
|
||||||
context,
|
|
||||||
...props
|
|
||||||
}: VirtuosoTableRowProps<TData>): JSX.Element {
|
|
||||||
const rowId = item.row.id;
|
|
||||||
const rowData = item.row.original;
|
|
||||||
|
|
||||||
// Stable callbacks for hover state management
|
|
||||||
const setHovered = useSetRowHovered(rowId);
|
|
||||||
const clearHovered = useClearRowHovered(rowId);
|
|
||||||
|
|
||||||
if (item.kind === 'expansion') {
|
|
||||||
return (
|
|
||||||
<tr {...props} className={tableStyles.tableRowExpansion}>
|
|
||||||
<TanStackRowCells
|
|
||||||
row={item.row}
|
|
||||||
itemKind={item.kind}
|
|
||||||
context={context}
|
|
||||||
hasSingleColumn={context?.hasSingleColumn ?? false}
|
|
||||||
columnOrderKey={context?.columnOrderKey ?? ''}
|
|
||||||
columnVisibilityKey={context?.columnVisibilityKey ?? ''}
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isActive = context?.isRowActive?.(rowData) ?? false;
|
|
||||||
const extraClass = context?.getRowClassName?.(rowData) ?? '';
|
|
||||||
const rowStyle = context?.getRowStyle?.(rowData);
|
|
||||||
|
|
||||||
const rowClassName = cx(
|
|
||||||
tableStyles.tableRow,
|
|
||||||
isActive && tableStyles.tableRowActive,
|
|
||||||
extraClass,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
{...props}
|
|
||||||
className={rowClassName}
|
|
||||||
style={rowStyle}
|
|
||||||
onMouseEnter={setHovered}
|
|
||||||
onMouseLeave={clearHovered}
|
|
||||||
>
|
|
||||||
<TanStackRowCells
|
|
||||||
row={item.row}
|
|
||||||
itemKind={item.kind}
|
|
||||||
context={context}
|
|
||||||
hasSingleColumn={context?.hasSingleColumn ?? false}
|
|
||||||
columnOrderKey={context?.columnOrderKey ?? ''}
|
|
||||||
columnVisibilityKey={context?.columnVisibilityKey ?? ''}
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom comparison - only re-render when row identity or computed values change
|
|
||||||
// This looks overkill but ensures the table is stable and doesn't re-render on every change
|
|
||||||
// If you add any new prop to context, remember to update this function
|
|
||||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
|
||||||
function areTableRowPropsEqual<TData>(
|
|
||||||
prev: Readonly<VirtuosoTableRowProps<TData>>,
|
|
||||||
next: Readonly<VirtuosoTableRowProps<TData>>,
|
|
||||||
): boolean {
|
|
||||||
if (prev.item.row.id !== next.item.row.id) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (prev.item.kind !== next.item.kind) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const prevData = prev.item.row.original;
|
|
||||||
const nextData = next.item.row.original;
|
|
||||||
|
|
||||||
if (prevData !== nextData) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prev.context?.hasSingleColumn !== next.context?.hasSingleColumn) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (prev.context?.columnOrderKey !== next.context?.columnOrderKey) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (prev.context?.columnVisibilityKey !== next.context?.columnVisibilityKey) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prev.context !== next.context) {
|
|
||||||
const prevActive = prev.context?.isRowActive?.(prevData) ?? false;
|
|
||||||
const nextActive = next.context?.isRowActive?.(nextData) ?? false;
|
|
||||||
if (prevActive !== nextActive) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const prevClass = prev.context?.getRowClassName?.(prevData) ?? '';
|
|
||||||
const nextClass = next.context?.getRowClassName?.(nextData) ?? '';
|
|
||||||
if (prevClass !== nextClass) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const prevStyle = prev.context?.getRowStyle?.(prevData);
|
|
||||||
const nextStyle = next.context?.getRowStyle?.(nextData);
|
|
||||||
if (prevStyle !== nextStyle) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default memo(
|
|
||||||
TanStackCustomTableRow,
|
|
||||||
areTableRowPropsEqual,
|
|
||||||
) as typeof TanStackCustomTableRow;
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
import type {
|
|
||||||
CSSProperties,
|
|
||||||
MouseEvent as ReactMouseEvent,
|
|
||||||
TouchEvent as ReactTouchEvent,
|
|
||||||
} from 'react';
|
|
||||||
import { useCallback, useMemo } from 'react';
|
|
||||||
import { CloseOutlined, MoreOutlined } from '@ant-design/icons';
|
|
||||||
import { useSortable } from '@dnd-kit/sortable';
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/popover';
|
|
||||||
import { flexRender, Header as TanStackHeader } from '@tanstack/react-table';
|
|
||||||
import cx from 'classnames';
|
|
||||||
import { ChevronDown, ChevronUp, GripVertical } from 'lucide-react';
|
|
||||||
|
|
||||||
import { SortState, TableColumnDef } from './types';
|
|
||||||
|
|
||||||
import headerStyles from './TanStackHeaderRow.module.scss';
|
|
||||||
import tableStyles from './TanStackTable.module.scss';
|
|
||||||
|
|
||||||
type TanStackHeaderRowProps<TData = unknown> = {
|
|
||||||
column: TableColumnDef<TData>;
|
|
||||||
header?: TanStackHeader<TData, unknown>;
|
|
||||||
isDarkMode: boolean;
|
|
||||||
hasSingleColumn: boolean;
|
|
||||||
canRemoveColumn?: boolean;
|
|
||||||
onRemoveColumn?: (columnId: string) => void;
|
|
||||||
orderBy?: SortState | null;
|
|
||||||
onSort?: (sort: SortState | null) => void;
|
|
||||||
/** Last column cannot be resized */
|
|
||||||
isLastColumn?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const GRIP_ICON_SIZE = 12;
|
|
||||||
|
|
||||||
const SORT_ICON_SIZE = 14;
|
|
||||||
|
|
||||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
|
||||||
function TanStackHeaderRow<TData>({
|
|
||||||
column,
|
|
||||||
header,
|
|
||||||
isDarkMode,
|
|
||||||
hasSingleColumn,
|
|
||||||
canRemoveColumn = false,
|
|
||||||
onRemoveColumn,
|
|
||||||
orderBy,
|
|
||||||
onSort,
|
|
||||||
isLastColumn = false,
|
|
||||||
}: TanStackHeaderRowProps<TData>): JSX.Element {
|
|
||||||
const columnId = column.id;
|
|
||||||
const isDragColumn = column.enableMove !== false && column.pin == null;
|
|
||||||
const isResizableColumn =
|
|
||||||
!isLastColumn &&
|
|
||||||
column.enableResize !== false &&
|
|
||||||
Boolean(header?.column.getCanResize());
|
|
||||||
const isColumnRemovable = Boolean(
|
|
||||||
canRemoveColumn && onRemoveColumn && column.enableRemove,
|
|
||||||
);
|
|
||||||
const isSortable = column.enableSort === true && Boolean(onSort);
|
|
||||||
const currentSortDirection =
|
|
||||||
orderBy?.columnName === columnId ? orderBy.order : null;
|
|
||||||
const isResizing = Boolean(header?.column.getIsResizing());
|
|
||||||
const resizeHandler = header?.getResizeHandler();
|
|
||||||
const headerText =
|
|
||||||
typeof column.header === 'string' && column.header
|
|
||||||
? column.header
|
|
||||||
: String(header?.id ?? columnId);
|
|
||||||
const headerTitleAttr = headerText.replace(/^\w/, (c) => c.toUpperCase());
|
|
||||||
|
|
||||||
const handleSortClick = useCallback((): void => {
|
|
||||||
if (!isSortable || !onSort) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (currentSortDirection === null) {
|
|
||||||
onSort({ columnName: columnId, order: 'asc' });
|
|
||||||
} else if (currentSortDirection === 'asc') {
|
|
||||||
onSort({ columnName: columnId, order: 'desc' });
|
|
||||||
} else {
|
|
||||||
onSort(null);
|
|
||||||
}
|
|
||||||
}, [isSortable, onSort, currentSortDirection, columnId]);
|
|
||||||
|
|
||||||
const handleResizeStart = (
|
|
||||||
event: ReactMouseEvent<HTMLElement> | ReactTouchEvent<HTMLElement>,
|
|
||||||
): void => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
resizeHandler?.(event);
|
|
||||||
};
|
|
||||||
const {
|
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
setNodeRef,
|
|
||||||
setActivatorNodeRef,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
isDragging,
|
|
||||||
} = useSortable({
|
|
||||||
id: columnId,
|
|
||||||
disabled: !isDragColumn,
|
|
||||||
});
|
|
||||||
const headerCellStyle = useMemo(
|
|
||||||
() =>
|
|
||||||
({
|
|
||||||
'--tanstack-header-translate-x': `${Math.round(transform?.x ?? 0)}px`,
|
|
||||||
'--tanstack-header-translate-y': `${Math.round(transform?.y ?? 0)}px`,
|
|
||||||
'--tanstack-header-transition': isResizing ? 'none' : transition || 'none',
|
|
||||||
} as CSSProperties),
|
|
||||||
[isResizing, transform?.x, transform?.y, transition],
|
|
||||||
);
|
|
||||||
const headerCellClassName = cx(
|
|
||||||
headerStyles.tanstackHeaderCell,
|
|
||||||
isDragging && headerStyles.isDragging,
|
|
||||||
isResizing && headerStyles.isResizing,
|
|
||||||
);
|
|
||||||
const headerContentClassName = cx(
|
|
||||||
headerStyles.tanstackHeaderContent,
|
|
||||||
isResizableColumn && headerStyles.hasResizeControl,
|
|
||||||
isColumnRemovable && headerStyles.hasActionControl,
|
|
||||||
isSortable && headerStyles.isSortable,
|
|
||||||
);
|
|
||||||
|
|
||||||
const thClassName = cx(
|
|
||||||
tableStyles.tableHeaderCell,
|
|
||||||
headerCellClassName,
|
|
||||||
column.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<th
|
|
||||||
ref={setNodeRef}
|
|
||||||
className={thClassName}
|
|
||||||
key={columnId}
|
|
||||||
style={headerCellStyle}
|
|
||||||
data-dark-mode={isDarkMode}
|
|
||||||
data-single-column={hasSingleColumn || undefined}
|
|
||||||
>
|
|
||||||
<span className={headerContentClassName}>
|
|
||||||
{isDragColumn ? (
|
|
||||||
<span className={headerStyles.tanstackGripSlot}>
|
|
||||||
<span
|
|
||||||
ref={setActivatorNodeRef}
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
role="button"
|
|
||||||
aria-label={`Drag ${String(
|
|
||||||
(typeof column.header === 'string' && column.header) ||
|
|
||||||
header?.id ||
|
|
||||||
columnId,
|
|
||||||
)} column`}
|
|
||||||
className={headerStyles.tanstackGripActivator}
|
|
||||||
>
|
|
||||||
<GripVertical size={GRIP_ICON_SIZE} />
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{isSortable ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cx(
|
|
||||||
'tanstack-header-title',
|
|
||||||
headerStyles.tanstackSortButton,
|
|
||||||
currentSortDirection && headerStyles.isSorted,
|
|
||||||
)}
|
|
||||||
title={headerTitleAttr}
|
|
||||||
onClick={handleSortClick}
|
|
||||||
aria-sort={
|
|
||||||
currentSortDirection === 'asc'
|
|
||||||
? 'ascending'
|
|
||||||
: currentSortDirection === 'desc'
|
|
||||||
? 'descending'
|
|
||||||
: 'none'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className={headerStyles.tanstackSortLabel}>
|
|
||||||
{header?.column?.columnDef
|
|
||||||
? flexRender(header.column.columnDef.header, header.getContext())
|
|
||||||
: typeof column.header === 'function'
|
|
||||||
? column.header()
|
|
||||||
: String(column.header || '').replace(/^\w/, (c) => c.toUpperCase())}
|
|
||||||
</span>
|
|
||||||
<span className={headerStyles.tanstackSortIndicator}>
|
|
||||||
{currentSortDirection === 'asc' ? (
|
|
||||||
<ChevronUp size={SORT_ICON_SIZE} />
|
|
||||||
) : currentSortDirection === 'desc' ? (
|
|
||||||
<ChevronDown size={SORT_ICON_SIZE} />
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className={cx('tanstack-header-title', headerStyles.tanstackHeaderTitle)}
|
|
||||||
title={headerTitleAttr}
|
|
||||||
>
|
|
||||||
{header?.column?.columnDef
|
|
||||||
? flexRender(header.column.columnDef.header, header.getContext())
|
|
||||||
: typeof column.header === 'function'
|
|
||||||
? column.header()
|
|
||||||
: String(column.header || '').replace(/^\w/, (c) => c.toUpperCase())}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{isColumnRemovable && (
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<span
|
|
||||||
role="button"
|
|
||||||
aria-label={`Column actions for ${headerTitleAttr}`}
|
|
||||||
className={headerStyles.tanstackHeaderActionTrigger}
|
|
||||||
onMouseDown={(event): void => {
|
|
||||||
event.stopPropagation();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MoreOutlined />
|
|
||||||
</span>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
align="end"
|
|
||||||
sideOffset={6}
|
|
||||||
className={headerStyles.tanstackColumnActionsContent}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={headerStyles.tanstackRemoveColumnAction}
|
|
||||||
onClick={(event): void => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
onRemoveColumn?.(column.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CloseOutlined
|
|
||||||
className={headerStyles.tanstackRemoveColumnActionIcon}
|
|
||||||
/>
|
|
||||||
Remove column
|
|
||||||
</button>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{isResizableColumn && (
|
|
||||||
<span
|
|
||||||
role="presentation"
|
|
||||||
className={headerStyles.cursorColResize}
|
|
||||||
title="Drag to resize column"
|
|
||||||
onClick={(event): void => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
}}
|
|
||||||
onMouseDown={(event): void => {
|
|
||||||
handleResizeStart(event);
|
|
||||||
}}
|
|
||||||
onTouchStart={(event): void => {
|
|
||||||
handleResizeStart(event);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className={headerStyles.tanstackResizeHandleLine} />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</th>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default TanStackHeaderRow;
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
import type { MouseEvent } from 'react';
|
|
||||||
import { memo, useCallback } from 'react';
|
|
||||||
import { Row as TanStackRowModel } from '@tanstack/react-table';
|
|
||||||
|
|
||||||
import { TanStackRowCell } from './TanStackRowCell';
|
|
||||||
import { useIsRowHovered } from './TanStackTableStateContext';
|
|
||||||
import { TableRowContext } from './types';
|
|
||||||
|
|
||||||
import tableStyles from './TanStackTable.module.scss';
|
|
||||||
|
|
||||||
type TanStackRowCellsProps<TData> = {
|
|
||||||
row: TanStackRowModel<TData>;
|
|
||||||
context: TableRowContext<TData> | undefined;
|
|
||||||
itemKind: 'row' | 'expansion';
|
|
||||||
hasSingleColumn: boolean;
|
|
||||||
columnOrderKey: string;
|
|
||||||
columnVisibilityKey: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function TanStackRowCellsInner<TData>({
|
|
||||||
row,
|
|
||||||
context,
|
|
||||||
itemKind,
|
|
||||||
hasSingleColumn,
|
|
||||||
columnOrderKey: _columnOrderKey,
|
|
||||||
columnVisibilityKey: _columnVisibilityKey,
|
|
||||||
}: TanStackRowCellsProps<TData>): JSX.Element {
|
|
||||||
const hasHovered = useIsRowHovered(row.id);
|
|
||||||
const rowData = row.original;
|
|
||||||
const visibleCells = row.getVisibleCells();
|
|
||||||
const lastCellIndex = visibleCells.length - 1;
|
|
||||||
|
|
||||||
// Stable references via destructuring, keep them as is
|
|
||||||
const onRowClick = context?.onRowClick;
|
|
||||||
const onRowClickNewTab = context?.onRowClickNewTab;
|
|
||||||
const onRowDeactivate = context?.onRowDeactivate;
|
|
||||||
const isRowActive = context?.isRowActive;
|
|
||||||
const getRowKeyData = context?.getRowKeyData;
|
|
||||||
const rowIndex = row.index;
|
|
||||||
|
|
||||||
const handleClick = useCallback(
|
|
||||||
(event: MouseEvent<HTMLTableCellElement>) => {
|
|
||||||
const keyData = getRowKeyData?.(rowIndex);
|
|
||||||
const itemKey = keyData?.itemKey ?? '';
|
|
||||||
|
|
||||||
// Handle ctrl+click or cmd+click (open in new tab)
|
|
||||||
if ((event.ctrlKey || event.metaKey) && onRowClickNewTab) {
|
|
||||||
onRowClickNewTab(rowData, itemKey);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isActive = isRowActive?.(rowData) ?? false;
|
|
||||||
if (isActive && onRowDeactivate) {
|
|
||||||
onRowDeactivate();
|
|
||||||
} else {
|
|
||||||
onRowClick?.(rowData, itemKey);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
isRowActive,
|
|
||||||
onRowDeactivate,
|
|
||||||
onRowClick,
|
|
||||||
onRowClickNewTab,
|
|
||||||
rowData,
|
|
||||||
getRowKeyData,
|
|
||||||
rowIndex,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (itemKind === 'expansion') {
|
|
||||||
const keyData = getRowKeyData?.(rowIndex);
|
|
||||||
return (
|
|
||||||
<td
|
|
||||||
colSpan={context?.colCount ?? 1}
|
|
||||||
className={tableStyles.tableCellExpansion}
|
|
||||||
>
|
|
||||||
{context?.renderExpandedRow?.(
|
|
||||||
rowData,
|
|
||||||
keyData?.finalKey ?? '',
|
|
||||||
keyData?.groupMeta,
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{visibleCells.map((cell, index) => {
|
|
||||||
const isLastCell = index === lastCellIndex;
|
|
||||||
return (
|
|
||||||
<TanStackRowCell
|
|
||||||
key={cell.id}
|
|
||||||
cell={cell}
|
|
||||||
hasSingleColumn={hasSingleColumn}
|
|
||||||
isLastCell={isLastCell}
|
|
||||||
hasHovered={hasHovered}
|
|
||||||
rowData={rowData}
|
|
||||||
onClick={handleClick}
|
|
||||||
renderRowActions={context?.renderRowActions}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom comparison - only re-render when row data changes
|
|
||||||
// If you add any new prop to context, remember to update this function
|
|
||||||
function areRowCellsPropsEqual<TData>(
|
|
||||||
prev: Readonly<TanStackRowCellsProps<TData>>,
|
|
||||||
next: Readonly<TanStackRowCellsProps<TData>>,
|
|
||||||
): boolean {
|
|
||||||
return (
|
|
||||||
prev.row.id === next.row.id &&
|
|
||||||
prev.itemKind === next.itemKind &&
|
|
||||||
prev.hasSingleColumn === next.hasSingleColumn &&
|
|
||||||
prev.columnOrderKey === next.columnOrderKey &&
|
|
||||||
prev.columnVisibilityKey === next.columnVisibilityKey &&
|
|
||||||
prev.context?.onRowClick === next.context?.onRowClick &&
|
|
||||||
prev.context?.onRowClickNewTab === next.context?.onRowClickNewTab &&
|
|
||||||
prev.context?.onRowDeactivate === next.context?.onRowDeactivate &&
|
|
||||||
prev.context?.isRowActive === next.context?.isRowActive &&
|
|
||||||
prev.context?.getRowKeyData === next.context?.getRowKeyData &&
|
|
||||||
prev.context?.renderRowActions === next.context?.renderRowActions &&
|
|
||||||
prev.context?.renderExpandedRow === next.context?.renderExpandedRow &&
|
|
||||||
prev.context?.colCount === next.context?.colCount
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const TanStackRowCells = memo(
|
|
||||||
TanStackRowCellsInner,
|
|
||||||
areRowCellsPropsEqual as any,
|
|
||||||
) as <T>(props: TanStackRowCellsProps<T>) => JSX.Element;
|
|
||||||
|
|
||||||
export default TanStackRowCells;
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import type { MouseEvent, ReactNode } from 'react';
|
|
||||||
import { memo } from 'react';
|
|
||||||
import type { Cell } from '@tanstack/react-table';
|
|
||||||
import { flexRender } from '@tanstack/react-table';
|
|
||||||
import { Skeleton } from 'antd';
|
|
||||||
import cx from 'classnames';
|
|
||||||
|
|
||||||
import { useShouldShowCellSkeleton } from './TanStackTableStateContext';
|
|
||||||
|
|
||||||
import tableStyles from './TanStackTable.module.scss';
|
|
||||||
import skeletonStyles from './TanStackTableSkeleton.module.scss';
|
|
||||||
|
|
||||||
export type TanStackRowCellProps<TData> = {
|
|
||||||
cell: Cell<TData, unknown>;
|
|
||||||
hasSingleColumn: boolean;
|
|
||||||
isLastCell: boolean;
|
|
||||||
hasHovered: boolean;
|
|
||||||
rowData: TData;
|
|
||||||
onClick: (event: MouseEvent<HTMLTableCellElement>) => void;
|
|
||||||
renderRowActions?: (row: TData) => ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
function TanStackRowCellInner<TData>({
|
|
||||||
cell,
|
|
||||||
hasSingleColumn,
|
|
||||||
isLastCell,
|
|
||||||
hasHovered,
|
|
||||||
rowData,
|
|
||||||
onClick,
|
|
||||||
renderRowActions,
|
|
||||||
}: TanStackRowCellProps<TData>): JSX.Element {
|
|
||||||
const showSkeleton = useShouldShowCellSkeleton();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<td
|
|
||||||
className={cx(tableStyles.tableCell, 'tanstack-cell-' + cell.column.id)}
|
|
||||||
data-single-column={hasSingleColumn || undefined}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{showSkeleton ? (
|
|
||||||
<Skeleton.Input
|
|
||||||
active
|
|
||||||
size="small"
|
|
||||||
className={skeletonStyles.cellSkeleton}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
flexRender(cell.column.columnDef.cell, cell.getContext())
|
|
||||||
)}
|
|
||||||
{isLastCell && hasHovered && renderRowActions && !showSkeleton && (
|
|
||||||
<span className={tableStyles.tableViewRowActions}>
|
|
||||||
{renderRowActions(rowData)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom comparison - only re-render when row data changes
|
|
||||||
// If you add any new prop to context, remember to update this function
|
|
||||||
function areTanStackRowCellPropsEqual<TData>(
|
|
||||||
prev: Readonly<TanStackRowCellProps<TData>>,
|
|
||||||
next: Readonly<TanStackRowCellProps<TData>>,
|
|
||||||
): boolean {
|
|
||||||
if (next.cell.id.startsWith('skeleton-')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
prev.cell.id === next.cell.id &&
|
|
||||||
prev.cell.column.id === next.cell.column.id &&
|
|
||||||
Object.is(prev.cell.getValue(), next.cell.getValue()) &&
|
|
||||||
prev.hasSingleColumn === next.hasSingleColumn &&
|
|
||||||
prev.isLastCell === next.isLastCell &&
|
|
||||||
prev.hasHovered === next.hasHovered &&
|
|
||||||
prev.onClick === next.onClick &&
|
|
||||||
prev.renderRowActions === next.renderRowActions &&
|
|
||||||
prev.rowData === next.rowData
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const TanStackRowCellMemo = memo(
|
|
||||||
TanStackRowCellInner,
|
|
||||||
areTanStackRowCellPropsEqual,
|
|
||||||
);
|
|
||||||
|
|
||||||
TanStackRowCellMemo.displayName = 'TanStackRowCell';
|
|
||||||
|
|
||||||
export const TanStackRowCell = TanStackRowCellMemo as typeof TanStackRowCellInner;
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
.tanStackTable {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: separate;
|
|
||||||
border-spacing: 0;
|
|
||||||
table-layout: fixed;
|
|
||||||
|
|
||||||
& td,
|
|
||||||
& th {
|
|
||||||
overflow: hidden;
|
|
||||||
min-width: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tableCellText {
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
letter-spacing: -0.07px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
display: -webkit-box;
|
|
||||||
width: auto;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
-webkit-line-clamp: var(--tanstack-plain-body-line-clamp, 1);
|
|
||||||
line-clamp: var(--tanstack-plain-body-line-clamp, 1);
|
|
||||||
font-size: var(--tanstack-plain-cell-font-size, 14px);
|
|
||||||
line-height: var(--tanstack-plain-cell-line-height, 18px);
|
|
||||||
color: var(--l2-foreground);
|
|
||||||
max-width: 100%;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tableViewRowActions {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
right: 8px;
|
|
||||||
left: auto;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
margin: 0;
|
|
||||||
z-index: 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tableCell {
|
|
||||||
padding: 0.3rem;
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
letter-spacing: -0.07px;
|
|
||||||
font-size: var(--tanstack-plain-cell-font-size, 14px);
|
|
||||||
line-height: var(--tanstack-plain-cell-line-height, 18px);
|
|
||||||
color: var(--l2-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tableRow {
|
|
||||||
cursor: pointer;
|
|
||||||
position: relative;
|
|
||||||
overflow-anchor: none;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
.tableCell {
|
|
||||||
background-color: var(--row-hover-bg) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.tableRowActive {
|
|
||||||
.tableCell {
|
|
||||||
background-color: var(--row-active-bg) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tableHeaderCell {
|
|
||||||
padding: 0.3rem;
|
|
||||||
height: 36px;
|
|
||||||
text-align: left;
|
|
||||||
font-size: 14px;
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
line-height: 18px;
|
|
||||||
letter-spacing: -0.07px;
|
|
||||||
color: var(--l1-foreground);
|
|
||||||
|
|
||||||
// TODO: Remove this once background color (l1) is matching the actual background color of the page
|
|
||||||
&[data-dark-mode='true'] {
|
|
||||||
background: #0b0c0d;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-dark-mode='false'] {
|
|
||||||
background: #fdfdfd;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tableRowExpansion {
|
|
||||||
display: table-row;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tableCellExpansion {
|
|
||||||
padding: 0.5rem;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
@@ -1,572 +0,0 @@
|
|||||||
import type { ComponentProps, CSSProperties } from 'react';
|
|
||||||
import {
|
|
||||||
forwardRef,
|
|
||||||
memo,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useImperativeHandle,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
} from 'react';
|
|
||||||
import type { TableComponents } from 'react-virtuoso';
|
|
||||||
import { TableVirtuoso, TableVirtuosoHandle } from 'react-virtuoso';
|
|
||||||
import { LoadingOutlined } from '@ant-design/icons';
|
|
||||||
import { DndContext, pointerWithin } from '@dnd-kit/core';
|
|
||||||
import {
|
|
||||||
horizontalListSortingStrategy,
|
|
||||||
SortableContext,
|
|
||||||
} from '@dnd-kit/sortable';
|
|
||||||
import {
|
|
||||||
ComboboxSimple,
|
|
||||||
ComboboxSimpleItem,
|
|
||||||
TooltipProvider,
|
|
||||||
} from '@signozhq/ui';
|
|
||||||
import { Pagination } from '@signozhq/ui';
|
|
||||||
import type { Row } from '@tanstack/react-table';
|
|
||||||
import {
|
|
||||||
ColumnDef,
|
|
||||||
ColumnPinningState,
|
|
||||||
getCoreRowModel,
|
|
||||||
useReactTable,
|
|
||||||
} from '@tanstack/react-table';
|
|
||||||
import { Spin } from 'antd';
|
|
||||||
import cx from 'classnames';
|
|
||||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
|
||||||
|
|
||||||
import TanStackCustomTableRow from './TanStackCustomTableRow';
|
|
||||||
import TanStackHeaderRow from './TanStackHeaderRow';
|
|
||||||
import {
|
|
||||||
ColumnVisibilitySync,
|
|
||||||
TableLoadingSync,
|
|
||||||
TanStackTableStateProvider,
|
|
||||||
} from './TanStackTableStateContext';
|
|
||||||
import {
|
|
||||||
FlatItem,
|
|
||||||
TableRowContext,
|
|
||||||
TanStackTableHandle,
|
|
||||||
TanStackTableProps,
|
|
||||||
} from './types';
|
|
||||||
import { useColumnDnd } from './useColumnDnd';
|
|
||||||
import { useColumnHandlers } from './useColumnHandlers';
|
|
||||||
import { useColumnState } from './useColumnState';
|
|
||||||
import { useEffectiveData } from './useEffectiveData';
|
|
||||||
import { useFlatItems } from './useFlatItems';
|
|
||||||
import { useRowKeyData } from './useRowKeyData';
|
|
||||||
import { useTableParams } from './useTableParams';
|
|
||||||
import { buildTanstackColumnDef } from './utils';
|
|
||||||
import { VirtuosoTableColGroup } from './VirtuosoTableColGroup';
|
|
||||||
|
|
||||||
import tableStyles from './TanStackTable.module.scss';
|
|
||||||
import viewStyles from './TanStackTableView.module.scss';
|
|
||||||
|
|
||||||
const COLUMN_DND_AUTO_SCROLL = {
|
|
||||||
layoutShiftCompensation: false as const,
|
|
||||||
threshold: { x: 0.2, y: 0 },
|
|
||||||
};
|
|
||||||
|
|
||||||
const INCREASE_VIEWPORT_BY = { top: 500, bottom: 500 };
|
|
||||||
|
|
||||||
const noopColumnVisibility = (): void => {};
|
|
||||||
|
|
||||||
const paginationPageSizeItems: ComboboxSimpleItem[] = [10, 20, 30, 50, 100].map(
|
|
||||||
(value) => ({
|
|
||||||
value: value.toString(),
|
|
||||||
label: value.toString(),
|
|
||||||
displayValue: value.toString(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
|
||||||
function TanStackTableInner<TData>(
|
|
||||||
{
|
|
||||||
data,
|
|
||||||
columns,
|
|
||||||
columnStorageKey,
|
|
||||||
columnSizing: columnSizingProp,
|
|
||||||
onColumnSizingChange,
|
|
||||||
onColumnOrderChange,
|
|
||||||
onColumnRemove,
|
|
||||||
isLoading = false,
|
|
||||||
skeletonRowCount = 10,
|
|
||||||
enableQueryParams,
|
|
||||||
pagination,
|
|
||||||
onEndReached,
|
|
||||||
getRowKey,
|
|
||||||
getItemKey,
|
|
||||||
groupBy,
|
|
||||||
getGroupKey,
|
|
||||||
getRowStyle,
|
|
||||||
getRowClassName,
|
|
||||||
isRowActive,
|
|
||||||
renderRowActions,
|
|
||||||
onRowClick,
|
|
||||||
onRowClickNewTab,
|
|
||||||
onRowDeactivate,
|
|
||||||
activeRowIndex,
|
|
||||||
renderExpandedRow,
|
|
||||||
getRowCanExpand,
|
|
||||||
tableScrollerProps,
|
|
||||||
plainTextCellLineClamp,
|
|
||||||
cellTypographySize,
|
|
||||||
className,
|
|
||||||
testId,
|
|
||||||
prefixPaginationContent,
|
|
||||||
suffixPaginationContent,
|
|
||||||
}: TanStackTableProps<TData>,
|
|
||||||
forwardedRef: React.ForwardedRef<TanStackTableHandle>,
|
|
||||||
): JSX.Element {
|
|
||||||
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
|
|
||||||
const isDarkMode = useIsDarkMode();
|
|
||||||
|
|
||||||
const {
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
setPage,
|
|
||||||
setLimit,
|
|
||||||
orderBy,
|
|
||||||
setOrderBy,
|
|
||||||
expanded,
|
|
||||||
setExpanded,
|
|
||||||
} = useTableParams(enableQueryParams, {
|
|
||||||
page: pagination?.defaultPage,
|
|
||||||
limit: pagination?.defaultLimit,
|
|
||||||
});
|
|
||||||
|
|
||||||
const isGrouped = (groupBy?.length ?? 0) > 0;
|
|
||||||
|
|
||||||
const {
|
|
||||||
columnVisibility: storeVisibility,
|
|
||||||
columnSizing: storeSizing,
|
|
||||||
sortedColumns,
|
|
||||||
hideColumn,
|
|
||||||
setColumnSizing: storeSetSizing,
|
|
||||||
setColumnOrder: storeSetOrder,
|
|
||||||
} = useColumnState({
|
|
||||||
storageKey: columnStorageKey,
|
|
||||||
columns,
|
|
||||||
isGrouped,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use store values when columnStorageKey is provided, otherwise fall back to props/defaults
|
|
||||||
const effectiveColumns = columnStorageKey ? sortedColumns : columns;
|
|
||||||
const effectiveVisibility = columnStorageKey ? storeVisibility : {};
|
|
||||||
const effectiveSizing = columnStorageKey
|
|
||||||
? storeSizing
|
|
||||||
: columnSizingProp ?? {};
|
|
||||||
|
|
||||||
const effectiveData = useEffectiveData<TData>({
|
|
||||||
data,
|
|
||||||
isLoading,
|
|
||||||
limit,
|
|
||||||
skeletonRowCount,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { rowKeyData, getRowKeyData } = useRowKeyData({
|
|
||||||
data: effectiveData,
|
|
||||||
isLoading,
|
|
||||||
getRowKey,
|
|
||||||
getItemKey,
|
|
||||||
groupBy,
|
|
||||||
getGroupKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
handleColumnSizingChange,
|
|
||||||
handleColumnOrderChange,
|
|
||||||
handleRemoveColumn,
|
|
||||||
} = useColumnHandlers({
|
|
||||||
columnStorageKey,
|
|
||||||
effectiveSizing,
|
|
||||||
storeSetSizing,
|
|
||||||
storeSetOrder,
|
|
||||||
hideColumn,
|
|
||||||
onColumnSizingChange,
|
|
||||||
onColumnOrderChange,
|
|
||||||
onColumnRemove,
|
|
||||||
});
|
|
||||||
|
|
||||||
const columnPinning = useMemo<ColumnPinningState>(
|
|
||||||
() => ({
|
|
||||||
left: effectiveColumns.filter((c) => c.pin === 'left').map((c) => c.id),
|
|
||||||
right: effectiveColumns.filter((c) => c.pin === 'right').map((c) => c.id),
|
|
||||||
}),
|
|
||||||
[effectiveColumns],
|
|
||||||
);
|
|
||||||
|
|
||||||
const tanstackColumns = useMemo<ColumnDef<TData>[]>(
|
|
||||||
() =>
|
|
||||||
effectiveColumns.map((colDef) =>
|
|
||||||
buildTanstackColumnDef(colDef, isRowActive, getRowKeyData),
|
|
||||||
),
|
|
||||||
[effectiveColumns, isRowActive, getRowKeyData],
|
|
||||||
);
|
|
||||||
|
|
||||||
const getRowId = useCallback(
|
|
||||||
(row: TData, index: number): string => {
|
|
||||||
if (rowKeyData) {
|
|
||||||
return rowKeyData[index]?.finalKey ?? String(index);
|
|
||||||
}
|
|
||||||
const r = row as Record<string, unknown>;
|
|
||||||
if (r != null && typeof r.id !== 'undefined') {
|
|
||||||
return String(r.id);
|
|
||||||
}
|
|
||||||
return String(index);
|
|
||||||
},
|
|
||||||
[rowKeyData],
|
|
||||||
);
|
|
||||||
|
|
||||||
const tableGetRowCanExpand = useCallback(
|
|
||||||
(row: Row<TData>): boolean =>
|
|
||||||
getRowCanExpand ? getRowCanExpand(row.original) : true,
|
|
||||||
[getRowCanExpand],
|
|
||||||
);
|
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data: effectiveData,
|
|
||||||
columns: tanstackColumns,
|
|
||||||
enableColumnResizing: true,
|
|
||||||
enableColumnPinning: true,
|
|
||||||
columnResizeMode: 'onChange',
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getRowId,
|
|
||||||
enableExpanding: Boolean(renderExpandedRow),
|
|
||||||
getRowCanExpand: renderExpandedRow ? tableGetRowCanExpand : undefined,
|
|
||||||
onColumnSizingChange: handleColumnSizingChange,
|
|
||||||
onColumnVisibilityChange: noopColumnVisibility,
|
|
||||||
onExpandedChange: setExpanded,
|
|
||||||
state: {
|
|
||||||
columnSizing: effectiveSizing,
|
|
||||||
columnVisibility: effectiveVisibility,
|
|
||||||
columnPinning,
|
|
||||||
expanded,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Keep refs to avoid recreating virtuosoComponents on every resize/render
|
|
||||||
const tableRef = useRef(table);
|
|
||||||
tableRef.current = table;
|
|
||||||
const columnsRef = useRef(effectiveColumns);
|
|
||||||
columnsRef.current = effectiveColumns;
|
|
||||||
|
|
||||||
const tableRows = table.getRowModel().rows;
|
|
||||||
|
|
||||||
const { flatItems, flatIndexForActiveRow } = useFlatItems({
|
|
||||||
tableRows,
|
|
||||||
renderExpandedRow,
|
|
||||||
expanded,
|
|
||||||
activeRowIndex,
|
|
||||||
});
|
|
||||||
|
|
||||||
// keep previous count just to avoid flashing the pagination component
|
|
||||||
const prevTotalCountRef = useRef(pagination?.total || 0);
|
|
||||||
if (pagination?.total && pagination?.total > 0) {
|
|
||||||
prevTotalCountRef.current = pagination?.total;
|
|
||||||
}
|
|
||||||
const effectiveTotalCount = !isLoading
|
|
||||||
? pagination?.total || 0
|
|
||||||
: prevTotalCountRef.current;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (flatIndexForActiveRow < 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
virtuosoRef.current?.scrollToIndex({
|
|
||||||
index: flatIndexForActiveRow,
|
|
||||||
align: 'center',
|
|
||||||
behavior: 'auto',
|
|
||||||
});
|
|
||||||
}, [flatIndexForActiveRow]);
|
|
||||||
|
|
||||||
const { sensors, columnIds, handleDragEnd } = useColumnDnd({
|
|
||||||
columns: effectiveColumns,
|
|
||||||
onColumnOrderChange: handleColumnOrderChange,
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasSingleColumn = useMemo(
|
|
||||||
() =>
|
|
||||||
effectiveColumns.filter((c) => !c.pin && c.enableRemove !== false).length <=
|
|
||||||
1,
|
|
||||||
[effectiveColumns],
|
|
||||||
);
|
|
||||||
|
|
||||||
const canRemoveColumn = !hasSingleColumn;
|
|
||||||
|
|
||||||
const flatHeaders = useMemo(
|
|
||||||
() => table.getFlatHeaders().filter((header) => !header.isPlaceholder),
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
[tanstackColumns, columnPinning, effectiveVisibility],
|
|
||||||
);
|
|
||||||
|
|
||||||
const columnsById = useMemo(
|
|
||||||
() => new Map(effectiveColumns.map((c) => [c.id, c] as const)),
|
|
||||||
[effectiveColumns],
|
|
||||||
);
|
|
||||||
|
|
||||||
const visibleColumnsCount = table.getVisibleFlatColumns().length;
|
|
||||||
|
|
||||||
const columnOrderKey = useMemo(() => columnIds.join(','), [columnIds]);
|
|
||||||
const columnVisibilityKey = useMemo(
|
|
||||||
() =>
|
|
||||||
table
|
|
||||||
.getVisibleFlatColumns()
|
|
||||||
.map((c) => c.id)
|
|
||||||
.join(','),
|
|
||||||
// we want to explicitly have table out of this deps
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
[effectiveVisibility, columnIds],
|
|
||||||
);
|
|
||||||
|
|
||||||
const virtuosoContext = useMemo<TableRowContext<TData>>(
|
|
||||||
() => ({
|
|
||||||
getRowStyle,
|
|
||||||
getRowClassName,
|
|
||||||
isRowActive,
|
|
||||||
renderRowActions,
|
|
||||||
onRowClick,
|
|
||||||
onRowClickNewTab,
|
|
||||||
onRowDeactivate,
|
|
||||||
renderExpandedRow,
|
|
||||||
getRowKeyData,
|
|
||||||
colCount: visibleColumnsCount,
|
|
||||||
isDarkMode,
|
|
||||||
plainTextCellLineClamp,
|
|
||||||
hasSingleColumn,
|
|
||||||
columnOrderKey,
|
|
||||||
columnVisibilityKey,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
getRowStyle,
|
|
||||||
getRowClassName,
|
|
||||||
isRowActive,
|
|
||||||
renderRowActions,
|
|
||||||
onRowClick,
|
|
||||||
onRowClickNewTab,
|
|
||||||
onRowDeactivate,
|
|
||||||
renderExpandedRow,
|
|
||||||
getRowKeyData,
|
|
||||||
visibleColumnsCount,
|
|
||||||
isDarkMode,
|
|
||||||
plainTextCellLineClamp,
|
|
||||||
hasSingleColumn,
|
|
||||||
columnOrderKey,
|
|
||||||
columnVisibilityKey,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const tableHeader = useCallback(() => {
|
|
||||||
return (
|
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={pointerWithin}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
autoScroll={COLUMN_DND_AUTO_SCROLL}
|
|
||||||
>
|
|
||||||
<SortableContext items={columnIds} strategy={horizontalListSortingStrategy}>
|
|
||||||
<tr>
|
|
||||||
{flatHeaders.map((header, index) => {
|
|
||||||
const column = columnsById.get(header.id);
|
|
||||||
if (!column) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<TanStackHeaderRow
|
|
||||||
key={header.id}
|
|
||||||
column={column}
|
|
||||||
header={header}
|
|
||||||
isDarkMode={isDarkMode}
|
|
||||||
hasSingleColumn={hasSingleColumn}
|
|
||||||
onRemoveColumn={handleRemoveColumn}
|
|
||||||
canRemoveColumn={canRemoveColumn}
|
|
||||||
orderBy={orderBy}
|
|
||||||
onSort={setOrderBy}
|
|
||||||
isLastColumn={index === flatHeaders.length - 1}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tr>
|
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
sensors,
|
|
||||||
handleDragEnd,
|
|
||||||
columnIds,
|
|
||||||
flatHeaders,
|
|
||||||
columnsById,
|
|
||||||
isDarkMode,
|
|
||||||
hasSingleColumn,
|
|
||||||
handleRemoveColumn,
|
|
||||||
canRemoveColumn,
|
|
||||||
orderBy,
|
|
||||||
setOrderBy,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleEndReached = useCallback(
|
|
||||||
(index: number): void => {
|
|
||||||
onEndReached?.(index);
|
|
||||||
},
|
|
||||||
[onEndReached],
|
|
||||||
);
|
|
||||||
|
|
||||||
const isInfiniteScrollMode = Boolean(onEndReached);
|
|
||||||
const showInfiniteScrollLoader = isInfiniteScrollMode && isLoading;
|
|
||||||
|
|
||||||
useImperativeHandle(
|
|
||||||
forwardedRef,
|
|
||||||
(): TanStackTableHandle =>
|
|
||||||
new Proxy(
|
|
||||||
{
|
|
||||||
goToPage: (p: number): void => {
|
|
||||||
setPage(p);
|
|
||||||
virtuosoRef.current?.scrollToIndex({
|
|
||||||
index: 0,
|
|
||||||
align: 'start',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
} as TanStackTableHandle,
|
|
||||||
{
|
|
||||||
get(target, prop): unknown {
|
|
||||||
if (prop in target) {
|
|
||||||
return Reflect.get(target, prop);
|
|
||||||
}
|
|
||||||
const v = (virtuosoRef.current as unknown) as Record<string, unknown>;
|
|
||||||
const value = v?.[prop as string];
|
|
||||||
if (typeof value === 'function') {
|
|
||||||
return (value as (...a: unknown[]) => unknown).bind(virtuosoRef.current);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
[setPage],
|
|
||||||
);
|
|
||||||
|
|
||||||
const showPagination = Boolean(pagination && !onEndReached);
|
|
||||||
|
|
||||||
const { className: tableScrollerClassName, ...restTableScrollerProps } =
|
|
||||||
tableScrollerProps ?? {};
|
|
||||||
|
|
||||||
const cellTypographyClass = useMemo((): string | undefined => {
|
|
||||||
if (cellTypographySize === 'small') {
|
|
||||||
return viewStyles.cellTypographySmall;
|
|
||||||
}
|
|
||||||
if (cellTypographySize === 'medium') {
|
|
||||||
return viewStyles.cellTypographyMedium;
|
|
||||||
}
|
|
||||||
if (cellTypographySize === 'large') {
|
|
||||||
return viewStyles.cellTypographyLarge;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}, [cellTypographySize]);
|
|
||||||
|
|
||||||
const virtuosoClassName = useMemo(
|
|
||||||
() =>
|
|
||||||
cx(
|
|
||||||
viewStyles.tanstackTableVirtuosoScroll,
|
|
||||||
cellTypographyClass,
|
|
||||||
tableScrollerClassName,
|
|
||||||
),
|
|
||||||
[cellTypographyClass, tableScrollerClassName],
|
|
||||||
);
|
|
||||||
|
|
||||||
const virtuosoTableStyle = useMemo(
|
|
||||||
() =>
|
|
||||||
({
|
|
||||||
'--tanstack-plain-body-line-clamp': plainTextCellLineClamp,
|
|
||||||
} as CSSProperties),
|
|
||||||
[plainTextCellLineClamp],
|
|
||||||
);
|
|
||||||
|
|
||||||
type VirtuosoTableComponentProps = ComponentProps<
|
|
||||||
NonNullable<TableComponents<FlatItem<TData>, TableRowContext<TData>>['Table']>
|
|
||||||
>;
|
|
||||||
|
|
||||||
// Use refs in virtuosoComponents to keep the component reference stable during resize
|
|
||||||
// This prevents Virtuoso from re-rendering all rows when columns are resized
|
|
||||||
const virtuosoComponents = useMemo(
|
|
||||||
() => ({
|
|
||||||
Table: ({ style, children }: VirtuosoTableComponentProps): JSX.Element => (
|
|
||||||
<table className={tableStyles.tanStackTable} style={style}>
|
|
||||||
<VirtuosoTableColGroup
|
|
||||||
columns={columnsRef.current}
|
|
||||||
table={tableRef.current}
|
|
||||||
/>
|
|
||||||
{children}
|
|
||||||
</table>
|
|
||||||
),
|
|
||||||
TableRow: TanStackCustomTableRow,
|
|
||||||
}),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cx(viewStyles.tanstackTableViewWrapper, className)}>
|
|
||||||
<TanStackTableStateProvider>
|
|
||||||
<TableLoadingSync
|
|
||||||
isLoading={isLoading}
|
|
||||||
isInfiniteScrollMode={isInfiniteScrollMode}
|
|
||||||
/>
|
|
||||||
<ColumnVisibilitySync visibility={effectiveVisibility} />
|
|
||||||
<TooltipProvider>
|
|
||||||
<TableVirtuoso<FlatItem<TData>, TableRowContext<TData>>
|
|
||||||
className={virtuosoClassName}
|
|
||||||
ref={virtuosoRef}
|
|
||||||
{...restTableScrollerProps}
|
|
||||||
data={flatItems}
|
|
||||||
totalCount={flatItems.length}
|
|
||||||
context={virtuosoContext}
|
|
||||||
increaseViewportBy={INCREASE_VIEWPORT_BY}
|
|
||||||
initialTopMostItemIndex={
|
|
||||||
flatIndexForActiveRow >= 0 ? flatIndexForActiveRow : 0
|
|
||||||
}
|
|
||||||
fixedHeaderContent={tableHeader}
|
|
||||||
style={virtuosoTableStyle}
|
|
||||||
components={virtuosoComponents}
|
|
||||||
endReached={onEndReached ? handleEndReached : undefined}
|
|
||||||
data-testid={testId}
|
|
||||||
/>
|
|
||||||
{showInfiniteScrollLoader && (
|
|
||||||
<div
|
|
||||||
className={viewStyles.tanstackLoadingOverlay}
|
|
||||||
data-testid="tanstack-infinite-loader"
|
|
||||||
>
|
|
||||||
<Spin indicator={<LoadingOutlined spin />} tip="Loading more..." />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{showPagination && pagination && (
|
|
||||||
<div className={viewStyles.paginationContainer}>
|
|
||||||
{prefixPaginationContent}
|
|
||||||
<Pagination
|
|
||||||
current={page}
|
|
||||||
pageSize={limit}
|
|
||||||
total={effectiveTotalCount}
|
|
||||||
onPageChange={(p): void => {
|
|
||||||
setPage(p);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className={viewStyles.paginationPageSize}>
|
|
||||||
<ComboboxSimple
|
|
||||||
value={limit?.toString()}
|
|
||||||
defaultValue="10"
|
|
||||||
onChange={(value): void => setLimit(+value)}
|
|
||||||
items={paginationPageSizeItems}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{suffixPaginationContent}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TooltipProvider>
|
|
||||||
</TanStackTableStateProvider>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const TanStackTableForward = forwardRef(TanStackTableInner) as <TData>(
|
|
||||||
props: TanStackTableProps<TData> & {
|
|
||||||
ref?: React.Ref<TanStackTableHandle>;
|
|
||||||
},
|
|
||||||
) => JSX.Element;
|
|
||||||
|
|
||||||
export const TanStackTableBase = memo(
|
|
||||||
TanStackTableForward,
|
|
||||||
) as typeof TanStackTableForward;
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
.headerSkeleton {
|
|
||||||
width: 60% !important;
|
|
||||||
min-width: 50px !important;
|
|
||||||
height: 16px !important;
|
|
||||||
|
|
||||||
:global(.ant-skeleton-input) {
|
|
||||||
min-width: 50px !important;
|
|
||||||
height: 16px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cellSkeleton {
|
|
||||||
width: 80% !important;
|
|
||||||
min-width: 40px !important;
|
|
||||||
height: 14px !important;
|
|
||||||
|
|
||||||
:global(.ant-skeleton-input) {
|
|
||||||
min-width: 40px !important;
|
|
||||||
height: 14px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
import type { ColumnSizingState } from '@tanstack/react-table';
|
|
||||||
import { Skeleton } from 'antd';
|
|
||||||
|
|
||||||
import { TableColumnDef } from './types';
|
|
||||||
import { getColumnWidthStyle } from './utils';
|
|
||||||
|
|
||||||
import tableStyles from './TanStackTable.module.scss';
|
|
||||||
import styles from './TanStackTableSkeleton.module.scss';
|
|
||||||
|
|
||||||
type TanStackTableSkeletonProps<TData> = {
|
|
||||||
columns: TableColumnDef<TData>[];
|
|
||||||
rowCount: number;
|
|
||||||
isDarkMode: boolean;
|
|
||||||
columnSizing?: ColumnSizingState;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function TanStackTableSkeleton<TData>({
|
|
||||||
columns,
|
|
||||||
rowCount,
|
|
||||||
isDarkMode,
|
|
||||||
columnSizing,
|
|
||||||
}: TanStackTableSkeletonProps<TData>): JSX.Element {
|
|
||||||
const rows = useMemo(() => Array.from({ length: rowCount }, (_, i) => i), [
|
|
||||||
rowCount,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<table className={tableStyles.tanStackTable}>
|
|
||||||
<colgroup>
|
|
||||||
{columns.map((column, index) => (
|
|
||||||
<col
|
|
||||||
key={column.id}
|
|
||||||
style={getColumnWidthStyle(
|
|
||||||
column,
|
|
||||||
columnSizing?.[column.id],
|
|
||||||
index === columns.length - 1,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</colgroup>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
{columns.map((column) => (
|
|
||||||
<th
|
|
||||||
key={column.id}
|
|
||||||
className={tableStyles.tableHeaderCell}
|
|
||||||
data-dark-mode={isDarkMode}
|
|
||||||
>
|
|
||||||
{typeof column.header === 'function' ? (
|
|
||||||
<Skeleton.Input active size="small" className={styles.headerSkeleton} />
|
|
||||||
) : (
|
|
||||||
column.header
|
|
||||||
)}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rows.map((rowIndex) => (
|
|
||||||
<tr key={rowIndex} className={tableStyles.tableRow}>
|
|
||||||
{columns.map((column) => (
|
|
||||||
<td key={column.id} className={tableStyles.tableCell}>
|
|
||||||
<Skeleton.Input active size="small" className={styles.cellSkeleton} />
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
/* eslint-disable no-restricted-imports */
|
|
||||||
import {
|
|
||||||
createContext,
|
|
||||||
ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
} from 'react';
|
|
||||||
/* eslint-enable no-restricted-imports */
|
|
||||||
import { VisibilityState } from '@tanstack/react-table';
|
|
||||||
import { createStore, StoreApi, useStore } from 'zustand';
|
|
||||||
|
|
||||||
const CLEAR_HOVER_DELAY_MS = 100;
|
|
||||||
|
|
||||||
type TanStackTableState = {
|
|
||||||
hoveredRowId: string | null;
|
|
||||||
clearTimeoutId: ReturnType<typeof setTimeout> | null;
|
|
||||||
setHoveredRowId: (id: string | null) => void;
|
|
||||||
scheduleClearHover: (rowId: string) => void;
|
|
||||||
isLoading: boolean;
|
|
||||||
setIsLoading: (loading: boolean) => void;
|
|
||||||
isInfiniteScrollMode: boolean;
|
|
||||||
setIsInfiniteScrollMode: (enabled: boolean) => void;
|
|
||||||
columnVisibility: VisibilityState;
|
|
||||||
setColumnVisibility: (visibility: VisibilityState) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createTableStateStore = (): StoreApi<TanStackTableState> =>
|
|
||||||
createStore<TanStackTableState>((set, get) => ({
|
|
||||||
hoveredRowId: null,
|
|
||||||
clearTimeoutId: null,
|
|
||||||
setHoveredRowId: (id: string | null): void => {
|
|
||||||
const { clearTimeoutId } = get();
|
|
||||||
if (clearTimeoutId) {
|
|
||||||
clearTimeout(clearTimeoutId);
|
|
||||||
set({ clearTimeoutId: null });
|
|
||||||
}
|
|
||||||
set({ hoveredRowId: id });
|
|
||||||
},
|
|
||||||
scheduleClearHover: (rowId: string): void => {
|
|
||||||
const { clearTimeoutId } = get();
|
|
||||||
if (clearTimeoutId) {
|
|
||||||
clearTimeout(clearTimeoutId);
|
|
||||||
}
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
const current = get().hoveredRowId;
|
|
||||||
if (current === rowId) {
|
|
||||||
set({ hoveredRowId: null, clearTimeoutId: null });
|
|
||||||
}
|
|
||||||
}, CLEAR_HOVER_DELAY_MS);
|
|
||||||
set({ clearTimeoutId: timeoutId });
|
|
||||||
},
|
|
||||||
isLoading: false,
|
|
||||||
setIsLoading: (loading: boolean): void => {
|
|
||||||
set({ isLoading: loading });
|
|
||||||
},
|
|
||||||
isInfiniteScrollMode: false,
|
|
||||||
setIsInfiniteScrollMode: (enabled: boolean): void => {
|
|
||||||
set({ isInfiniteScrollMode: enabled });
|
|
||||||
},
|
|
||||||
columnVisibility: {},
|
|
||||||
setColumnVisibility: (visibility: VisibilityState): void => {
|
|
||||||
set({ columnVisibility: visibility });
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
type TableStateStore = StoreApi<TanStackTableState>;
|
|
||||||
|
|
||||||
const TanStackTableStateContext = createContext<TableStateStore | null>(null);
|
|
||||||
|
|
||||||
export function TanStackTableStateProvider({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
}): JSX.Element {
|
|
||||||
const storeRef = useRef<TableStateStore | null>(null);
|
|
||||||
if (!storeRef.current) {
|
|
||||||
storeRef.current = createTableStateStore();
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<TanStackTableStateContext.Provider value={storeRef.current}>
|
|
||||||
{children}
|
|
||||||
</TanStackTableStateContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultStore = createTableStateStore();
|
|
||||||
|
|
||||||
export const useIsRowHovered = (rowId: string): boolean => {
|
|
||||||
const store = useContext(TanStackTableStateContext);
|
|
||||||
const isHovered = useStore(
|
|
||||||
store ?? defaultStore,
|
|
||||||
(s) => s.hoveredRowId === rowId,
|
|
||||||
);
|
|
||||||
return store ? isHovered : false;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useSetRowHovered = (rowId: string): (() => void) => {
|
|
||||||
const store = useContext(TanStackTableStateContext);
|
|
||||||
return useCallback(() => {
|
|
||||||
if (store) {
|
|
||||||
const current = store.getState().hoveredRowId;
|
|
||||||
if (current !== rowId) {
|
|
||||||
store.getState().setHoveredRowId(rowId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [store, rowId]);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useClearRowHovered = (rowId: string): (() => void) => {
|
|
||||||
const store = useContext(TanStackTableStateContext);
|
|
||||||
return useCallback(() => {
|
|
||||||
if (store) {
|
|
||||||
store.getState().scheduleClearHover(rowId);
|
|
||||||
}
|
|
||||||
}, [store, rowId]);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useIsTableLoading = (): boolean => {
|
|
||||||
const store = useContext(TanStackTableStateContext);
|
|
||||||
return useStore(store ?? defaultStore, (s) => s.isLoading);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useSetTableLoading = (): ((loading: boolean) => void) => {
|
|
||||||
const store = useContext(TanStackTableStateContext);
|
|
||||||
return useCallback(
|
|
||||||
(loading: boolean) => {
|
|
||||||
if (store) {
|
|
||||||
store.getState().setIsLoading(loading);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[store],
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function TableLoadingSync({
|
|
||||||
isLoading,
|
|
||||||
isInfiniteScrollMode,
|
|
||||||
}: {
|
|
||||||
isLoading: boolean;
|
|
||||||
isInfiniteScrollMode: boolean;
|
|
||||||
}): null {
|
|
||||||
const store = useContext(TanStackTableStateContext);
|
|
||||||
|
|
||||||
// Sync on mount and when props change
|
|
||||||
useEffect(() => {
|
|
||||||
if (store) {
|
|
||||||
store.getState().setIsLoading(isLoading);
|
|
||||||
store.getState().setIsInfiniteScrollMode(isInfiniteScrollMode);
|
|
||||||
}
|
|
||||||
}, [isLoading, isInfiniteScrollMode, store]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useShouldShowCellSkeleton = (): boolean => {
|
|
||||||
const store = useContext(TanStackTableStateContext);
|
|
||||||
return useStore(
|
|
||||||
store ?? defaultStore,
|
|
||||||
(s) => s.isLoading && !s.isInfiniteScrollMode,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useColumnVisibility = (): VisibilityState => {
|
|
||||||
const store = useContext(TanStackTableStateContext);
|
|
||||||
return useStore(store ?? defaultStore, (s) => s.columnVisibility);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useIsColumnVisible = (columnId: string): boolean => {
|
|
||||||
const store = useContext(TanStackTableStateContext);
|
|
||||||
return useStore(
|
|
||||||
store ?? defaultStore,
|
|
||||||
(s) => s.columnVisibility[columnId] !== false,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useSetColumnVisibility = (): ((
|
|
||||||
visibility: VisibilityState,
|
|
||||||
) => void) => {
|
|
||||||
const store = useContext(TanStackTableStateContext);
|
|
||||||
return useCallback(
|
|
||||||
(visibility: VisibilityState) => {
|
|
||||||
if (store) {
|
|
||||||
store.getState().setColumnVisibility(visibility);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[store],
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ColumnVisibilitySync({
|
|
||||||
visibility,
|
|
||||||
}: {
|
|
||||||
visibility: VisibilityState;
|
|
||||||
}): null {
|
|
||||||
const setVisibility = useSetColumnVisibility();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setVisibility(visibility);
|
|
||||||
}, [visibility, setVisibility]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default TanStackTableStateContext;
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import type { HTMLAttributes, ReactNode } from 'react';
|
|
||||||
import cx from 'classnames';
|
|
||||||
|
|
||||||
import tableStyles from './TanStackTable.module.scss';
|
|
||||||
|
|
||||||
type BaseProps = Omit<
|
|
||||||
HTMLAttributes<HTMLSpanElement>,
|
|
||||||
'children' | 'dangerouslySetInnerHTML'
|
|
||||||
> & {
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type WithChildren = BaseProps & {
|
|
||||||
children: ReactNode;
|
|
||||||
dangerouslySetInnerHTML?: never;
|
|
||||||
};
|
|
||||||
|
|
||||||
type WithDangerousHtml = BaseProps & {
|
|
||||||
children?: never;
|
|
||||||
dangerouslySetInnerHTML: { __html: string };
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TanStackTableTextProps = WithChildren | WithDangerousHtml;
|
|
||||||
|
|
||||||
function TanStackTableText({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
dangerouslySetInnerHTML,
|
|
||||||
...rest
|
|
||||||
}: TanStackTableTextProps): JSX.Element {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cx(tableStyles.tableCellText, className)}
|
|
||||||
dangerouslySetInnerHTML={dangerouslySetInnerHTML}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default TanStackTableText;
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
.tanstackTableViewWrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex: 1;
|
|
||||||
width: 100%;
|
|
||||||
position: relative;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tanstackFixedCol {
|
|
||||||
width: 32px;
|
|
||||||
min-width: 32px;
|
|
||||||
max-width: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tanstackFillerCol {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tanstackActionsCol {
|
|
||||||
width: 0;
|
|
||||||
min-width: 0;
|
|
||||||
max-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tanstackLoadMoreContainer {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 56px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 8px 0 12px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tanstackTableVirtuoso {
|
|
||||||
width: 100%;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tanstackTableFootLoaderCell {
|
|
||||||
text-align: center;
|
|
||||||
padding: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tanstackTableVirtuosoScroll {
|
|
||||||
flex: 1;
|
|
||||||
width: 100%;
|
|
||||||
overflow-x: auto;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--bg-slate-300) transparent;
|
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
width: 4px;
|
|
||||||
height: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-corner {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--bg-slate-300);
|
|
||||||
border-radius: 9999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--bg-slate-200);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.cellTypographySmall {
|
|
||||||
--tanstack-plain-cell-font-size: 11px;
|
|
||||||
--tanstack-plain-cell-line-height: 16px;
|
|
||||||
|
|
||||||
:global(table tr td),
|
|
||||||
:global(table thead th) {
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 16px;
|
|
||||||
letter-spacing: -0.07px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.cellTypographyMedium {
|
|
||||||
--tanstack-plain-cell-font-size: 13px;
|
|
||||||
--tanstack-plain-cell-line-height: 20px;
|
|
||||||
|
|
||||||
:global(table tr td),
|
|
||||||
:global(table thead th) {
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 20px;
|
|
||||||
letter-spacing: -0.07px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.cellTypographyLarge {
|
|
||||||
--tanstack-plain-cell-font-size: 14px;
|
|
||||||
--tanstack-plain-cell-line-height: 24px;
|
|
||||||
|
|
||||||
:global(table tr td),
|
|
||||||
:global(table thead th) {
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 24px;
|
|
||||||
letter-spacing: -0.07px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.paginationContainer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.paginationPageSize {
|
|
||||||
width: 80px;
|
|
||||||
--combobox-trigger-height: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tanstackLoadingOverlay {
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
bottom: 2rem;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 3;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: var(--l1-background);
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.lightMode) .tanstackTableVirtuosoScroll {
|
|
||||||
scrollbar-color: var(--bg-vanilla-300) transparent;
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--bg-vanilla-300);
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--bg-vanilla-100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import type { Table } from '@tanstack/react-table';
|
|
||||||
|
|
||||||
import type { TableColumnDef } from './types';
|
|
||||||
import { getColumnWidthStyle } from './utils';
|
|
||||||
|
|
||||||
export function VirtuosoTableColGroup<TData>({
|
|
||||||
columns,
|
|
||||||
table,
|
|
||||||
}: {
|
|
||||||
columns: TableColumnDef<TData>[];
|
|
||||||
table: Table<TData>;
|
|
||||||
}): JSX.Element {
|
|
||||||
const visibleTanstackColumns = table.getVisibleFlatColumns();
|
|
||||||
const columnDefsById = new Map(columns.map((c) => [c.id, c]));
|
|
||||||
const columnSizing = table.getState().columnSizing;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<colgroup>
|
|
||||||
{visibleTanstackColumns.map((tanstackCol, index) => {
|
|
||||||
const colDef = columnDefsById.get(tanstackCol.id);
|
|
||||||
if (!colDef) {
|
|
||||||
return <col key={tanstackCol.id} />;
|
|
||||||
}
|
|
||||||
const persistedWidth = columnSizing[tanstackCol.id];
|
|
||||||
const isLastColumn = index === visibleTanstackColumns.length - 1;
|
|
||||||
return (
|
|
||||||
<col
|
|
||||||
key={tanstackCol.id}
|
|
||||||
style={getColumnWidthStyle(colDef, persistedWidth, isLastColumn)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</colgroup>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
jest.mock('../TanStackTable.module.scss', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: {
|
|
||||||
tableRow: 'tableRow',
|
|
||||||
tableRowActive: 'tableRowActive',
|
|
||||||
tableRowExpansion: 'tableRowExpansion',
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('../TanStackRow', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: (): JSX.Element => (
|
|
||||||
<td data-testid="mocked-row-cells">mocked cells</td>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockSetRowHovered = jest.fn();
|
|
||||||
const mockClearRowHovered = jest.fn();
|
|
||||||
|
|
||||||
jest.mock('../TanStackTableStateContext', () => ({
|
|
||||||
useSetRowHovered: (_rowId: string): (() => void) => mockSetRowHovered,
|
|
||||||
useClearRowHovered: (_rowId: string): (() => void) => mockClearRowHovered,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
|
||||||
|
|
||||||
import TanStackCustomTableRow from '../TanStackCustomTableRow';
|
|
||||||
import type { FlatItem, TableRowContext } from '../types';
|
|
||||||
|
|
||||||
const makeItem = (id: string): FlatItem<{ id: string }> => ({
|
|
||||||
kind: 'row',
|
|
||||||
row: { original: { id }, id } as never,
|
|
||||||
});
|
|
||||||
|
|
||||||
const virtuosoAttrs = {
|
|
||||||
'data-index': 0,
|
|
||||||
'data-item-index': 0,
|
|
||||||
'data-known-size': 40,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const baseContext: TableRowContext<{ id: string }> = {
|
|
||||||
colCount: 1,
|
|
||||||
hasSingleColumn: false,
|
|
||||||
columnOrderKey: 'col1',
|
|
||||||
columnVisibilityKey: 'col1',
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('TanStackCustomTableRow', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockSetRowHovered.mockClear();
|
|
||||||
mockClearRowHovered.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders cells via TanStackRowCells', async () => {
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<TanStackCustomTableRow
|
|
||||||
{...virtuosoAttrs}
|
|
||||||
item={makeItem('1')}
|
|
||||||
context={baseContext}
|
|
||||||
/>
|
|
||||||
</tbody>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
expect(await screen.findByTestId('mocked-row-cells')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies active class when isRowActive returns true', () => {
|
|
||||||
const ctx: TableRowContext<{ id: string }> = {
|
|
||||||
...baseContext,
|
|
||||||
isRowActive: (row) => row.id === '1',
|
|
||||||
};
|
|
||||||
const { container } = render(
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<TanStackCustomTableRow
|
|
||||||
{...virtuosoAttrs}
|
|
||||||
item={makeItem('1')}
|
|
||||||
context={ctx}
|
|
||||||
/>
|
|
||||||
</tbody>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
expect(container.querySelector('tr')).toHaveClass('tableRowActive');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not apply active class when isRowActive returns false', () => {
|
|
||||||
const ctx: TableRowContext<{ id: string }> = {
|
|
||||||
...baseContext,
|
|
||||||
isRowActive: (row) => row.id === 'other',
|
|
||||||
};
|
|
||||||
const { container } = render(
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<TanStackCustomTableRow
|
|
||||||
{...virtuosoAttrs}
|
|
||||||
item={makeItem('1')}
|
|
||||||
context={ctx}
|
|
||||||
/>
|
|
||||||
</tbody>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
expect(container.querySelector('tr')).not.toHaveClass('tableRowActive');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders expansion row with expansion class', () => {
|
|
||||||
const item: FlatItem<{ id: string }> = {
|
|
||||||
kind: 'expansion',
|
|
||||||
row: { original: { id: '1' }, id: '1' } as never,
|
|
||||||
};
|
|
||||||
const { container } = render(
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<TanStackCustomTableRow
|
|
||||||
{...virtuosoAttrs}
|
|
||||||
item={item}
|
|
||||||
context={baseContext}
|
|
||||||
/>
|
|
||||||
</tbody>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
expect(container.querySelector('tr')).toHaveClass('tableRowExpansion');
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('hover state management', () => {
|
|
||||||
it('calls setRowHovered on mouse enter', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<TanStackCustomTableRow
|
|
||||||
{...virtuosoAttrs}
|
|
||||||
item={makeItem('1')}
|
|
||||||
context={baseContext}
|
|
||||||
/>
|
|
||||||
</tbody>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
const row = container.querySelector('tr')!;
|
|
||||||
fireEvent.mouseEnter(row);
|
|
||||||
expect(mockSetRowHovered).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls clearRowHovered on mouse leave', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<TanStackCustomTableRow
|
|
||||||
{...virtuosoAttrs}
|
|
||||||
item={makeItem('1')}
|
|
||||||
context={baseContext}
|
|
||||||
/>
|
|
||||||
</tbody>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
const row = container.querySelector('tr')!;
|
|
||||||
fireEvent.mouseLeave(row);
|
|
||||||
expect(mockClearRowHovered).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('virtuoso integration', () => {
|
|
||||||
it('forwards data-index attribute to tr element', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<TanStackCustomTableRow
|
|
||||||
{...virtuosoAttrs}
|
|
||||||
item={makeItem('1')}
|
|
||||||
context={baseContext}
|
|
||||||
/>
|
|
||||||
</tbody>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
const row = container.querySelector('tr')!;
|
|
||||||
expect(row).toHaveAttribute('data-index', '0');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('forwards data-item-index attribute to tr element', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<TanStackCustomTableRow
|
|
||||||
{...virtuosoAttrs}
|
|
||||||
item={makeItem('1')}
|
|
||||||
context={baseContext}
|
|
||||||
/>
|
|
||||||
</tbody>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
const row = container.querySelector('tr')!;
|
|
||||||
expect(row).toHaveAttribute('data-item-index', '0');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('forwards data-known-size attribute to tr element', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<TanStackCustomTableRow
|
|
||||||
{...virtuosoAttrs}
|
|
||||||
item={makeItem('1')}
|
|
||||||
context={baseContext}
|
|
||||||
/>
|
|
||||||
</tbody>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
const row = container.querySelector('tr')!;
|
|
||||||
expect(row).toHaveAttribute('data-known-size', '40');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('row interaction', () => {
|
|
||||||
it('applies custom style from getRowStyle in context', () => {
|
|
||||||
const ctx: TableRowContext<{ id: string }> = {
|
|
||||||
...baseContext,
|
|
||||||
getRowStyle: () => ({ backgroundColor: 'red' }),
|
|
||||||
};
|
|
||||||
const { container } = render(
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<TanStackCustomTableRow
|
|
||||||
{...virtuosoAttrs}
|
|
||||||
item={makeItem('1')}
|
|
||||||
context={ctx}
|
|
||||||
/>
|
|
||||||
</tbody>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
const row = container.querySelector('tr')!;
|
|
||||||
expect(row).toHaveStyle({ backgroundColor: 'red' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies custom className from getRowClassName in context', () => {
|
|
||||||
const ctx: TableRowContext<{ id: string }> = {
|
|
||||||
...baseContext,
|
|
||||||
getRowClassName: () => 'custom-row-class',
|
|
||||||
};
|
|
||||||
const { container } = render(
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<TanStackCustomTableRow
|
|
||||||
{...virtuosoAttrs}
|
|
||||||
item={makeItem('1')}
|
|
||||||
context={ctx}
|
|
||||||
/>
|
|
||||||
</tbody>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
const row = container.querySelector('tr')!;
|
|
||||||
expect(row).toHaveClass('custom-row-class');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,368 +0,0 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
|
|
||||||
import TanStackHeaderRow from '../TanStackHeaderRow';
|
|
||||||
import type { TableColumnDef } from '../types';
|
|
||||||
|
|
||||||
jest.mock('@dnd-kit/sortable', () => ({
|
|
||||||
useSortable: (): any => ({
|
|
||||||
attributes: {},
|
|
||||||
listeners: {},
|
|
||||||
setNodeRef: jest.fn(),
|
|
||||||
setActivatorNodeRef: jest.fn(),
|
|
||||||
transform: null,
|
|
||||||
transition: null,
|
|
||||||
isDragging: false,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const col = (
|
|
||||||
id: string,
|
|
||||||
overrides?: Partial<TableColumnDef<unknown>>,
|
|
||||||
): TableColumnDef<unknown> => ({
|
|
||||||
id,
|
|
||||||
header: id,
|
|
||||||
cell: (): null => null,
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
const header = {
|
|
||||||
id: 'col',
|
|
||||||
column: {
|
|
||||||
getCanResize: () => true,
|
|
||||||
getIsResizing: () => false,
|
|
||||||
columnDef: { header: 'col' },
|
|
||||||
},
|
|
||||||
getResizeHandler: () => jest.fn(),
|
|
||||||
getContext: () => ({}),
|
|
||||||
} as never;
|
|
||||||
|
|
||||||
describe('TanStackHeaderRow', () => {
|
|
||||||
it('renders column title', () => {
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<TanStackHeaderRow
|
|
||||||
column={col('timestamp', { header: 'timestamp' })}
|
|
||||||
header={header}
|
|
||||||
isDarkMode={false}
|
|
||||||
hasSingleColumn={false}
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
expect(screen.getByTitle('Timestamp')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows grip icon when enableMove is not false and pin is not set', () => {
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<TanStackHeaderRow
|
|
||||||
column={col('body')}
|
|
||||||
header={header}
|
|
||||||
isDarkMode={false}
|
|
||||||
hasSingleColumn={false}
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
screen.getByRole('button', { name: /drag body/i }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does NOT show grip icon when pin is set', () => {
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<TanStackHeaderRow
|
|
||||||
column={col('indicator', { pin: 'left' })}
|
|
||||||
header={header}
|
|
||||||
isDarkMode={false}
|
|
||||||
hasSingleColumn={false}
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
screen.queryByRole('button', { name: /drag/i }),
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows remove button when enableRemove and canRemoveColumn are true', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const onRemoveColumn = jest.fn();
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<TanStackHeaderRow
|
|
||||||
column={col('name', { enableRemove: true })}
|
|
||||||
header={header}
|
|
||||||
isDarkMode={false}
|
|
||||||
hasSingleColumn={false}
|
|
||||||
canRemoveColumn
|
|
||||||
onRemoveColumn={onRemoveColumn}
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
await user.click(screen.getByRole('button', { name: /column actions/i }));
|
|
||||||
await user.click(await screen.findByText(/remove column/i));
|
|
||||||
expect(onRemoveColumn).toHaveBeenCalledWith('name');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does NOT show remove button when enableRemove is absent', () => {
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<TanStackHeaderRow
|
|
||||||
column={col('name')}
|
|
||||||
header={header}
|
|
||||||
isDarkMode={false}
|
|
||||||
hasSingleColumn={false}
|
|
||||||
canRemoveColumn
|
|
||||||
onRemoveColumn={jest.fn()}
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
screen.queryByRole('button', { name: /column actions/i }),
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('sorting', () => {
|
|
||||||
const sortableCol = col('sortable', { enableSort: true, header: 'Sortable' });
|
|
||||||
const sortableHeader = {
|
|
||||||
id: 'sortable',
|
|
||||||
column: {
|
|
||||||
id: 'sortable',
|
|
||||||
getCanResize: (): boolean => true,
|
|
||||||
getIsResizing: (): boolean => false,
|
|
||||||
columnDef: { header: 'Sortable', enableSort: true },
|
|
||||||
},
|
|
||||||
getResizeHandler: (): jest.Mock => jest.fn(),
|
|
||||||
getContext: (): Record<string, unknown> => ({}),
|
|
||||||
} as never;
|
|
||||||
|
|
||||||
it('calls onSort with asc when clicking unsorted column', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const onSort = jest.fn();
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<TanStackHeaderRow
|
|
||||||
column={sortableCol}
|
|
||||||
header={sortableHeader}
|
|
||||||
isDarkMode={false}
|
|
||||||
hasSingleColumn={false}
|
|
||||||
onSort={onSort}
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
// Sort button uses the column header as title
|
|
||||||
const sortButton = screen.getByTitle('Sortable');
|
|
||||||
await user.click(sortButton);
|
|
||||||
expect(onSort).toHaveBeenCalledWith({
|
|
||||||
columnName: 'sortable',
|
|
||||||
order: 'asc',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls onSort with desc when clicking asc-sorted column', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const onSort = jest.fn();
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<TanStackHeaderRow
|
|
||||||
column={sortableCol}
|
|
||||||
header={sortableHeader}
|
|
||||||
isDarkMode={false}
|
|
||||||
hasSingleColumn={false}
|
|
||||||
onSort={onSort}
|
|
||||||
orderBy={{ columnName: 'sortable', order: 'asc' }}
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
const sortButton = screen.getByTitle('Sortable');
|
|
||||||
await user.click(sortButton);
|
|
||||||
expect(onSort).toHaveBeenCalledWith({
|
|
||||||
columnName: 'sortable',
|
|
||||||
order: 'desc',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls onSort with null when clicking desc-sorted column', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const onSort = jest.fn();
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<TanStackHeaderRow
|
|
||||||
column={sortableCol}
|
|
||||||
header={sortableHeader}
|
|
||||||
isDarkMode={false}
|
|
||||||
hasSingleColumn={false}
|
|
||||||
onSort={onSort}
|
|
||||||
orderBy={{ columnName: 'sortable', order: 'desc' }}
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
const sortButton = screen.getByTitle('Sortable');
|
|
||||||
await user.click(sortButton);
|
|
||||||
expect(onSort).toHaveBeenCalledWith(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows ascending indicator when orderBy matches column with asc', () => {
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<TanStackHeaderRow
|
|
||||||
column={sortableCol}
|
|
||||||
header={sortableHeader}
|
|
||||||
isDarkMode={false}
|
|
||||||
hasSingleColumn={false}
|
|
||||||
onSort={jest.fn()}
|
|
||||||
orderBy={{ columnName: 'sortable', order: 'asc' }}
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
const sortButton = screen.getByTitle('Sortable');
|
|
||||||
expect(sortButton).toHaveAttribute('aria-sort', 'ascending');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows descending indicator when orderBy matches column with desc', () => {
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<TanStackHeaderRow
|
|
||||||
column={sortableCol}
|
|
||||||
header={sortableHeader}
|
|
||||||
isDarkMode={false}
|
|
||||||
hasSingleColumn={false}
|
|
||||||
onSort={jest.fn()}
|
|
||||||
orderBy={{ columnName: 'sortable', order: 'desc' }}
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
const sortButton = screen.getByTitle('Sortable');
|
|
||||||
expect(sortButton).toHaveAttribute('aria-sort', 'descending');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not show sort button when enableSort is false', () => {
|
|
||||||
const nonSortableCol = col('nonsort', {
|
|
||||||
enableSort: false,
|
|
||||||
header: 'Nonsort',
|
|
||||||
});
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<TanStackHeaderRow
|
|
||||||
column={nonSortableCol}
|
|
||||||
header={header}
|
|
||||||
isDarkMode={false}
|
|
||||||
hasSingleColumn={false}
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
// When enableSort is false, the header text is rendered as a span, not a button
|
|
||||||
// The title 'Nonsort' exists on the span, not on a button
|
|
||||||
const titleElement = screen.getByTitle('Nonsort');
|
|
||||||
expect(titleElement.tagName.toLowerCase()).not.toBe('button');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('resizing', () => {
|
|
||||||
it('shows resize handle when enableResize is not false', () => {
|
|
||||||
const resizableCol = col('resizable', { enableResize: true });
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<TanStackHeaderRow
|
|
||||||
column={resizableCol}
|
|
||||||
header={header}
|
|
||||||
isDarkMode={false}
|
|
||||||
hasSingleColumn={false}
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
// Resize handle has title "Drag to resize column"
|
|
||||||
expect(screen.getByTitle('Drag to resize column')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hides resize handle when enableResize is false', () => {
|
|
||||||
const nonResizableCol = col('noresize', { enableResize: false });
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<TanStackHeaderRow
|
|
||||||
column={nonResizableCol}
|
|
||||||
header={header}
|
|
||||||
isDarkMode={false}
|
|
||||||
hasSingleColumn={false}
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
expect(screen.queryByTitle('Drag to resize column')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('column movement', () => {
|
|
||||||
it('does not show grip when enableMove is false', () => {
|
|
||||||
const noMoveCol = col('nomove', { enableMove: false });
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<TanStackHeaderRow
|
|
||||||
column={noMoveCol}
|
|
||||||
header={header}
|
|
||||||
isDarkMode={false}
|
|
||||||
hasSingleColumn={false}
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
screen.queryByRole('button', { name: /drag/i }),
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,288 +0,0 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
|
|
||||||
import TanStackRowCells from '../TanStackRow';
|
|
||||||
import type { TableRowContext } from '../types';
|
|
||||||
|
|
||||||
const flexRenderMock = jest.fn((def: unknown) =>
|
|
||||||
typeof def === 'function' ? def({}) : def,
|
|
||||||
);
|
|
||||||
jest.mock('@tanstack/react-table', () => ({
|
|
||||||
flexRender: (def: unknown, _ctx?: unknown): unknown => flexRenderMock(def),
|
|
||||||
}));
|
|
||||||
|
|
||||||
type Row = { id: string };
|
|
||||||
|
|
||||||
function buildMockRow(
|
|
||||||
cells: { id: string }[],
|
|
||||||
rowData: Row = { id: 'r1' },
|
|
||||||
): Parameters<typeof TanStackRowCells>[0]['row'] {
|
|
||||||
return {
|
|
||||||
original: rowData,
|
|
||||||
getVisibleCells: () =>
|
|
||||||
cells.map((c, i) => ({
|
|
||||||
id: `cell-${i}`,
|
|
||||||
column: {
|
|
||||||
id: c.id,
|
|
||||||
columnDef: { cell: (): string => `content-${c.id}` },
|
|
||||||
},
|
|
||||||
getContext: (): Record<string, unknown> => ({}),
|
|
||||||
getValue: (): string => `content-${c.id}`,
|
|
||||||
})),
|
|
||||||
} as never;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('TanStackRowCells', () => {
|
|
||||||
beforeEach(() => flexRenderMock.mockClear());
|
|
||||||
|
|
||||||
it('renders a cell per visible column', () => {
|
|
||||||
const row = buildMockRow([{ id: 'col-a' }, { id: 'col-b' }]);
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<TanStackRowCells<Row>
|
|
||||||
row={row as never}
|
|
||||||
context={undefined}
|
|
||||||
itemKind="row"
|
|
||||||
hasSingleColumn={false}
|
|
||||||
columnOrderKey=""
|
|
||||||
columnVisibilityKey=""
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
expect(screen.getAllByRole('cell')).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls onRowClick when a cell is clicked', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const onRowClick = jest.fn();
|
|
||||||
const ctx: TableRowContext<Row> = {
|
|
||||||
colCount: 1,
|
|
||||||
onRowClick,
|
|
||||||
hasSingleColumn: false,
|
|
||||||
columnOrderKey: '',
|
|
||||||
columnVisibilityKey: '',
|
|
||||||
};
|
|
||||||
const row = buildMockRow([{ id: 'body' }]);
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<TanStackRowCells<Row>
|
|
||||||
row={row as never}
|
|
||||||
context={ctx}
|
|
||||||
itemKind="row"
|
|
||||||
hasSingleColumn={false}
|
|
||||||
columnOrderKey=""
|
|
||||||
columnVisibilityKey=""
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
await user.click(screen.getAllByRole('cell')[0]);
|
|
||||||
// onRowClick receives (rowData, itemKey) - itemKey is empty when getRowKeyData not provided
|
|
||||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, '');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls onRowDeactivate instead of onRowClick when row is active', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const onRowClick = jest.fn();
|
|
||||||
const onRowDeactivate = jest.fn();
|
|
||||||
const ctx: TableRowContext<Row> = {
|
|
||||||
colCount: 1,
|
|
||||||
onRowClick,
|
|
||||||
onRowDeactivate,
|
|
||||||
isRowActive: () => true,
|
|
||||||
hasSingleColumn: false,
|
|
||||||
columnOrderKey: '',
|
|
||||||
columnVisibilityKey: '',
|
|
||||||
};
|
|
||||||
const row = buildMockRow([{ id: 'body' }]);
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<TanStackRowCells<Row>
|
|
||||||
row={row as never}
|
|
||||||
context={ctx}
|
|
||||||
itemKind="row"
|
|
||||||
hasSingleColumn={false}
|
|
||||||
columnOrderKey=""
|
|
||||||
columnVisibilityKey=""
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
await user.click(screen.getAllByRole('cell')[0]);
|
|
||||||
expect(onRowDeactivate).toHaveBeenCalled();
|
|
||||||
expect(onRowClick).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not render renderRowActions before hover', () => {
|
|
||||||
const ctx: TableRowContext<Row> = {
|
|
||||||
colCount: 1,
|
|
||||||
renderRowActions: () => <button type="button">action</button>,
|
|
||||||
hasSingleColumn: false,
|
|
||||||
columnOrderKey: '',
|
|
||||||
columnVisibilityKey: '',
|
|
||||||
};
|
|
||||||
const row = buildMockRow([{ id: 'body' }]);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<TanStackRowCells<Row>
|
|
||||||
row={row as never}
|
|
||||||
context={ctx}
|
|
||||||
itemKind="row"
|
|
||||||
hasSingleColumn={false}
|
|
||||||
columnOrderKey=""
|
|
||||||
columnVisibilityKey=""
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
// Row actions are not rendered until hover (useIsRowHovered returns false by default)
|
|
||||||
expect(
|
|
||||||
screen.queryByRole('button', { name: 'action' }),
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders expansion cell with renderExpandedRow content', async () => {
|
|
||||||
const row = {
|
|
||||||
original: { id: 'r1' },
|
|
||||||
getVisibleCells: () => [],
|
|
||||||
} as never;
|
|
||||||
const ctx: TableRowContext<Row> = {
|
|
||||||
colCount: 3,
|
|
||||||
renderExpandedRow: (r) => <div>expanded-{r.id}</div>,
|
|
||||||
hasSingleColumn: false,
|
|
||||||
columnOrderKey: '',
|
|
||||||
columnVisibilityKey: '',
|
|
||||||
};
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<TanStackRowCells<Row>
|
|
||||||
row={row as never}
|
|
||||||
context={ctx}
|
|
||||||
itemKind="expansion"
|
|
||||||
hasSingleColumn={false}
|
|
||||||
columnOrderKey=""
|
|
||||||
columnVisibilityKey=""
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
expect(await screen.findByText('expanded-r1')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('new tab click', () => {
|
|
||||||
it('calls onRowClickNewTab on ctrl+click', () => {
|
|
||||||
const onRowClick = jest.fn();
|
|
||||||
const onRowClickNewTab = jest.fn();
|
|
||||||
const ctx: TableRowContext<Row> = {
|
|
||||||
colCount: 1,
|
|
||||||
onRowClick,
|
|
||||||
onRowClickNewTab,
|
|
||||||
hasSingleColumn: false,
|
|
||||||
columnOrderKey: '',
|
|
||||||
columnVisibilityKey: '',
|
|
||||||
};
|
|
||||||
const row = buildMockRow([{ id: 'body' }]);
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<TanStackRowCells<Row>
|
|
||||||
row={row as never}
|
|
||||||
context={ctx}
|
|
||||||
itemKind="row"
|
|
||||||
hasSingleColumn={false}
|
|
||||||
columnOrderKey=""
|
|
||||||
columnVisibilityKey=""
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
fireEvent.click(screen.getAllByRole('cell')[0], { ctrlKey: true });
|
|
||||||
expect(onRowClickNewTab).toHaveBeenCalledWith({ id: 'r1' }, '');
|
|
||||||
expect(onRowClick).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls onRowClickNewTab on meta+click (cmd)', () => {
|
|
||||||
const onRowClick = jest.fn();
|
|
||||||
const onRowClickNewTab = jest.fn();
|
|
||||||
const ctx: TableRowContext<Row> = {
|
|
||||||
colCount: 1,
|
|
||||||
onRowClick,
|
|
||||||
onRowClickNewTab,
|
|
||||||
hasSingleColumn: false,
|
|
||||||
columnOrderKey: '',
|
|
||||||
columnVisibilityKey: '',
|
|
||||||
};
|
|
||||||
const row = buildMockRow([{ id: 'body' }]);
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<TanStackRowCells<Row>
|
|
||||||
row={row as never}
|
|
||||||
context={ctx}
|
|
||||||
itemKind="row"
|
|
||||||
hasSingleColumn={false}
|
|
||||||
columnOrderKey=""
|
|
||||||
columnVisibilityKey=""
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
fireEvent.click(screen.getAllByRole('cell')[0], { metaKey: true });
|
|
||||||
expect(onRowClickNewTab).toHaveBeenCalledWith({ id: 'r1' }, '');
|
|
||||||
expect(onRowClick).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not call onRowClick when modifier key is pressed', () => {
|
|
||||||
const onRowClick = jest.fn();
|
|
||||||
const onRowClickNewTab = jest.fn();
|
|
||||||
const ctx: TableRowContext<Row> = {
|
|
||||||
colCount: 1,
|
|
||||||
onRowClick,
|
|
||||||
onRowClickNewTab,
|
|
||||||
hasSingleColumn: false,
|
|
||||||
columnOrderKey: '',
|
|
||||||
columnVisibilityKey: '',
|
|
||||||
};
|
|
||||||
const row = buildMockRow([{ id: 'body' }]);
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<TanStackRowCells<Row>
|
|
||||||
row={row as never}
|
|
||||||
context={ctx}
|
|
||||||
itemKind="row"
|
|
||||||
hasSingleColumn={false}
|
|
||||||
columnOrderKey=""
|
|
||||||
columnVisibilityKey=""
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
fireEvent.click(screen.getAllByRole('cell')[0], { ctrlKey: true });
|
|
||||||
expect(onRowClick).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,440 +0,0 @@
|
|||||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import { UrlUpdateEvent } from 'nuqs/adapters/testing';
|
|
||||||
|
|
||||||
import { renderTanStackTable } from './testUtils';
|
|
||||||
|
|
||||||
jest.mock('hooks/useDarkMode', () => ({
|
|
||||||
useIsDarkMode: (): boolean => false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('../TanStackTable.module.scss', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: {
|
|
||||||
tanStackTable: 'tanStackTable',
|
|
||||||
tableRow: 'tableRow',
|
|
||||||
tableRowActive: 'tableRowActive',
|
|
||||||
tableRowExpansion: 'tableRowExpansion',
|
|
||||||
tableCell: 'tableCell',
|
|
||||||
tableCellExpansion: 'tableCellExpansion',
|
|
||||||
tableHeaderCell: 'tableHeaderCell',
|
|
||||||
tableCellText: 'tableCellText',
|
|
||||||
tableViewRowActions: 'tableViewRowActions',
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('TanStackTableView Integration', () => {
|
|
||||||
describe('rendering', () => {
|
|
||||||
it('renders all data rows', async () => {
|
|
||||||
renderTanStackTable({});
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Item 2')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Item 3')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders column headers', async () => {
|
|
||||||
renderTanStackTable({});
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('ID')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Name')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Value')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders empty state when data is empty and not loading', async () => {
|
|
||||||
renderTanStackTable({
|
|
||||||
props: { data: [], isLoading: false },
|
|
||||||
});
|
|
||||||
// Table should still render but with no data rows
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByText('Item 1')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders table structure when loading with no previous data', async () => {
|
|
||||||
renderTanStackTable({
|
|
||||||
props: { data: [], isLoading: true },
|
|
||||||
});
|
|
||||||
// Table should render with skeleton rows
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('loading states', () => {
|
|
||||||
it('keeps table mounted when loading with no data', () => {
|
|
||||||
renderTanStackTable({
|
|
||||||
props: { data: [], isLoading: true },
|
|
||||||
});
|
|
||||||
// Table should still be in the DOM for skeleton rows
|
|
||||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows loading spinner for infinite scroll when loading', () => {
|
|
||||||
renderTanStackTable({
|
|
||||||
props: { isLoading: true, onEndReached: jest.fn() },
|
|
||||||
});
|
|
||||||
expect(screen.getByTestId('tanstack-infinite-loader')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not show loading spinner for infinite scroll when not loading', () => {
|
|
||||||
renderTanStackTable({
|
|
||||||
props: { isLoading: false, onEndReached: jest.fn() },
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
screen.queryByTestId('tanstack-infinite-loader'),
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not show loading spinner when not in infinite scroll mode', () => {
|
|
||||||
renderTanStackTable({
|
|
||||||
props: { isLoading: true },
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
screen.queryByTestId('tanstack-infinite-loader'),
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('pagination', () => {
|
|
||||||
it('renders pagination when pagination prop is provided', async () => {
|
|
||||||
renderTanStackTable({
|
|
||||||
props: {
|
|
||||||
pagination: { total: 100, defaultPage: 1, defaultLimit: 10 },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await waitFor(() => {
|
|
||||||
// Look for pagination navigation or page number text
|
|
||||||
expect(screen.getByRole('navigation')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates page when clicking page number', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
|
||||||
|
|
||||||
renderTanStackTable({
|
|
||||||
props: {
|
|
||||||
pagination: { total: 100, defaultPage: 1, defaultLimit: 10 },
|
|
||||||
enableQueryParams: true,
|
|
||||||
},
|
|
||||||
onUrlUpdate,
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByRole('navigation')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Find page 2 button/link within pagination navigation
|
|
||||||
const nav = screen.getByRole('navigation');
|
|
||||||
const page2 = Array.from(nav.querySelectorAll('button')).find(
|
|
||||||
(btn) => btn.textContent?.trim() === '2',
|
|
||||||
);
|
|
||||||
if (!page2) {
|
|
||||||
throw new Error('Page 2 button not found in pagination');
|
|
||||||
}
|
|
||||||
await user.click(page2);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const lastPage = onUrlUpdate.mock.calls
|
|
||||||
.map((call) => call[0].searchParams.get('page'))
|
|
||||||
.filter(Boolean)
|
|
||||||
.pop();
|
|
||||||
expect(lastPage).toBe('2');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not render pagination in infinite scroll mode', async () => {
|
|
||||||
renderTanStackTable({
|
|
||||||
props: {
|
|
||||||
pagination: { total: 100 },
|
|
||||||
onEndReached: jest.fn(), // This enables infinite scroll mode
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pagination should not be visible in infinite scroll mode
|
|
||||||
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders prefixPaginationContent before pagination', async () => {
|
|
||||||
renderTanStackTable({
|
|
||||||
props: {
|
|
||||||
pagination: { total: 100 },
|
|
||||||
prefixPaginationContent: <span data-testid="prefix-content">Prefix</span>,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId('prefix-content')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders suffixPaginationContent after pagination', async () => {
|
|
||||||
renderTanStackTable({
|
|
||||||
props: {
|
|
||||||
pagination: { total: 100 },
|
|
||||||
suffixPaginationContent: <span data-testid="suffix-content">Suffix</span>,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId('suffix-content')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('sorting', () => {
|
|
||||||
it('updates orderBy URL param when clicking sortable header', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
|
||||||
|
|
||||||
renderTanStackTable({
|
|
||||||
props: { enableQueryParams: true },
|
|
||||||
onUrlUpdate,
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Find the sortable column header's sort button (ID column has enableSort: true)
|
|
||||||
const sortButton = screen.getByTitle('ID');
|
|
||||||
await user.click(sortButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const lastOrderBy = onUrlUpdate.mock.calls
|
|
||||||
.map((call) => call[0].searchParams.get('order_by'))
|
|
||||||
.filter(Boolean)
|
|
||||||
.pop();
|
|
||||||
expect(lastOrderBy).toBeDefined();
|
|
||||||
const parsed = JSON.parse(lastOrderBy!);
|
|
||||||
expect(parsed.columnName).toBe('id');
|
|
||||||
expect(parsed.order).toBe('asc');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('toggles sort order on subsequent clicks', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
|
||||||
|
|
||||||
renderTanStackTable({
|
|
||||||
props: { enableQueryParams: true },
|
|
||||||
onUrlUpdate,
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortButton = screen.getByTitle('ID');
|
|
||||||
|
|
||||||
// First click - asc
|
|
||||||
await user.click(sortButton);
|
|
||||||
// Second click - desc
|
|
||||||
await user.click(sortButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const lastOrderBy = onUrlUpdate.mock.calls
|
|
||||||
.map((call) => call[0].searchParams.get('order_by'))
|
|
||||||
.filter(Boolean)
|
|
||||||
.pop();
|
|
||||||
if (lastOrderBy) {
|
|
||||||
const parsed = JSON.parse(lastOrderBy);
|
|
||||||
expect(parsed.order).toBe('desc');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('row selection', () => {
|
|
||||||
it('calls onRowClick with row data and itemKey', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const onRowClick = jest.fn();
|
|
||||||
|
|
||||||
renderTanStackTable({
|
|
||||||
props: {
|
|
||||||
onRowClick,
|
|
||||||
getRowKey: (row) => row.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
await user.click(screen.getByText('Item 1'));
|
|
||||||
|
|
||||||
expect(onRowClick).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ id: '1', name: 'Item 1' }),
|
|
||||||
'1',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies active class when isRowActive returns true', async () => {
|
|
||||||
renderTanStackTable({
|
|
||||||
props: {
|
|
||||||
isRowActive: (row) => row.id === '1',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Find the row containing Item 1 and check for active class
|
|
||||||
const cell = screen.getByText('Item 1');
|
|
||||||
const row = cell.closest('tr');
|
|
||||||
expect(row).toHaveClass('tableRowActive');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls onRowDeactivate when clicking active row', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const onRowClick = jest.fn();
|
|
||||||
const onRowDeactivate = jest.fn();
|
|
||||||
|
|
||||||
renderTanStackTable({
|
|
||||||
props: {
|
|
||||||
onRowClick,
|
|
||||||
onRowDeactivate,
|
|
||||||
isRowActive: (row) => row.id === '1',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
await user.click(screen.getByText('Item 1'));
|
|
||||||
|
|
||||||
expect(onRowDeactivate).toHaveBeenCalled();
|
|
||||||
expect(onRowClick).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('opens in new tab on ctrl+click', async () => {
|
|
||||||
const onRowClick = jest.fn();
|
|
||||||
const onRowClickNewTab = jest.fn();
|
|
||||||
|
|
||||||
renderTanStackTable({
|
|
||||||
props: {
|
|
||||||
onRowClick,
|
|
||||||
onRowClickNewTab,
|
|
||||||
getRowKey: (row) => row.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('Item 1'), { ctrlKey: true });
|
|
||||||
|
|
||||||
expect(onRowClickNewTab).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ id: '1' }),
|
|
||||||
'1',
|
|
||||||
);
|
|
||||||
expect(onRowClick).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('opens in new tab on meta+click', async () => {
|
|
||||||
const onRowClick = jest.fn();
|
|
||||||
const onRowClickNewTab = jest.fn();
|
|
||||||
|
|
||||||
renderTanStackTable({
|
|
||||||
props: {
|
|
||||||
onRowClick,
|
|
||||||
onRowClickNewTab,
|
|
||||||
getRowKey: (row) => row.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('Item 1'), { metaKey: true });
|
|
||||||
|
|
||||||
expect(onRowClickNewTab).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ id: '1' }),
|
|
||||||
'1',
|
|
||||||
);
|
|
||||||
expect(onRowClick).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('row expansion', () => {
|
|
||||||
it('renders expanded content below the row when expanded', async () => {
|
|
||||||
renderTanStackTable({
|
|
||||||
props: {
|
|
||||||
renderExpandedRow: (row) => (
|
|
||||||
<div data-testid="expanded-content">Expanded: {row.name}</div>
|
|
||||||
),
|
|
||||||
getRowCanExpand: () => true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Find and click expand button (if available in the row)
|
|
||||||
// The expansion is controlled by TanStack Table's expanded state
|
|
||||||
// For now, just verify the renderExpandedRow prop is wired correctly
|
|
||||||
// by checking the table renders without errors
|
|
||||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('infinite scroll', () => {
|
|
||||||
it('calls onEndReached when provided', async () => {
|
|
||||||
const onEndReached = jest.fn();
|
|
||||||
|
|
||||||
renderTanStackTable({
|
|
||||||
props: {
|
|
||||||
onEndReached,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Virtuoso will call onEndReached based on scroll position
|
|
||||||
// In mock context, we verify the prop is wired correctly
|
|
||||||
expect(onEndReached).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows loading spinner at bottom when loading in infinite scroll mode', () => {
|
|
||||||
renderTanStackTable({
|
|
||||||
props: {
|
|
||||||
isLoading: true,
|
|
||||||
onEndReached: jest.fn(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByTestId('tanstack-infinite-loader')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hides pagination in infinite scroll mode', async () => {
|
|
||||||
renderTanStackTable({
|
|
||||||
props: {
|
|
||||||
pagination: { total: 100 },
|
|
||||||
onEndReached: jest.fn(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// When onEndReached is provided, pagination should not render
|
|
||||||
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import { ReactNode } from 'react';
|
|
||||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
|
||||||
import { TooltipProvider } from '@signozhq/ui';
|
|
||||||
import { render, RenderResult } from '@testing-library/react';
|
|
||||||
import { NuqsTestingAdapter, OnUrlUpdateFunction } from 'nuqs/adapters/testing';
|
|
||||||
|
|
||||||
import TanStackTable from '../index';
|
|
||||||
import type { TableColumnDef, TanStackTableProps } from '../types';
|
|
||||||
|
|
||||||
// NOTE: Test files importing this utility must add this mock at the top of their file:
|
|
||||||
// jest.mock('hooks/useDarkMode', () => ({ useIsDarkMode: (): boolean => false }));
|
|
||||||
|
|
||||||
// Default test data types
|
|
||||||
export type TestRow = { id: string; name: string; value: number };
|
|
||||||
|
|
||||||
export const defaultColumns: TableColumnDef<TestRow>[] = [
|
|
||||||
{
|
|
||||||
id: 'id',
|
|
||||||
header: 'ID',
|
|
||||||
accessorKey: 'id',
|
|
||||||
enableSort: true,
|
|
||||||
cell: ({ value }): string => String(value),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'name',
|
|
||||||
header: 'Name',
|
|
||||||
accessorKey: 'name',
|
|
||||||
cell: ({ value }): string => String(value),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'value',
|
|
||||||
header: 'Value',
|
|
||||||
accessorKey: 'value',
|
|
||||||
enableSort: true,
|
|
||||||
cell: ({ value }): string => String(value),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const defaultData: TestRow[] = [
|
|
||||||
{ id: '1', name: 'Item 1', value: 100 },
|
|
||||||
{ id: '2', name: 'Item 2', value: 200 },
|
|
||||||
{ id: '3', name: 'Item 3', value: 300 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export type RenderTanStackTableOptions<T> = {
|
|
||||||
props?: Partial<TanStackTableProps<T>>;
|
|
||||||
queryParams?: Record<string, string>;
|
|
||||||
onUrlUpdate?: OnUrlUpdateFunction;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function renderTanStackTable<T = TestRow>(
|
|
||||||
options: RenderTanStackTableOptions<T> = {},
|
|
||||||
): RenderResult {
|
|
||||||
const { props = {}, queryParams, onUrlUpdate } = options;
|
|
||||||
|
|
||||||
const mergedProps = {
|
|
||||||
data: (defaultData as unknown) as T[],
|
|
||||||
columns: (defaultColumns as unknown) as TableColumnDef<T>[],
|
|
||||||
...props,
|
|
||||||
} as TanStackTableProps<T>;
|
|
||||||
|
|
||||||
return render(
|
|
||||||
<NuqsTestingAdapter searchParams={queryParams} onUrlUpdate={onUrlUpdate}>
|
|
||||||
<VirtuosoMockContext.Provider
|
|
||||||
value={{ viewportHeight: 500, itemHeight: 50 }}
|
|
||||||
>
|
|
||||||
<TooltipProvider>
|
|
||||||
<TanStackTable<T> {...mergedProps} />
|
|
||||||
</TooltipProvider>
|
|
||||||
</VirtuosoMockContext.Provider>
|
|
||||||
</NuqsTestingAdapter>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to wrap any component with test providers (for unit tests)
|
|
||||||
export function renderWithProviders(
|
|
||||||
ui: ReactNode,
|
|
||||||
options: {
|
|
||||||
queryParams?: Record<string, string>;
|
|
||||||
onUrlUpdate?: OnUrlUpdateFunction;
|
|
||||||
} = {},
|
|
||||||
): RenderResult {
|
|
||||||
const { queryParams, onUrlUpdate } = options;
|
|
||||||
|
|
||||||
return render(
|
|
||||||
<NuqsTestingAdapter searchParams={queryParams} onUrlUpdate={onUrlUpdate}>
|
|
||||||
<VirtuosoMockContext.Provider
|
|
||||||
value={{ viewportHeight: 500, itemHeight: 50 }}
|
|
||||||
>
|
|
||||||
<TooltipProvider>{ui}</TooltipProvider>
|
|
||||||
</VirtuosoMockContext.Provider>
|
|
||||||
</NuqsTestingAdapter>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
/* eslint-disable no-restricted-syntax */
|
|
||||||
import { act, renderHook } from '@testing-library/react';
|
|
||||||
|
|
||||||
import { TableColumnDef } from '../types';
|
|
||||||
import { useColumnState } from '../useColumnState';
|
|
||||||
import { useColumnStore } from '../useColumnStore';
|
|
||||||
|
|
||||||
const TEST_KEY = 'test-state';
|
|
||||||
|
|
||||||
type TestRow = { id: string; name: string };
|
|
||||||
|
|
||||||
const col = (
|
|
||||||
id: string,
|
|
||||||
overrides: Partial<TableColumnDef<TestRow>> = {},
|
|
||||||
): TableColumnDef<TestRow> => ({
|
|
||||||
id,
|
|
||||||
header: id,
|
|
||||||
cell: (): null => null,
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('useColumnState', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
useColumnStore.setState({ tables: {} });
|
|
||||||
localStorage.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('initialization', () => {
|
|
||||||
it('initializes store from column defaults on mount', () => {
|
|
||||||
const columns = [
|
|
||||||
col('a', { defaultVisibility: true }),
|
|
||||||
col('b', { defaultVisibility: false }),
|
|
||||||
col('c'),
|
|
||||||
];
|
|
||||||
|
|
||||||
renderHook(() => useColumnState({ storageKey: TEST_KEY, columns }));
|
|
||||||
|
|
||||||
const state = useColumnStore.getState().tables[TEST_KEY];
|
|
||||||
expect(state.hiddenColumnIds).toEqual(['b']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not initialize without storageKey', () => {
|
|
||||||
const columns = [col('a', { defaultVisibility: false })];
|
|
||||||
|
|
||||||
renderHook(() => useColumnState({ columns }));
|
|
||||||
|
|
||||||
expect(useColumnStore.getState().tables[TEST_KEY]).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('columnVisibility', () => {
|
|
||||||
it('returns visibility state from hidden columns', () => {
|
|
||||||
const columns = [col('a'), col('b'), col('c')];
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
|
||||||
useColumnStore.getState().hideColumn(TEST_KEY, 'b');
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useColumnState({ storageKey: TEST_KEY, columns }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.columnVisibility).toEqual({ b: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies visibilityBehavior for grouped state', () => {
|
|
||||||
const columns = [
|
|
||||||
col('ungrouped', { visibilityBehavior: 'hidden-on-expand' }),
|
|
||||||
col('grouped', { visibilityBehavior: 'hidden-on-collapse' }),
|
|
||||||
col('always'),
|
|
||||||
];
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Not grouped
|
|
||||||
const { result: notGrouped } = renderHook(() =>
|
|
||||||
useColumnState({ storageKey: TEST_KEY, columns, isGrouped: false }),
|
|
||||||
);
|
|
||||||
expect(notGrouped.current.columnVisibility).toEqual({ grouped: false });
|
|
||||||
|
|
||||||
// Grouped
|
|
||||||
const { result: grouped } = renderHook(() =>
|
|
||||||
useColumnState({ storageKey: TEST_KEY, columns, isGrouped: true }),
|
|
||||||
);
|
|
||||||
expect(grouped.current.columnVisibility).toEqual({ ungrouped: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('combines store hidden + visibilityBehavior', () => {
|
|
||||||
const columns = [
|
|
||||||
col('a', { visibilityBehavior: 'hidden-on-expand' }),
|
|
||||||
col('b'),
|
|
||||||
];
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
|
||||||
useColumnStore.getState().hideColumn(TEST_KEY, 'b');
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useColumnState({ storageKey: TEST_KEY, columns, isGrouped: true }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.columnVisibility).toEqual({ a: false, b: false });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('sortedColumns', () => {
|
|
||||||
it('returns columns in original order when no order set', () => {
|
|
||||||
const columns = [col('a'), col('b'), col('c')];
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useColumnState({ storageKey: TEST_KEY, columns }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.sortedColumns.map((c) => c.id)).toEqual([
|
|
||||||
'a',
|
|
||||||
'b',
|
|
||||||
'c',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns columns sorted by stored order', () => {
|
|
||||||
const columns = [col('a'), col('b'), col('c')];
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
|
||||||
useColumnStore.getState().setColumnOrder(TEST_KEY, ['c', 'a', 'b']);
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useColumnState({ storageKey: TEST_KEY, columns }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.sortedColumns.map((c) => c.id)).toEqual([
|
|
||||||
'c',
|
|
||||||
'a',
|
|
||||||
'b',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps pinned columns at the start', () => {
|
|
||||||
const columns = [col('a'), col('pinned', { pin: 'left' }), col('b')];
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
|
||||||
useColumnStore.getState().setColumnOrder(TEST_KEY, ['b', 'a']);
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useColumnState({ storageKey: TEST_KEY, columns }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.sortedColumns.map((c) => c.id)).toEqual([
|
|
||||||
'pinned',
|
|
||||||
'b',
|
|
||||||
'a',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('actions', () => {
|
|
||||||
it('hideColumn hides a column', () => {
|
|
||||||
const columns = [col('a'), col('b')];
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useColumnState({ storageKey: TEST_KEY, columns }),
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.hideColumn('a');
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.columnVisibility).toEqual({ a: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('showColumn shows a column', () => {
|
|
||||||
const columns = [col('a', { defaultVisibility: false })];
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useColumnState({ storageKey: TEST_KEY, columns }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.columnVisibility).toEqual({ a: false });
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.showColumn('a');
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.columnVisibility).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('setColumnSizing updates sizing', () => {
|
|
||||||
const columns = [col('a')];
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useColumnState({ storageKey: TEST_KEY, columns }),
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.setColumnSizing({ a: 200 });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.columnSizing).toEqual({ a: 200 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('setColumnOrder updates order from column array', () => {
|
|
||||||
const columns = [col('a'), col('b'), col('c')];
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useColumnState({ storageKey: TEST_KEY, columns }),
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.setColumnOrder([col('c'), col('a'), col('b')]);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.sortedColumns.map((c) => c.id)).toEqual([
|
|
||||||
'c',
|
|
||||||
'a',
|
|
||||||
'b',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
/* eslint-disable no-restricted-syntax */
|
|
||||||
import { act, renderHook } from '@testing-library/react';
|
|
||||||
|
|
||||||
import {
|
|
||||||
useColumnOrder,
|
|
||||||
useColumnSizing,
|
|
||||||
useColumnStore,
|
|
||||||
useHiddenColumnIds,
|
|
||||||
} from '../useColumnStore';
|
|
||||||
|
|
||||||
const TEST_KEY = 'test-table';
|
|
||||||
|
|
||||||
describe('useColumnStore', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
useColumnStore.getState().tables = {};
|
|
||||||
localStorage.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('initializeFromDefaults', () => {
|
|
||||||
it('initializes hidden columns from defaultVisibility: false', () => {
|
|
||||||
const columns = [
|
|
||||||
{ id: 'a', defaultVisibility: true },
|
|
||||||
{ id: 'b', defaultVisibility: false },
|
|
||||||
{ id: 'c' }, // defaults to visible
|
|
||||||
];
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns as any);
|
|
||||||
});
|
|
||||||
|
|
||||||
const state = useColumnStore.getState().tables[TEST_KEY];
|
|
||||||
expect(state.hiddenColumnIds).toEqual(['b']);
|
|
||||||
expect(state.columnOrder).toEqual([]);
|
|
||||||
expect(state.columnSizing).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not reinitialize if already exists', () => {
|
|
||||||
act(() => {
|
|
||||||
useColumnStore
|
|
||||||
.getState()
|
|
||||||
.initializeFromDefaults(TEST_KEY, [
|
|
||||||
{ id: 'a', defaultVisibility: false },
|
|
||||||
] as any);
|
|
||||||
useColumnStore.getState().hideColumn(TEST_KEY, 'x');
|
|
||||||
useColumnStore
|
|
||||||
.getState()
|
|
||||||
.initializeFromDefaults(TEST_KEY, [
|
|
||||||
{ id: 'b', defaultVisibility: false },
|
|
||||||
] as any);
|
|
||||||
});
|
|
||||||
|
|
||||||
const state = useColumnStore.getState().tables[TEST_KEY];
|
|
||||||
expect(state.hiddenColumnIds).toContain('a');
|
|
||||||
expect(state.hiddenColumnIds).toContain('x');
|
|
||||||
expect(state.hiddenColumnIds).not.toContain('b');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('hideColumn / showColumn / toggleColumn', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
act(() => {
|
|
||||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hideColumn adds to hiddenColumnIds', () => {
|
|
||||||
act(() => {
|
|
||||||
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
|
|
||||||
});
|
|
||||||
expect(useColumnStore.getState().tables[TEST_KEY].hiddenColumnIds).toContain(
|
|
||||||
'col1',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hideColumn is idempotent', () => {
|
|
||||||
act(() => {
|
|
||||||
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
|
|
||||||
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
useColumnStore
|
|
||||||
.getState()
|
|
||||||
.tables[TEST_KEY].hiddenColumnIds.filter((id) => id === 'col1'),
|
|
||||||
).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('showColumn removes from hiddenColumnIds', () => {
|
|
||||||
act(() => {
|
|
||||||
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
|
|
||||||
useColumnStore.getState().showColumn(TEST_KEY, 'col1');
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
useColumnStore.getState().tables[TEST_KEY].hiddenColumnIds,
|
|
||||||
).not.toContain('col1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('toggleColumn toggles visibility', () => {
|
|
||||||
act(() => {
|
|
||||||
useColumnStore.getState().toggleColumn(TEST_KEY, 'col1');
|
|
||||||
});
|
|
||||||
expect(useColumnStore.getState().tables[TEST_KEY].hiddenColumnIds).toContain(
|
|
||||||
'col1',
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
useColumnStore.getState().toggleColumn(TEST_KEY, 'col1');
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
useColumnStore.getState().tables[TEST_KEY].hiddenColumnIds,
|
|
||||||
).not.toContain('col1');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('setColumnSizing', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
act(() => {
|
|
||||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates column sizing', () => {
|
|
||||||
act(() => {
|
|
||||||
useColumnStore
|
|
||||||
.getState()
|
|
||||||
.setColumnSizing(TEST_KEY, { col1: 200, col2: 300 });
|
|
||||||
});
|
|
||||||
expect(useColumnStore.getState().tables[TEST_KEY].columnSizing).toEqual({
|
|
||||||
col1: 200,
|
|
||||||
col2: 300,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('setColumnOrder', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
act(() => {
|
|
||||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates column order', () => {
|
|
||||||
act(() => {
|
|
||||||
useColumnStore
|
|
||||||
.getState()
|
|
||||||
.setColumnOrder(TEST_KEY, ['col2', 'col1', 'col3']);
|
|
||||||
});
|
|
||||||
expect(useColumnStore.getState().tables[TEST_KEY].columnOrder).toEqual([
|
|
||||||
'col2',
|
|
||||||
'col1',
|
|
||||||
'col3',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('resetToDefaults', () => {
|
|
||||||
it('resets to column defaults', () => {
|
|
||||||
const columns = [
|
|
||||||
{ id: 'a', defaultVisibility: false },
|
|
||||||
{ id: 'b', defaultVisibility: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns as any);
|
|
||||||
useColumnStore.getState().showColumn(TEST_KEY, 'a');
|
|
||||||
useColumnStore.getState().hideColumn(TEST_KEY, 'b');
|
|
||||||
useColumnStore.getState().setColumnOrder(TEST_KEY, ['b', 'a']);
|
|
||||||
useColumnStore.getState().setColumnSizing(TEST_KEY, { a: 100 });
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
useColumnStore.getState().resetToDefaults(TEST_KEY, columns as any);
|
|
||||||
});
|
|
||||||
|
|
||||||
const state = useColumnStore.getState().tables[TEST_KEY];
|
|
||||||
expect(state.hiddenColumnIds).toEqual(['a']);
|
|
||||||
expect(state.columnOrder).toEqual([]);
|
|
||||||
expect(state.columnSizing).toEqual({});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('cleanupStaleHiddenColumns', () => {
|
|
||||||
it('removes hidden column IDs that are not in validColumnIds', () => {
|
|
||||||
act(() => {
|
|
||||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
|
|
||||||
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
|
|
||||||
useColumnStore.getState().hideColumn(TEST_KEY, 'col2');
|
|
||||||
useColumnStore.getState().hideColumn(TEST_KEY, 'col3');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Only col1 and col3 are valid now
|
|
||||||
act(() => {
|
|
||||||
useColumnStore
|
|
||||||
.getState()
|
|
||||||
.cleanupStaleHiddenColumns(TEST_KEY, new Set(['col1', 'col3']));
|
|
||||||
});
|
|
||||||
|
|
||||||
const state = useColumnStore.getState().tables[TEST_KEY];
|
|
||||||
expect(state.hiddenColumnIds).toEqual(['col1', 'col3']);
|
|
||||||
expect(state.hiddenColumnIds).not.toContain('col2');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does nothing when all hidden columns are valid', () => {
|
|
||||||
act(() => {
|
|
||||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
|
|
||||||
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
|
|
||||||
useColumnStore.getState().hideColumn(TEST_KEY, 'col2');
|
|
||||||
});
|
|
||||||
|
|
||||||
const stateBefore = useColumnStore.getState().tables[TEST_KEY];
|
|
||||||
const hiddenBefore = [...stateBefore.hiddenColumnIds];
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
useColumnStore
|
|
||||||
.getState()
|
|
||||||
.cleanupStaleHiddenColumns(TEST_KEY, new Set(['col1', 'col2', 'col3']));
|
|
||||||
});
|
|
||||||
|
|
||||||
const stateAfter = useColumnStore.getState().tables[TEST_KEY];
|
|
||||||
expect(stateAfter.hiddenColumnIds).toEqual(hiddenBefore);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does nothing for unknown storage key', () => {
|
|
||||||
act(() => {
|
|
||||||
useColumnStore
|
|
||||||
.getState()
|
|
||||||
.cleanupStaleHiddenColumns('unknown-key', new Set(['col1']));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should not throw or create state
|
|
||||||
expect(useColumnStore.getState().tables['unknown-key']).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('selector hooks', () => {
|
|
||||||
it('useHiddenColumnIds returns hidden columns', () => {
|
|
||||||
act(() => {
|
|
||||||
useColumnStore
|
|
||||||
.getState()
|
|
||||||
.initializeFromDefaults(TEST_KEY, [
|
|
||||||
{ id: 'a', defaultVisibility: false },
|
|
||||||
] as any);
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useHiddenColumnIds(TEST_KEY));
|
|
||||||
expect(result.current).toEqual(['a']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('useHiddenColumnIds returns a stable snapshot for persisted state', () => {
|
|
||||||
localStorage.setItem(
|
|
||||||
'@signoz/table-columns/test-table',
|
|
||||||
JSON.stringify({
|
|
||||||
hiddenColumnIds: ['persisted'],
|
|
||||||
columnOrder: [],
|
|
||||||
columnSizing: {},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { result, rerender } = renderHook(() => useHiddenColumnIds(TEST_KEY));
|
|
||||||
const firstSnapshot = result.current;
|
|
||||||
|
|
||||||
rerender();
|
|
||||||
|
|
||||||
expect(result.current).toBe(firstSnapshot);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('useColumnSizing returns sizing', () => {
|
|
||||||
act(() => {
|
|
||||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
|
|
||||||
useColumnStore.getState().setColumnSizing(TEST_KEY, { col1: 150 });
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useColumnSizing(TEST_KEY));
|
|
||||||
expect(result.current).toEqual({ col1: 150 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('useColumnOrder returns order', () => {
|
|
||||||
act(() => {
|
|
||||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
|
|
||||||
useColumnStore.getState().setColumnOrder(TEST_KEY, ['c', 'b', 'a']);
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useColumnOrder(TEST_KEY));
|
|
||||||
expect(result.current).toEqual(['c', 'b', 'a']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns empty defaults for unknown storageKey', () => {
|
|
||||||
const { result: hidden } = renderHook(() => useHiddenColumnIds('unknown'));
|
|
||||||
const { result: sizing } = renderHook(() => useColumnSizing('unknown'));
|
|
||||||
const { result: order } = renderHook(() => useColumnOrder('unknown'));
|
|
||||||
|
|
||||||
expect(hidden.current).toEqual([]);
|
|
||||||
expect(sizing.current).toEqual({});
|
|
||||||
expect(order.current).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
import { ReactNode } from 'react';
|
|
||||||
import { act, renderHook } from '@testing-library/react';
|
|
||||||
import {
|
|
||||||
NuqsTestingAdapter,
|
|
||||||
OnUrlUpdateFunction,
|
|
||||||
UrlUpdateEvent,
|
|
||||||
} from 'nuqs/adapters/testing';
|
|
||||||
|
|
||||||
import { useTableParams } from '../useTableParams';
|
|
||||||
|
|
||||||
function createNuqsWrapper(
|
|
||||||
queryParams?: Record<string, string>,
|
|
||||||
onUrlUpdate?: OnUrlUpdateFunction,
|
|
||||||
): ({ children }: { children: ReactNode }) => JSX.Element {
|
|
||||||
return function NuqsWrapper({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
}): JSX.Element {
|
|
||||||
return (
|
|
||||||
<NuqsTestingAdapter
|
|
||||||
searchParams={queryParams}
|
|
||||||
onUrlUpdate={onUrlUpdate}
|
|
||||||
hasMemory
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</NuqsTestingAdapter>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('useTableParams (local mode — enableQueryParams not set)', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.useFakeTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns default page=1 and limit=50', () => {
|
|
||||||
const wrapper = createNuqsWrapper();
|
|
||||||
const { result } = renderHook(() => useTableParams(), { wrapper });
|
|
||||||
expect(result.current.page).toBe(1);
|
|
||||||
expect(result.current.limit).toBe(50);
|
|
||||||
expect(result.current.orderBy).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('respects custom defaults', () => {
|
|
||||||
const wrapper = createNuqsWrapper();
|
|
||||||
const { result } = renderHook(
|
|
||||||
() => useTableParams(undefined, { page: 2, limit: 25 }),
|
|
||||||
{ wrapper },
|
|
||||||
);
|
|
||||||
expect(result.current.page).toBe(2);
|
|
||||||
expect(result.current.limit).toBe(25);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('setPage updates page', () => {
|
|
||||||
const wrapper = createNuqsWrapper();
|
|
||||||
const { result } = renderHook(() => useTableParams(), { wrapper });
|
|
||||||
act(() => {
|
|
||||||
result.current.setPage(3);
|
|
||||||
});
|
|
||||||
expect(result.current.page).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('setLimit updates limit', () => {
|
|
||||||
const wrapper = createNuqsWrapper();
|
|
||||||
const { result } = renderHook(() => useTableParams(), { wrapper });
|
|
||||||
act(() => {
|
|
||||||
result.current.setLimit(100);
|
|
||||||
});
|
|
||||||
expect(result.current.limit).toBe(100);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('setOrderBy updates orderBy', () => {
|
|
||||||
const wrapper = createNuqsWrapper();
|
|
||||||
const { result } = renderHook(() => useTableParams(), { wrapper });
|
|
||||||
act(() => {
|
|
||||||
result.current.setOrderBy({ columnName: 'cpu', order: 'desc' });
|
|
||||||
});
|
|
||||||
expect(result.current.orderBy).toEqual({ columnName: 'cpu', order: 'desc' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('useTableParams (URL mode — enableQueryParams set)', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.useFakeTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses nuqs state when enableQueryParams=true', () => {
|
|
||||||
const wrapper = createNuqsWrapper();
|
|
||||||
const { result } = renderHook(() => useTableParams(true), { wrapper });
|
|
||||||
expect(result.current.page).toBe(1);
|
|
||||||
act(() => {
|
|
||||||
result.current.setPage(5);
|
|
||||||
jest.runAllTimers();
|
|
||||||
});
|
|
||||||
expect(result.current.page).toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses prefixed keys when enableQueryParams is a string', () => {
|
|
||||||
const wrapper = createNuqsWrapper({ pods_page: '2' });
|
|
||||||
const { result } = renderHook(() => useTableParams('pods', { page: 2 }), {
|
|
||||||
wrapper,
|
|
||||||
});
|
|
||||||
expect(result.current.page).toBe(2);
|
|
||||||
act(() => {
|
|
||||||
result.current.setPage(4);
|
|
||||||
jest.runAllTimers();
|
|
||||||
});
|
|
||||||
expect(result.current.page).toBe(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('local state is ignored when enableQueryParams is set', () => {
|
|
||||||
const localWrapper = createNuqsWrapper();
|
|
||||||
const urlWrapper = createNuqsWrapper();
|
|
||||||
const { result: local } = renderHook(() => useTableParams(), {
|
|
||||||
wrapper: localWrapper,
|
|
||||||
});
|
|
||||||
const { result: url } = renderHook(() => useTableParams(true), {
|
|
||||||
wrapper: urlWrapper,
|
|
||||||
});
|
|
||||||
act(() => {
|
|
||||||
local.current.setPage(99);
|
|
||||||
});
|
|
||||||
// URL mode hook in a separate wrapper should still have its own state
|
|
||||||
expect(url.current.page).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reads initial page from URL params', () => {
|
|
||||||
const wrapper = createNuqsWrapper({ page: '3' });
|
|
||||||
const { result } = renderHook(() => useTableParams(true), { wrapper });
|
|
||||||
expect(result.current.page).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reads initial orderBy from URL params', () => {
|
|
||||||
const orderBy = JSON.stringify({ columnName: 'name', order: 'desc' });
|
|
||||||
const wrapper = createNuqsWrapper({ order_by: orderBy });
|
|
||||||
const { result } = renderHook(() => useTableParams(true), { wrapper });
|
|
||||||
expect(result.current.orderBy).toEqual({ columnName: 'name', order: 'desc' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates URL when setPage is called', () => {
|
|
||||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
|
||||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
|
||||||
const { result } = renderHook(() => useTableParams(true), { wrapper });
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.setPage(5);
|
|
||||||
jest.runAllTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
const lastPage = onUrlUpdate.mock.calls
|
|
||||||
.map((call) => call[0].searchParams.get('page'))
|
|
||||||
.filter(Boolean)
|
|
||||||
.pop();
|
|
||||||
expect(lastPage).toBe('5');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates URL when setOrderBy is called', () => {
|
|
||||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
|
||||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
|
||||||
const { result } = renderHook(() => useTableParams(true), { wrapper });
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.setOrderBy({ columnName: 'value', order: 'asc' });
|
|
||||||
jest.runAllTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
const lastOrderBy = onUrlUpdate.mock.calls
|
|
||||||
.map((call) => call[0].searchParams.get('order_by'))
|
|
||||||
.filter(Boolean)
|
|
||||||
.pop();
|
|
||||||
expect(lastOrderBy).toBeDefined();
|
|
||||||
expect(JSON.parse(lastOrderBy!)).toEqual({
|
|
||||||
columnName: 'value',
|
|
||||||
order: 'asc',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses custom param names from config object', () => {
|
|
||||||
const config = {
|
|
||||||
page: 'listPage',
|
|
||||||
limit: 'listLimit',
|
|
||||||
orderBy: 'listOrderBy',
|
|
||||||
expanded: 'listExpanded',
|
|
||||||
};
|
|
||||||
const wrapper = createNuqsWrapper({ listPage: '3' });
|
|
||||||
const { result } = renderHook(() => useTableParams(config, { page: 3 }), {
|
|
||||||
wrapper,
|
|
||||||
});
|
|
||||||
expect(result.current.page).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('manages expanded state for row expansion', () => {
|
|
||||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
|
||||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
|
||||||
const { result } = renderHook(() => useTableParams(true), { wrapper });
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.setExpanded({ 'row-1': true });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.expanded).toEqual({ 'row-1': true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('toggles sort order correctly: null → asc → desc → null', () => {
|
|
||||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
|
||||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
|
||||||
const { result } = renderHook(() => useTableParams(true), { wrapper });
|
|
||||||
|
|
||||||
// Initial state
|
|
||||||
expect(result.current.orderBy).toBeNull();
|
|
||||||
|
|
||||||
// First click: null → asc
|
|
||||||
act(() => {
|
|
||||||
result.current.setOrderBy({ columnName: 'id', order: 'asc' });
|
|
||||||
});
|
|
||||||
expect(result.current.orderBy).toEqual({ columnName: 'id', order: 'asc' });
|
|
||||||
|
|
||||||
// Second click: asc → desc
|
|
||||||
act(() => {
|
|
||||||
result.current.setOrderBy({ columnName: 'id', order: 'desc' });
|
|
||||||
});
|
|
||||||
expect(result.current.orderBy).toEqual({ columnName: 'id', order: 'desc' });
|
|
||||||
|
|
||||||
// Third click: desc → null
|
|
||||||
act(() => {
|
|
||||||
result.current.setOrderBy(null);
|
|
||||||
});
|
|
||||||
expect(result.current.orderBy).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
import { TanStackTableBase } from './TanStackTable';
|
|
||||||
import TanStackTableText from './TanStackTableText';
|
|
||||||
|
|
||||||
export * from './TanStackTableStateContext';
|
|
||||||
export * from './types';
|
|
||||||
export * from './useColumnState';
|
|
||||||
export * from './useColumnStore';
|
|
||||||
export * from './useTableParams';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Virtualized data table built on TanStack Table and `react-virtuoso`: resizable and pinnable columns,
|
|
||||||
* optional drag-to-reorder headers, expandable rows, and pagination or infinite scroll.
|
|
||||||
*
|
|
||||||
* @example Minimal usage
|
|
||||||
* ```tsx
|
|
||||||
* import TanStackTable from 'components/TanStackTableView';
|
|
||||||
* import type { TableColumnDef } from 'components/TanStackTableView';
|
|
||||||
*
|
|
||||||
* type Row = { id: string; name: string };
|
|
||||||
*
|
|
||||||
* const columns: TableColumnDef<Row>[] = [
|
|
||||||
* {
|
|
||||||
* id: 'name',
|
|
||||||
* header: 'Name',
|
|
||||||
* accessorKey: 'name',
|
|
||||||
* cell: ({ value }) => <TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>,
|
|
||||||
* },
|
|
||||||
* ];
|
|
||||||
*
|
|
||||||
* function Example(): JSX.Element {
|
|
||||||
* return <TanStackTable<Row> data={rows} columns={columns} />;
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @example Column definitions — `accessorFn`, custom header, pinned column, sortable
|
|
||||||
* ```tsx
|
|
||||||
* const columns: TableColumnDef<Row>[] = [
|
|
||||||
* {
|
|
||||||
* id: 'id',
|
|
||||||
* header: 'ID',
|
|
||||||
* accessorKey: 'id',
|
|
||||||
* pin: 'left',
|
|
||||||
* width: { min: 80, default: 120 },
|
|
||||||
* enableSort: true,
|
|
||||||
* cell: ({ value }) => <TanStackTable.Text>{String(value)}</TanStackTable.Text>,
|
|
||||||
* },
|
|
||||||
* {
|
|
||||||
* id: 'computed',
|
|
||||||
* header: () => <span>Computed</span>,
|
|
||||||
* accessorFn: (row) => row.first + row.last,
|
|
||||||
* enableMove: false,
|
|
||||||
* enableRemove: false,
|
|
||||||
* cell: ({ value }) => <TanStackTable.Text>{String(value)}</TanStackTable.Text>,
|
|
||||||
* },
|
|
||||||
* ];
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @example Column state persistence with store (recommended)
|
|
||||||
* ```tsx
|
|
||||||
* <TanStackTable
|
|
||||||
* data={data}
|
|
||||||
* columns={columns}
|
|
||||||
* columnStorageKey="my-table-columns"
|
|
||||||
* />
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @example Pagination with query params. Use `enableQueryParams` object to customize param names.
|
|
||||||
* ```tsx
|
|
||||||
* <TanStackTable
|
|
||||||
* data={pageRows}
|
|
||||||
* columns={columns}
|
|
||||||
* pagination={{ total: totalCount, defaultPage: 1, defaultLimit: 20 }}
|
|
||||||
* enableQueryParams={{
|
|
||||||
* page: 'listPage',
|
|
||||||
* limit: 'listPageSize',
|
|
||||||
* orderBy: 'orderBy',
|
|
||||||
* expanded: 'listExpanded',
|
|
||||||
* }}
|
|
||||||
* prefixPaginationContent={<span>Custom prefix</span>}
|
|
||||||
* suffixPaginationContent={<span>Custom suffix</span>}
|
|
||||||
* />
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @example Infinite scroll — use `onEndReached` (pagination UI is hidden when set).
|
|
||||||
* ```tsx
|
|
||||||
* <TanStackTable
|
|
||||||
* data={accumulatedRows}
|
|
||||||
* columns={columns}
|
|
||||||
* onEndReached={(lastIndex) => fetchMore(lastIndex)}
|
|
||||||
* isLoading={isFetching}
|
|
||||||
* />
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @example Loading state and typography for plain string/number cells
|
|
||||||
* ```tsx
|
|
||||||
* <TanStackTable
|
|
||||||
* data={data}
|
|
||||||
* columns={columns}
|
|
||||||
* isLoading={isFetching}
|
|
||||||
* skeletonRowCount={15}
|
|
||||||
* cellTypographySize="small"
|
|
||||||
* plainTextCellLineClamp={2}
|
|
||||||
* />
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @example Row styling, selection, and actions. `onRowClick` receives `(row, itemKey)`.
|
|
||||||
* ```tsx
|
|
||||||
* <TanStackTable
|
|
||||||
* data={data}
|
|
||||||
* columns={columns}
|
|
||||||
* getRowKey={(row) => row.id}
|
|
||||||
* getItemKey={(row) => row.id}
|
|
||||||
* isRowActive={(row) => row.id === selectedId}
|
|
||||||
* activeRowIndex={selectedIndex}
|
|
||||||
* onRowClick={(row, itemKey) => setSelectedId(itemKey)}
|
|
||||||
* onRowClickNewTab={(row, itemKey) => openInNewTab(itemKey)}
|
|
||||||
* onRowDeactivate={() => setSelectedId(undefined)}
|
|
||||||
* getRowClassName={(row) => (row.severity === 'error' ? 'row-error' : '')}
|
|
||||||
* getRowStyle={(row) => (row.dimmed ? { opacity: 0.5 } : {})}
|
|
||||||
* renderRowActions={(row) => <Button size="small">Open</Button>}
|
|
||||||
* />
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @example Expandable rows. `renderExpandedRow` receives `(row, rowKey, groupMeta?)`.
|
|
||||||
* ```tsx
|
|
||||||
* <TanStackTable
|
|
||||||
* data={data}
|
|
||||||
* columns={columns}
|
|
||||||
* getRowKey={(row) => row.id}
|
|
||||||
* renderExpandedRow={(row, rowKey, groupMeta) => (
|
|
||||||
* <pre>{JSON.stringify({ rowKey, groupMeta, raw: row.raw }, null, 2)}</pre>
|
|
||||||
* )}
|
|
||||||
* getRowCanExpand={(row) => Boolean(row.raw)}
|
|
||||||
* />
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @example Grouped rows — use `groupBy` + `getGroupKey` for group-aware key generation.
|
|
||||||
* ```tsx
|
|
||||||
* <TanStackTable
|
|
||||||
* data={data}
|
|
||||||
* columns={columns}
|
|
||||||
* getRowKey={(row) => row.id}
|
|
||||||
* groupBy={[{ key: 'namespace' }, { key: 'cluster' }]}
|
|
||||||
* getGroupKey={(row) => row.meta ?? {}}
|
|
||||||
* renderExpandedRow={(row, rowKey, groupMeta) => (
|
|
||||||
* <ExpandedDetails groupMeta={groupMeta} />
|
|
||||||
* )}
|
|
||||||
* getRowCanExpand={() => true}
|
|
||||||
* />
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @example Imperative handle — `goToPage` plus Virtuoso methods (e.g. `scrollToIndex`)
|
|
||||||
* ```tsx
|
|
||||||
* import type { TanStackTableHandle } from 'components/TanStackTableView';
|
|
||||||
*
|
|
||||||
* const ref = useRef<TanStackTableHandle>(null);
|
|
||||||
*
|
|
||||||
* <TanStackTable ref={ref} data={data} columns={columns} pagination={{ total, defaultLimit: 20 }} />;
|
|
||||||
*
|
|
||||||
* ref.current?.goToPage(2);
|
|
||||||
* ref.current?.scrollToIndex({ index: 0, align: 'start' });
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @example Scroll container props and testing
|
|
||||||
* ```tsx
|
|
||||||
* <TanStackTable
|
|
||||||
* data={data}
|
|
||||||
* columns={columns}
|
|
||||||
* className="my-table-wrapper"
|
|
||||||
* testId="logs-table"
|
|
||||||
* tableScrollerProps={{ className: 'my-table-scroll', 'data-testid': 'logs-scroller' }}
|
|
||||||
* />
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
const TanStackTable = Object.assign(TanStackTableBase, {
|
|
||||||
Text: TanStackTableText,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default TanStackTable;
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
import {
|
|
||||||
CSSProperties,
|
|
||||||
Dispatch,
|
|
||||||
HTMLAttributes,
|
|
||||||
ReactNode,
|
|
||||||
SetStateAction,
|
|
||||||
} from 'react';
|
|
||||||
import type { TableVirtuosoHandle } from 'react-virtuoso';
|
|
||||||
import type {
|
|
||||||
ColumnSizingState,
|
|
||||||
Row as TanStackRowType,
|
|
||||||
} from '@tanstack/react-table';
|
|
||||||
|
|
||||||
export type SortState = { columnName: string; order: 'asc' | 'desc' };
|
|
||||||
|
|
||||||
/** Sets `--tanstack-plain-cell-*` on the scroll root via CSS module classes (no data attributes). */
|
|
||||||
export type CellTypographySize = 'small' | 'medium' | 'large';
|
|
||||||
|
|
||||||
export type TableCellContext<TData, TValue> = {
|
|
||||||
row: TData;
|
|
||||||
value: TValue;
|
|
||||||
isActive: boolean;
|
|
||||||
rowIndex: number;
|
|
||||||
isExpanded: boolean;
|
|
||||||
canExpand: boolean;
|
|
||||||
toggleExpanded: () => void;
|
|
||||||
/** Business/selection key for the row */
|
|
||||||
itemKey: string;
|
|
||||||
/** Group metadata when row is part of a grouped view */
|
|
||||||
groupMeta?: Record<string, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RowKeyData = {
|
|
||||||
/** Final unique key (with duplicate suffix if needed) */
|
|
||||||
finalKey: string;
|
|
||||||
/** Business/selection key */
|
|
||||||
itemKey: string;
|
|
||||||
/** Group metadata */
|
|
||||||
groupMeta?: Record<string, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TableColumnDef<
|
|
||||||
TData,
|
|
||||||
TKey extends keyof TData = any,
|
|
||||||
TValue = TData[TKey]
|
|
||||||
> = {
|
|
||||||
id: string;
|
|
||||||
header: string | (() => ReactNode);
|
|
||||||
cell: (context: TableCellContext<TData, TValue>) => ReactNode;
|
|
||||||
accessorKey?: TKey;
|
|
||||||
accessorFn?: (row: TData) => TValue;
|
|
||||||
pin?: 'left' | 'right';
|
|
||||||
enableMove?: boolean;
|
|
||||||
enableResize?: boolean;
|
|
||||||
enableRemove?: boolean;
|
|
||||||
enableSort?: boolean;
|
|
||||||
/** Default visibility when no persisted state exists. Default: true */
|
|
||||||
defaultVisibility?: boolean;
|
|
||||||
/** Whether user can hide this column. Default: true */
|
|
||||||
canBeHidden?: boolean;
|
|
||||||
/**
|
|
||||||
* Visibility behavior for grouped views:
|
|
||||||
* - 'hidden-on-expand': Hide when rows are expanded (grouped view)
|
|
||||||
* - 'hidden-on-collapse': Hide when rows are collapsed (ungrouped view)
|
|
||||||
* - 'always-visible': Always show regardless of grouping
|
|
||||||
* Default: 'always-visible'
|
|
||||||
*/
|
|
||||||
visibilityBehavior?:
|
|
||||||
| 'hidden-on-expand'
|
|
||||||
| 'hidden-on-collapse'
|
|
||||||
| 'always-visible';
|
|
||||||
width?: {
|
|
||||||
fixed?: number | string;
|
|
||||||
min?: number | string;
|
|
||||||
default?: number | string;
|
|
||||||
max?: number | string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FlatItem<TData> =
|
|
||||||
| { kind: 'row'; row: TanStackRowType<TData> }
|
|
||||||
| { kind: 'expansion'; row: TanStackRowType<TData> };
|
|
||||||
|
|
||||||
export type TableRowContext<TData> = {
|
|
||||||
getRowStyle?: (row: TData) => CSSProperties;
|
|
||||||
getRowClassName?: (row: TData) => string;
|
|
||||||
isRowActive?: (row: TData) => boolean;
|
|
||||||
renderRowActions?: (row: TData) => ReactNode;
|
|
||||||
onRowClick?: (row: TData, itemKey: string) => void;
|
|
||||||
/** Called when ctrl+click or cmd+click on a row */
|
|
||||||
onRowClickNewTab?: (row: TData, itemKey: string) => void;
|
|
||||||
onRowDeactivate?: () => void;
|
|
||||||
renderExpandedRow?: (
|
|
||||||
row: TData,
|
|
||||||
rowKey: string,
|
|
||||||
groupMeta?: Record<string, string>,
|
|
||||||
) => ReactNode;
|
|
||||||
/** Get key data for a row by index */
|
|
||||||
getRowKeyData?: (index: number) => RowKeyData | undefined;
|
|
||||||
colCount: number;
|
|
||||||
isDarkMode?: boolean;
|
|
||||||
/** When set, primitive cell output (string/number/boolean) is wrapped with typography + line-clamp (see `plainTextCellLineClamp` on the table). */
|
|
||||||
plainTextCellLineClamp?: number;
|
|
||||||
/** Whether there's only one non-pinned column that can be removed */
|
|
||||||
hasSingleColumn: boolean;
|
|
||||||
/** Column order key for memo invalidation on reorder */
|
|
||||||
columnOrderKey: string;
|
|
||||||
/** Column visibility key for memo invalidation on visibility change */
|
|
||||||
columnVisibilityKey: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PaginationProps = {
|
|
||||||
total: number;
|
|
||||||
defaultPage?: number;
|
|
||||||
defaultLimit?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TanstackTableQueryParamsConfig = {
|
|
||||||
page: string;
|
|
||||||
limit: string;
|
|
||||||
orderBy: string;
|
|
||||||
expanded: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TanStackTableProps<TData> = {
|
|
||||||
data: TData[];
|
|
||||||
columns: TableColumnDef<TData>[];
|
|
||||||
/** Storage key for column state persistence (visibility, sizing, ordering). When set, enables unified column management. */
|
|
||||||
columnStorageKey?: string;
|
|
||||||
columnSizing?: ColumnSizingState;
|
|
||||||
onColumnSizingChange?: Dispatch<SetStateAction<ColumnSizingState>>;
|
|
||||||
onColumnOrderChange?: (cols: TableColumnDef<TData>[]) => void;
|
|
||||||
/** Called when a column is removed via the header menu. Use this to sync with external column preferences. */
|
|
||||||
onColumnRemove?: (columnId: string) => void;
|
|
||||||
isLoading?: boolean;
|
|
||||||
/** Number of skeleton rows to show when loading with no data. Default: 10 */
|
|
||||||
skeletonRowCount?: number;
|
|
||||||
enableQueryParams?: boolean | string | TanstackTableQueryParamsConfig;
|
|
||||||
pagination?: PaginationProps;
|
|
||||||
onEndReached?: (index: number) => void;
|
|
||||||
/** Function to get the unique key for a row (before duplicate handling).
|
|
||||||
* When set, enables automatic duplicate key detection and group-aware key composition. */
|
|
||||||
getRowKey?: (row: TData) => string;
|
|
||||||
/** Function to get the business/selection key. Defaults to getRowKey result. */
|
|
||||||
getItemKey?: (row: TData) => string;
|
|
||||||
/** When set, enables group-aware key generation (prefixes rowKey with group values). */
|
|
||||||
groupBy?: Array<{ key: string }>;
|
|
||||||
/** Extract group metadata from a row. Required when groupBy is set. */
|
|
||||||
getGroupKey?: (row: TData) => Record<string, string>;
|
|
||||||
getRowStyle?: (row: TData) => CSSProperties;
|
|
||||||
getRowClassName?: (row: TData) => string;
|
|
||||||
isRowActive?: (row: TData) => boolean;
|
|
||||||
renderRowActions?: (row: TData) => ReactNode;
|
|
||||||
onRowClick?: (row: TData, itemKey: string) => void;
|
|
||||||
/** Called when ctrl+click or cmd+click on a row */
|
|
||||||
onRowClickNewTab?: (row: TData, itemKey: string) => void;
|
|
||||||
onRowDeactivate?: () => void;
|
|
||||||
activeRowIndex?: number;
|
|
||||||
renderExpandedRow?: (
|
|
||||||
row: TData,
|
|
||||||
rowKey: string,
|
|
||||||
groupMeta?: Record<string, string>,
|
|
||||||
) => ReactNode;
|
|
||||||
getRowCanExpand?: (row: TData) => boolean;
|
|
||||||
/**
|
|
||||||
* Primitive cell values use `--tanstack-plain-cell-*` from the scroll container when `cellTypographySize` is set.
|
|
||||||
*/
|
|
||||||
plainTextCellLineClamp?: number;
|
|
||||||
/** Optional CSS-module typography tier for the scroll root (`--tanstack-plain-cell-font-size` / line-height + header `th`). */
|
|
||||||
cellTypographySize?: CellTypographySize;
|
|
||||||
/** Spread onto the Virtuoso scroll container. `data` is omitted — reserved by Virtuoso. */
|
|
||||||
tableScrollerProps?: Omit<HTMLAttributes<HTMLDivElement>, 'data'>;
|
|
||||||
className?: string;
|
|
||||||
testId?: string;
|
|
||||||
/** Content rendered before the pagination controls */
|
|
||||||
prefixPaginationContent?: ReactNode;
|
|
||||||
/** Content rendered after the pagination controls */
|
|
||||||
suffixPaginationContent?: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TanStackTableHandle = TableVirtuosoHandle & {
|
|
||||||
goToPage: (page: number) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TableColumnsState<TData> = {
|
|
||||||
columns: TableColumnDef<TData>[];
|
|
||||||
columnSizing: ColumnSizingState;
|
|
||||||
onColumnSizingChange: Dispatch<SetStateAction<ColumnSizingState>>;
|
|
||||||
onColumnOrderChange: (cols: TableColumnDef<TData>[]) => void;
|
|
||||||
onRemoveColumn: (id: string) => void;
|
|
||||||
};
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
|
||||||
import {
|
|
||||||
DragEndEvent,
|
|
||||||
PointerSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
} from '@dnd-kit/core';
|
|
||||||
import { arrayMove } from '@dnd-kit/sortable';
|
|
||||||
|
|
||||||
import { TableColumnDef } from './types';
|
|
||||||
|
|
||||||
export interface UseColumnDndOptions<TData> {
|
|
||||||
columns: TableColumnDef<TData>[];
|
|
||||||
onColumnOrderChange: (columns: TableColumnDef<TData>[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseColumnDndResult {
|
|
||||||
sensors: ReturnType<typeof useSensors>;
|
|
||||||
columnIds: string[];
|
|
||||||
handleDragEnd: (event: DragEndEvent) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets up drag-and-drop for column reordering.
|
|
||||||
*/
|
|
||||||
export function useColumnDnd<TData>({
|
|
||||||
columns,
|
|
||||||
onColumnOrderChange,
|
|
||||||
}: UseColumnDndOptions<TData>): UseColumnDndResult {
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const columnIds = useMemo(() => columns.map((c) => c.id), [columns]);
|
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
|
||||||
(event: DragEndEvent): void => {
|
|
||||||
const { active, over } = event;
|
|
||||||
if (!over || active.id === over.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const activeCol = columns.find((c) => c.id === String(active.id));
|
|
||||||
const overCol = columns.find((c) => c.id === String(over.id));
|
|
||||||
if (
|
|
||||||
!activeCol ||
|
|
||||||
!overCol ||
|
|
||||||
activeCol.pin != null ||
|
|
||||||
overCol.pin != null ||
|
|
||||||
activeCol.enableMove === false
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const oldIndex = columns.findIndex((c) => c.id === String(active.id));
|
|
||||||
const newIndex = columns.findIndex((c) => c.id === String(over.id));
|
|
||||||
if (oldIndex === -1 || newIndex === -1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onColumnOrderChange(arrayMove(columns, oldIndex, newIndex));
|
|
||||||
},
|
|
||||||
[columns, onColumnOrderChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
return { sensors, columnIds, handleDragEnd };
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import type { SetStateAction } from 'react';
|
|
||||||
import { useCallback } from 'react';
|
|
||||||
import type { ColumnSizingState } from '@tanstack/react-table';
|
|
||||||
|
|
||||||
import { TableColumnDef } from './types';
|
|
||||||
|
|
||||||
export interface UseColumnHandlersOptions<TData> {
|
|
||||||
/** Storage key for persisting column state (enables store mode) */
|
|
||||||
columnStorageKey?: string;
|
|
||||||
effectiveSizing: ColumnSizingState;
|
|
||||||
storeSetSizing: (sizing: ColumnSizingState) => void;
|
|
||||||
storeSetOrder: (columns: TableColumnDef<TData>[]) => void;
|
|
||||||
hideColumn: (columnId: string) => void;
|
|
||||||
onColumnSizingChange?: (sizing: ColumnSizingState) => void;
|
|
||||||
onColumnOrderChange?: (columns: TableColumnDef<TData>[]) => void;
|
|
||||||
onColumnRemove?: (columnId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseColumnHandlersResult<TData> {
|
|
||||||
handleColumnSizingChange: (updater: SetStateAction<ColumnSizingState>) => void;
|
|
||||||
handleColumnOrderChange: (columns: TableColumnDef<TData>[]) => void;
|
|
||||||
handleRemoveColumn: (columnId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates handlers for column state changes that delegate to either
|
|
||||||
* the store (when columnStorageKey is provided) or prop callbacks.
|
|
||||||
*/
|
|
||||||
export function useColumnHandlers<TData>({
|
|
||||||
columnStorageKey,
|
|
||||||
effectiveSizing,
|
|
||||||
storeSetSizing,
|
|
||||||
storeSetOrder,
|
|
||||||
hideColumn,
|
|
||||||
onColumnSizingChange,
|
|
||||||
onColumnOrderChange,
|
|
||||||
onColumnRemove,
|
|
||||||
}: UseColumnHandlersOptions<TData>): UseColumnHandlersResult<TData> {
|
|
||||||
const handleColumnSizingChange = useCallback(
|
|
||||||
(updater: SetStateAction<ColumnSizingState>) => {
|
|
||||||
const next =
|
|
||||||
typeof updater === 'function' ? updater(effectiveSizing) : updater;
|
|
||||||
if (columnStorageKey) {
|
|
||||||
storeSetSizing(next);
|
|
||||||
}
|
|
||||||
onColumnSizingChange?.(next);
|
|
||||||
},
|
|
||||||
[columnStorageKey, effectiveSizing, storeSetSizing, onColumnSizingChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleColumnOrderChange = useCallback(
|
|
||||||
(cols: TableColumnDef<TData>[]) => {
|
|
||||||
if (columnStorageKey) {
|
|
||||||
storeSetOrder(cols);
|
|
||||||
}
|
|
||||||
onColumnOrderChange?.(cols);
|
|
||||||
},
|
|
||||||
[columnStorageKey, storeSetOrder, onColumnOrderChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRemoveColumn = useCallback(
|
|
||||||
(columnId: string) => {
|
|
||||||
if (columnStorageKey) {
|
|
||||||
hideColumn(columnId);
|
|
||||||
}
|
|
||||||
onColumnRemove?.(columnId);
|
|
||||||
},
|
|
||||||
[columnStorageKey, hideColumn, onColumnRemove],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
handleColumnSizingChange,
|
|
||||||
handleColumnOrderChange,
|
|
||||||
handleRemoveColumn,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
||||||
import { ColumnSizingState, VisibilityState } from '@tanstack/react-table';
|
|
||||||
|
|
||||||
import { TableColumnDef } from './types';
|
|
||||||
import {
|
|
||||||
cleanupStaleHiddenColumns as storeCleanupStaleHiddenColumns,
|
|
||||||
hideColumn as storeHideColumn,
|
|
||||||
initializeFromDefaults as storeInitializeFromDefaults,
|
|
||||||
resetToDefaults as storeResetToDefaults,
|
|
||||||
setColumnOrder as storeSetColumnOrder,
|
|
||||||
setColumnSizing as storeSetColumnSizing,
|
|
||||||
showColumn as storeShowColumn,
|
|
||||||
toggleColumn as storeToggleColumn,
|
|
||||||
useColumnOrder as useStoreOrder,
|
|
||||||
useColumnSizing as useStoreSizing,
|
|
||||||
useHiddenColumnIds,
|
|
||||||
} from './useColumnStore';
|
|
||||||
|
|
||||||
type UseColumnStateOptions<TData> = {
|
|
||||||
storageKey?: string;
|
|
||||||
columns: TableColumnDef<TData>[];
|
|
||||||
isGrouped?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type UseColumnStateResult<TData> = {
|
|
||||||
columnVisibility: VisibilityState;
|
|
||||||
columnSizing: ColumnSizingState;
|
|
||||||
/** Columns sorted by persisted order (pinned first) */
|
|
||||||
sortedColumns: TableColumnDef<TData>[];
|
|
||||||
hiddenColumnIds: string[];
|
|
||||||
hideColumn: (columnId: string) => void;
|
|
||||||
showColumn: (columnId: string) => void;
|
|
||||||
toggleColumn: (columnId: string) => void;
|
|
||||||
setColumnSizing: (sizing: ColumnSizingState) => void;
|
|
||||||
setColumnOrder: (columns: TableColumnDef<TData>[]) => void;
|
|
||||||
resetToDefaults: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useColumnState<TData>({
|
|
||||||
storageKey,
|
|
||||||
columns,
|
|
||||||
isGrouped = false,
|
|
||||||
}: UseColumnStateOptions<TData>): UseColumnStateResult<TData> {
|
|
||||||
useEffect(() => {
|
|
||||||
if (storageKey) {
|
|
||||||
storeInitializeFromDefaults(storageKey, columns);
|
|
||||||
}
|
|
||||||
// Only run on mount, not when columns change
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [storageKey]);
|
|
||||||
|
|
||||||
const rawHiddenColumnIds = useHiddenColumnIds(storageKey ?? '');
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
function cleanupHiddenColumnIdsNoLongerInDefinitions(): void {
|
|
||||||
if (!storageKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const validColumnIds = new Set(columns.map((c) => c.id));
|
|
||||||
storeCleanupStaleHiddenColumns(storageKey, validColumnIds);
|
|
||||||
},
|
|
||||||
[storageKey, columns],
|
|
||||||
);
|
|
||||||
|
|
||||||
const columnSizing = useStoreSizing(storageKey ?? '');
|
|
||||||
const prevColumnIdsRef = useRef<Set<string> | null>(null);
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
function autoShowNewlyAddedColumns(): void {
|
|
||||||
if (!storageKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentIds = new Set(columns.map((c) => c.id));
|
|
||||||
|
|
||||||
// Skip first render - just record the initial columns
|
|
||||||
if (prevColumnIdsRef.current === null) {
|
|
||||||
prevColumnIdsRef.current = currentIds;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const prevIds = prevColumnIdsRef.current;
|
|
||||||
|
|
||||||
// Find columns that are new (in current but not in previous)
|
|
||||||
for (const id of currentIds) {
|
|
||||||
if (!prevIds.has(id) && rawHiddenColumnIds.includes(id)) {
|
|
||||||
// Column was just added and is hidden - show it
|
|
||||||
storeShowColumn(storageKey, id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
prevColumnIdsRef.current = currentIds;
|
|
||||||
},
|
|
||||||
[storageKey, columns, rawHiddenColumnIds],
|
|
||||||
);
|
|
||||||
|
|
||||||
const columnOrder = useStoreOrder(storageKey ?? '');
|
|
||||||
const columnMap = useMemo(() => new Map(columns.map((c) => [c.id, c])), [
|
|
||||||
columns,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const hiddenColumnIds = useMemo(
|
|
||||||
() =>
|
|
||||||
rawHiddenColumnIds.filter((id) => {
|
|
||||||
const col = columnMap.get(id);
|
|
||||||
return col && col.canBeHidden !== false;
|
|
||||||
}),
|
|
||||||
[rawHiddenColumnIds, columnMap],
|
|
||||||
);
|
|
||||||
|
|
||||||
const columnVisibility = useMemo((): VisibilityState => {
|
|
||||||
const visibility: VisibilityState = {};
|
|
||||||
|
|
||||||
for (const id of hiddenColumnIds) {
|
|
||||||
visibility[id] = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const column of columns) {
|
|
||||||
if (column.visibilityBehavior === 'hidden-on-expand' && isGrouped) {
|
|
||||||
visibility[column.id] = false;
|
|
||||||
}
|
|
||||||
if (column.visibilityBehavior === 'hidden-on-collapse' && !isGrouped) {
|
|
||||||
visibility[column.id] = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return visibility;
|
|
||||||
}, [hiddenColumnIds, columns, isGrouped]);
|
|
||||||
|
|
||||||
const sortedColumns = useMemo((): TableColumnDef<TData>[] => {
|
|
||||||
if (columnOrder.length === 0) {
|
|
||||||
return columns;
|
|
||||||
}
|
|
||||||
|
|
||||||
const orderMap = new Map(columnOrder.map((id, i) => [id, i]));
|
|
||||||
const pinned = columns.filter((c) => c.pin != null);
|
|
||||||
const rest = columns.filter((c) => c.pin == null);
|
|
||||||
const sortedRest = [...rest].sort((a, b) => {
|
|
||||||
const ai = orderMap.get(a.id) ?? Infinity;
|
|
||||||
const bi = orderMap.get(b.id) ?? Infinity;
|
|
||||||
return ai - bi;
|
|
||||||
});
|
|
||||||
|
|
||||||
return [...pinned, ...sortedRest];
|
|
||||||
}, [columns, columnOrder]);
|
|
||||||
|
|
||||||
const hideColumn = useCallback(
|
|
||||||
(columnId: string) => {
|
|
||||||
if (!storageKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Prevent hiding columns with canBeHidden: false
|
|
||||||
const col = columnMap.get(columnId);
|
|
||||||
if (col && col.canBeHidden === false) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
storeHideColumn(storageKey, columnId);
|
|
||||||
},
|
|
||||||
[storageKey, columnMap],
|
|
||||||
);
|
|
||||||
|
|
||||||
const showColumn = useCallback(
|
|
||||||
(columnId: string) => {
|
|
||||||
if (storageKey) {
|
|
||||||
storeShowColumn(storageKey, columnId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[storageKey],
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggleColumn = useCallback(
|
|
||||||
(columnId: string) => {
|
|
||||||
if (!storageKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const col = columnMap.get(columnId);
|
|
||||||
const isCurrentlyHidden = hiddenColumnIds.includes(columnId);
|
|
||||||
if (col && col.canBeHidden === false && !isCurrentlyHidden) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
storeToggleColumn(storageKey, columnId);
|
|
||||||
},
|
|
||||||
[storageKey, columnMap, hiddenColumnIds],
|
|
||||||
);
|
|
||||||
|
|
||||||
const setColumnSizing = useCallback(
|
|
||||||
(sizing: ColumnSizingState) => {
|
|
||||||
if (storageKey) {
|
|
||||||
storeSetColumnSizing(storageKey, sizing);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[storageKey],
|
|
||||||
);
|
|
||||||
|
|
||||||
const setColumnOrder = useCallback(
|
|
||||||
(cols: TableColumnDef<TData>[]) => {
|
|
||||||
if (storageKey) {
|
|
||||||
storeSetColumnOrder(
|
|
||||||
storageKey,
|
|
||||||
cols.map((c) => c.id),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[storageKey],
|
|
||||||
);
|
|
||||||
|
|
||||||
const resetToDefaults = useCallback(() => {
|
|
||||||
if (storageKey) {
|
|
||||||
storeResetToDefaults(storageKey, columns);
|
|
||||||
}
|
|
||||||
}, [storageKey, columns]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
columnVisibility,
|
|
||||||
columnSizing,
|
|
||||||
sortedColumns,
|
|
||||||
hiddenColumnIds,
|
|
||||||
hideColumn,
|
|
||||||
showColumn,
|
|
||||||
toggleColumn,
|
|
||||||
setColumnSizing,
|
|
||||||
setColumnOrder,
|
|
||||||
resetToDefaults,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,328 +0,0 @@
|
|||||||
import { ColumnSizingState } from '@tanstack/react-table';
|
|
||||||
import { create } from 'zustand';
|
|
||||||
|
|
||||||
import { TableColumnDef } from './types';
|
|
||||||
|
|
||||||
const STORAGE_PREFIX = '@signoz/table-columns/';
|
|
||||||
|
|
||||||
const persistedTableCache = new Map<
|
|
||||||
string,
|
|
||||||
{ raw: string; parsed: ColumnState }
|
|
||||||
>();
|
|
||||||
|
|
||||||
type ColumnState = {
|
|
||||||
hiddenColumnIds: string[];
|
|
||||||
columnOrder: string[];
|
|
||||||
columnSizing: ColumnSizingState;
|
|
||||||
};
|
|
||||||
|
|
||||||
const EMPTY_STATE: ColumnState = {
|
|
||||||
hiddenColumnIds: [],
|
|
||||||
columnOrder: [],
|
|
||||||
columnSizing: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
type ColumnStoreState = {
|
|
||||||
tables: Record<string, ColumnState>;
|
|
||||||
hideColumn: (storageKey: string, columnId: string) => void;
|
|
||||||
showColumn: (storageKey: string, columnId: string) => void;
|
|
||||||
toggleColumn: (storageKey: string, columnId: string) => void;
|
|
||||||
setColumnSizing: (storageKey: string, sizing: ColumnSizingState) => void;
|
|
||||||
setColumnOrder: (storageKey: string, order: string[]) => void;
|
|
||||||
initializeFromDefaults: <TData>(
|
|
||||||
storageKey: string,
|
|
||||||
columns: TableColumnDef<TData>[],
|
|
||||||
) => void;
|
|
||||||
resetToDefaults: <TData>(
|
|
||||||
storageKey: string,
|
|
||||||
columns: TableColumnDef<TData>[],
|
|
||||||
) => void;
|
|
||||||
cleanupStaleHiddenColumns: (
|
|
||||||
storageKey: string,
|
|
||||||
validColumnIds: Set<string>,
|
|
||||||
) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDefaultHiddenIds = <TData>(
|
|
||||||
columns: TableColumnDef<TData>[],
|
|
||||||
): string[] =>
|
|
||||||
columns.filter((c) => c.defaultVisibility === false).map((c) => c.id);
|
|
||||||
|
|
||||||
const getStorageKeyForTable = (tableKey: string): string =>
|
|
||||||
`${STORAGE_PREFIX}${tableKey}`;
|
|
||||||
|
|
||||||
const loadTableFromStorage = (tableKey: string): ColumnState | null => {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(getStorageKeyForTable(tableKey));
|
|
||||||
if (!raw) {
|
|
||||||
persistedTableCache.delete(tableKey);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cached = persistedTableCache.get(tableKey);
|
|
||||||
if (cached && cached.raw === raw) {
|
|
||||||
return cached.parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = JSON.parse(raw) as ColumnState;
|
|
||||||
persistedTableCache.set(tableKey, { raw, parsed });
|
|
||||||
return parsed;
|
|
||||||
} catch {
|
|
||||||
persistedTableCache.delete(tableKey);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveTableToStorage = (tableKey: string, state: ColumnState): void => {
|
|
||||||
try {
|
|
||||||
const raw = JSON.stringify(state);
|
|
||||||
localStorage.setItem(getStorageKeyForTable(tableKey), raw);
|
|
||||||
persistedTableCache.set(tableKey, { raw, parsed: state });
|
|
||||||
} catch {
|
|
||||||
// Ignore storage errors (e.g., private browsing quota exceeded)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useColumnStore = create<ColumnStoreState>()((set, get) => {
|
|
||||||
return {
|
|
||||||
tables: {},
|
|
||||||
hideColumn: (storageKey, columnId): void => {
|
|
||||||
const state = get();
|
|
||||||
let table = state.tables[storageKey];
|
|
||||||
|
|
||||||
// Lazy load from storage if not in memory
|
|
||||||
if (!table) {
|
|
||||||
const persisted = loadTableFromStorage(storageKey);
|
|
||||||
if (persisted) {
|
|
||||||
table = persisted;
|
|
||||||
set({ tables: { ...state.tables, [storageKey]: table } });
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (table.hiddenColumnIds.includes(columnId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextTable = {
|
|
||||||
...table,
|
|
||||||
hiddenColumnIds: [...table.hiddenColumnIds, columnId],
|
|
||||||
};
|
|
||||||
set({ tables: { ...get().tables, [storageKey]: nextTable } });
|
|
||||||
saveTableToStorage(storageKey, nextTable);
|
|
||||||
},
|
|
||||||
showColumn: (storageKey, columnId): void => {
|
|
||||||
const state = get();
|
|
||||||
let table = state.tables[storageKey];
|
|
||||||
|
|
||||||
if (!table) {
|
|
||||||
const persisted = loadTableFromStorage(storageKey);
|
|
||||||
if (persisted) {
|
|
||||||
table = persisted;
|
|
||||||
set({ tables: { ...state.tables, [storageKey]: table } });
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!table.hiddenColumnIds.includes(columnId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextTable = {
|
|
||||||
...table,
|
|
||||||
hiddenColumnIds: table.hiddenColumnIds.filter((id) => id !== columnId),
|
|
||||||
};
|
|
||||||
set({ tables: { ...get().tables, [storageKey]: nextTable } });
|
|
||||||
saveTableToStorage(storageKey, nextTable);
|
|
||||||
},
|
|
||||||
toggleColumn: (storageKey, columnId): void => {
|
|
||||||
const state = get();
|
|
||||||
let table = state.tables[storageKey];
|
|
||||||
|
|
||||||
if (!table) {
|
|
||||||
const persisted = loadTableFromStorage(storageKey);
|
|
||||||
if (persisted) {
|
|
||||||
table = persisted;
|
|
||||||
set({ tables: { ...state.tables, [storageKey]: table } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!table) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isHidden = table.hiddenColumnIds.includes(columnId);
|
|
||||||
if (isHidden) {
|
|
||||||
get().showColumn(storageKey, columnId);
|
|
||||||
} else {
|
|
||||||
get().hideColumn(storageKey, columnId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setColumnSizing: (storageKey, sizing): void => {
|
|
||||||
const state = get();
|
|
||||||
let table = state.tables[storageKey];
|
|
||||||
|
|
||||||
if (!table) {
|
|
||||||
const persisted = loadTableFromStorage(storageKey);
|
|
||||||
table = persisted ?? { ...EMPTY_STATE };
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextTable = {
|
|
||||||
...table,
|
|
||||||
columnSizing: sizing,
|
|
||||||
};
|
|
||||||
set({ tables: { ...get().tables, [storageKey]: nextTable } });
|
|
||||||
saveTableToStorage(storageKey, nextTable);
|
|
||||||
},
|
|
||||||
setColumnOrder: (storageKey, order): void => {
|
|
||||||
const state = get();
|
|
||||||
let table = state.tables[storageKey];
|
|
||||||
|
|
||||||
if (!table) {
|
|
||||||
const persisted = loadTableFromStorage(storageKey);
|
|
||||||
table = persisted ?? { ...EMPTY_STATE };
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextTable = {
|
|
||||||
...table,
|
|
||||||
columnOrder: order,
|
|
||||||
};
|
|
||||||
set({ tables: { ...get().tables, [storageKey]: nextTable } });
|
|
||||||
saveTableToStorage(storageKey, nextTable);
|
|
||||||
},
|
|
||||||
initializeFromDefaults: (storageKey, columns): void => {
|
|
||||||
const state = get();
|
|
||||||
|
|
||||||
if (state.tables[storageKey]) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const persisted = loadTableFromStorage(storageKey);
|
|
||||||
if (persisted) {
|
|
||||||
set({ tables: { ...state.tables, [storageKey]: persisted } });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newTable: ColumnState = {
|
|
||||||
hiddenColumnIds: getDefaultHiddenIds(columns),
|
|
||||||
columnOrder: [],
|
|
||||||
columnSizing: {},
|
|
||||||
};
|
|
||||||
set({ tables: { ...state.tables, [storageKey]: newTable } });
|
|
||||||
saveTableToStorage(storageKey, newTable);
|
|
||||||
},
|
|
||||||
|
|
||||||
resetToDefaults: (storageKey, columns): void => {
|
|
||||||
const newTable: ColumnState = {
|
|
||||||
hiddenColumnIds: getDefaultHiddenIds(columns),
|
|
||||||
columnOrder: [],
|
|
||||||
columnSizing: {},
|
|
||||||
};
|
|
||||||
set({ tables: { ...get().tables, [storageKey]: newTable } });
|
|
||||||
saveTableToStorage(storageKey, newTable);
|
|
||||||
},
|
|
||||||
|
|
||||||
cleanupStaleHiddenColumns: (storageKey, validColumnIds): void => {
|
|
||||||
const state = get();
|
|
||||||
let table = state.tables[storageKey];
|
|
||||||
|
|
||||||
if (!table) {
|
|
||||||
const persisted = loadTableFromStorage(storageKey);
|
|
||||||
if (!persisted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
table = persisted;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredHiddenIds = table.hiddenColumnIds.filter((id) =>
|
|
||||||
validColumnIds.has(id),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Only update if something changed
|
|
||||||
if (filteredHiddenIds.length === table.hiddenColumnIds.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextTable = {
|
|
||||||
...table,
|
|
||||||
hiddenColumnIds: filteredHiddenIds,
|
|
||||||
};
|
|
||||||
set({ tables: { ...get().tables, [storageKey]: nextTable } });
|
|
||||||
saveTableToStorage(storageKey, nextTable);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Stable empty references to avoid `Object.is` false-negatives when a key
|
|
||||||
// does not exist yet (returning a new `[]` / `{}` on every selector call
|
|
||||||
// would trigger React's useSyncExternalStore tearing detection).
|
|
||||||
const EMPTY_ARRAY: string[] = [];
|
|
||||||
const EMPTY_SIZING: ColumnSizingState = {};
|
|
||||||
|
|
||||||
export const useHiddenColumnIds = (storageKey: string): string[] =>
|
|
||||||
useColumnStore((s) => {
|
|
||||||
const table = s.tables[storageKey];
|
|
||||||
if (table) {
|
|
||||||
return table.hiddenColumnIds;
|
|
||||||
}
|
|
||||||
const persisted = loadTableFromStorage(storageKey);
|
|
||||||
return persisted?.hiddenColumnIds ?? EMPTY_ARRAY;
|
|
||||||
});
|
|
||||||
|
|
||||||
export const useColumnSizing = (storageKey: string): ColumnSizingState =>
|
|
||||||
useColumnStore((s) => {
|
|
||||||
const table = s.tables[storageKey];
|
|
||||||
if (table) {
|
|
||||||
return table.columnSizing;
|
|
||||||
}
|
|
||||||
const persisted = loadTableFromStorage(storageKey);
|
|
||||||
return persisted?.columnSizing ?? EMPTY_SIZING;
|
|
||||||
});
|
|
||||||
|
|
||||||
export const useColumnOrder = (storageKey: string): string[] =>
|
|
||||||
useColumnStore((s) => {
|
|
||||||
const table = s.tables[storageKey];
|
|
||||||
if (table) {
|
|
||||||
return table.columnOrder;
|
|
||||||
}
|
|
||||||
const persisted = loadTableFromStorage(storageKey);
|
|
||||||
return persisted?.columnOrder ?? EMPTY_ARRAY;
|
|
||||||
});
|
|
||||||
|
|
||||||
export const initializeFromDefaults = <TData>(
|
|
||||||
storageKey: string,
|
|
||||||
columns: TableColumnDef<TData>[],
|
|
||||||
): void =>
|
|
||||||
useColumnStore.getState().initializeFromDefaults(storageKey, columns);
|
|
||||||
|
|
||||||
export const hideColumn = (storageKey: string, columnId: string): void =>
|
|
||||||
useColumnStore.getState().hideColumn(storageKey, columnId);
|
|
||||||
|
|
||||||
export const showColumn = (storageKey: string, columnId: string): void =>
|
|
||||||
useColumnStore.getState().showColumn(storageKey, columnId);
|
|
||||||
|
|
||||||
export const toggleColumn = (storageKey: string, columnId: string): void =>
|
|
||||||
useColumnStore.getState().toggleColumn(storageKey, columnId);
|
|
||||||
|
|
||||||
export const setColumnSizing = (
|
|
||||||
storageKey: string,
|
|
||||||
sizing: ColumnSizingState,
|
|
||||||
): void => useColumnStore.getState().setColumnSizing(storageKey, sizing);
|
|
||||||
|
|
||||||
export const setColumnOrder = (storageKey: string, order: string[]): void =>
|
|
||||||
useColumnStore.getState().setColumnOrder(storageKey, order);
|
|
||||||
|
|
||||||
export const resetToDefaults = <TData>(
|
|
||||||
storageKey: string,
|
|
||||||
columns: TableColumnDef<TData>[],
|
|
||||||
): void => useColumnStore.getState().resetToDefaults(storageKey, columns);
|
|
||||||
|
|
||||||
export const cleanupStaleHiddenColumns = (
|
|
||||||
storageKey: string,
|
|
||||||
validColumnIds: Set<string>,
|
|
||||||
): void =>
|
|
||||||
useColumnStore
|
|
||||||
.getState()
|
|
||||||
.cleanupStaleHiddenColumns(storageKey, validColumnIds);
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { useMemo, useRef } from 'react';
|
|
||||||
|
|
||||||
export interface UseEffectiveDataOptions {
|
|
||||||
data: unknown[];
|
|
||||||
isLoading: boolean;
|
|
||||||
limit?: number;
|
|
||||||
skeletonRowCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages effective data for the table, handling loading states gracefully.
|
|
||||||
*/
|
|
||||||
export function useEffectiveData<TData>({
|
|
||||||
data,
|
|
||||||
isLoading,
|
|
||||||
limit,
|
|
||||||
skeletonRowCount = 10,
|
|
||||||
}: UseEffectiveDataOptions): TData[] {
|
|
||||||
const prevDataRef = useRef<TData[]>(data as TData[]);
|
|
||||||
const prevDataSizeRef = useRef(data.length || limit || skeletonRowCount);
|
|
||||||
|
|
||||||
// Update refs when we have real data (not loading)
|
|
||||||
if (!isLoading && data.length > 0) {
|
|
||||||
prevDataRef.current = data as TData[];
|
|
||||||
prevDataSizeRef.current = data.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
return useMemo((): TData[] => {
|
|
||||||
if (data.length > 0) {
|
|
||||||
return data as TData[];
|
|
||||||
}
|
|
||||||
if (prevDataRef.current.length > 0) {
|
|
||||||
return prevDataRef.current;
|
|
||||||
}
|
|
||||||
if (isLoading) {
|
|
||||||
const fakeCount = prevDataSizeRef.current || limit || skeletonRowCount;
|
|
||||||
return Array.from({ length: fakeCount }, (_, i) => ({
|
|
||||||
id: `skeleton-${i}`,
|
|
||||||
})) as TData[];
|
|
||||||
}
|
|
||||||
return data as TData[];
|
|
||||||
}, [isLoading, data, limit, skeletonRowCount]);
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
import type { ExpandedState, Row } from '@tanstack/react-table';
|
|
||||||
|
|
||||||
import { FlatItem } from './types';
|
|
||||||
|
|
||||||
export interface UseFlatItemsOptions<TData> {
|
|
||||||
tableRows: Row<TData>[];
|
|
||||||
/** Whether row expansion is enabled, needs to be unknown since it will be a function that can be updated/modified, boolean does not work well here */
|
|
||||||
renderExpandedRow?: unknown;
|
|
||||||
expanded: ExpandedState;
|
|
||||||
/** Index of the active row (for scroll-to behavior) */
|
|
||||||
activeRowIndex?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseFlatItemsResult<TData> {
|
|
||||||
flatItems: FlatItem<TData>[];
|
|
||||||
/** Index of active row in flatItems (-1 if not found) */
|
|
||||||
flatIndexForActiveRow: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flattens table rows with their expansion rows into a single list.
|
|
||||||
*
|
|
||||||
* When a row is expanded, an expansion item is inserted immediately after it.
|
|
||||||
* Also computes the flat index for the active row (used for scroll-to).
|
|
||||||
*/
|
|
||||||
export function useFlatItems<TData>({
|
|
||||||
tableRows,
|
|
||||||
renderExpandedRow,
|
|
||||||
expanded,
|
|
||||||
activeRowIndex,
|
|
||||||
}: UseFlatItemsOptions<TData>): UseFlatItemsResult<TData> {
|
|
||||||
const flatItems = useMemo<FlatItem<TData>[]>(() => {
|
|
||||||
const result: FlatItem<TData>[] = [];
|
|
||||||
for (const row of tableRows) {
|
|
||||||
result.push({ kind: 'row', row });
|
|
||||||
if (renderExpandedRow && row.getIsExpanded()) {
|
|
||||||
result.push({ kind: 'expansion', row });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
// expanded needs to be here, otherwise the rows are not updated when you click to expand
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [tableRows, renderExpandedRow, expanded]);
|
|
||||||
|
|
||||||
const flatIndexForActiveRow = useMemo(() => {
|
|
||||||
if (activeRowIndex == null || activeRowIndex < 0) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
for (let i = 0; i < flatItems.length; i++) {
|
|
||||||
const item = flatItems[i];
|
|
||||||
if (item.kind === 'row' && item.row.index === activeRowIndex) {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}, [activeRowIndex, flatItems]);
|
|
||||||
|
|
||||||
return { flatItems, flatIndexForActiveRow };
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
|
||||||
|
|
||||||
export interface RowKeyDataItem {
|
|
||||||
/** Final unique key for the row (with dedup suffix if needed) */
|
|
||||||
finalKey: string;
|
|
||||||
/** Item key for tracking (may differ from finalKey) */
|
|
||||||
itemKey: string;
|
|
||||||
/** Group metadata when grouped */
|
|
||||||
groupMeta: Record<string, string> | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseRowKeyDataOptions<TData> {
|
|
||||||
data: TData[];
|
|
||||||
isLoading: boolean;
|
|
||||||
getRowKey?: (item: TData) => string;
|
|
||||||
getItemKey?: (item: TData) => string;
|
|
||||||
groupBy?: Array<{ key: string }>;
|
|
||||||
getGroupKey?: (item: TData) => Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseRowKeyDataResult {
|
|
||||||
/** Array of key data for each row, undefined if getRowKey not provided or loading */
|
|
||||||
rowKeyData: RowKeyDataItem[] | undefined;
|
|
||||||
getRowKeyData: (index: number) => RowKeyDataItem | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Computes unique row keys with duplicate handling and group prefixes.
|
|
||||||
*/
|
|
||||||
export function useRowKeyData<TData>({
|
|
||||||
data,
|
|
||||||
isLoading,
|
|
||||||
getRowKey,
|
|
||||||
getItemKey,
|
|
||||||
groupBy,
|
|
||||||
getGroupKey,
|
|
||||||
}: UseRowKeyDataOptions<TData>): UseRowKeyDataResult {
|
|
||||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
|
||||||
const rowKeyData = useMemo((): RowKeyDataItem[] | undefined => {
|
|
||||||
if (!getRowKey || isLoading) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyCount = new Map<string, number>();
|
|
||||||
|
|
||||||
return data.map(
|
|
||||||
(item, index): RowKeyDataItem => {
|
|
||||||
const itemIdentifier = getRowKey(item);
|
|
||||||
const itemKey = getItemKey?.(item) ?? itemIdentifier;
|
|
||||||
const groupMeta = groupBy?.length ? getGroupKey?.(item) : undefined;
|
|
||||||
|
|
||||||
// Build rowKey with group prefix when grouped
|
|
||||||
let rowKey: string;
|
|
||||||
if (groupBy?.length && groupMeta) {
|
|
||||||
const groupKeyStr = Object.values(groupMeta).join('-');
|
|
||||||
if (groupKeyStr && itemIdentifier) {
|
|
||||||
rowKey = `${groupKeyStr}-${itemIdentifier}`;
|
|
||||||
} else {
|
|
||||||
rowKey = groupKeyStr || itemIdentifier || String(index);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
rowKey = itemIdentifier || String(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
const count = keyCount.get(rowKey) || 0;
|
|
||||||
keyCount.set(rowKey, count + 1);
|
|
||||||
const finalKey = count > 0 ? `${rowKey}-${count}` : rowKey;
|
|
||||||
|
|
||||||
return { finalKey, itemKey, groupMeta };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}, [data, getRowKey, getItemKey, groupBy, getGroupKey, isLoading]);
|
|
||||||
|
|
||||||
const getRowKeyData = useCallback((index: number) => rowKeyData?.[index], [
|
|
||||||
rowKeyData,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return { rowKeyData, getRowKeyData };
|
|
||||||
}
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import type { ExpandedState, Updater } from '@tanstack/react-table';
|
|
||||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
|
||||||
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
|
|
||||||
|
|
||||||
import { SortState, TanstackTableQueryParamsConfig } from './types';
|
|
||||||
|
|
||||||
const NUQS_OPTIONS = { history: 'push' as const };
|
|
||||||
const DEFAULT_PAGE = 1;
|
|
||||||
const DEFAULT_LIMIT = 50;
|
|
||||||
|
|
||||||
type Defaults = {
|
|
||||||
page?: number;
|
|
||||||
limit?: number;
|
|
||||||
orderBy?: SortState | null;
|
|
||||||
expanded?: ExpandedState;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TableParamsResult = {
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
orderBy: SortState | null;
|
|
||||||
expanded: ExpandedState;
|
|
||||||
setPage: (p: number) => void;
|
|
||||||
setLimit: (l: number) => void;
|
|
||||||
setOrderBy: (s: SortState | null) => void;
|
|
||||||
setExpanded: (updaterOrValue: Updater<ExpandedState>) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function expandedStateToArray(state: ExpandedState): string[] {
|
|
||||||
if (typeof state === 'boolean') {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return Object.entries(state)
|
|
||||||
.filter(([, v]) => v)
|
|
||||||
.map(([k]) => k);
|
|
||||||
}
|
|
||||||
|
|
||||||
function arrayToExpandedState(arr: string[]): ExpandedState {
|
|
||||||
const result: Record<string, boolean> = {};
|
|
||||||
for (const id of arr) {
|
|
||||||
result[id] = true;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
|
||||||
export function useTableParams(
|
|
||||||
enableQueryParams?: boolean | string | TanstackTableQueryParamsConfig,
|
|
||||||
defaults?: Defaults,
|
|
||||||
): TableParamsResult {
|
|
||||||
const pageQueryParam =
|
|
||||||
typeof enableQueryParams === 'string'
|
|
||||||
? `${enableQueryParams}_page`
|
|
||||||
: typeof enableQueryParams === 'object'
|
|
||||||
? enableQueryParams.page
|
|
||||||
: 'page';
|
|
||||||
const limitQueryParam =
|
|
||||||
typeof enableQueryParams === 'string'
|
|
||||||
? `${enableQueryParams}_limit`
|
|
||||||
: typeof enableQueryParams === 'object'
|
|
||||||
? enableQueryParams.limit
|
|
||||||
: 'limit';
|
|
||||||
const orderByQueryParam =
|
|
||||||
typeof enableQueryParams === 'string'
|
|
||||||
? `${enableQueryParams}_order_by`
|
|
||||||
: typeof enableQueryParams === 'object'
|
|
||||||
? enableQueryParams.orderBy
|
|
||||||
: 'order_by';
|
|
||||||
const expandedQueryParam =
|
|
||||||
typeof enableQueryParams === 'string'
|
|
||||||
? `${enableQueryParams}_expanded`
|
|
||||||
: typeof enableQueryParams === 'object'
|
|
||||||
? enableQueryParams.expanded
|
|
||||||
: 'expanded';
|
|
||||||
const pageDefault = defaults?.page ?? DEFAULT_PAGE;
|
|
||||||
const limitDefault = defaults?.limit ?? DEFAULT_LIMIT;
|
|
||||||
const orderByDefault = defaults?.orderBy ?? null;
|
|
||||||
const expandedDefault = defaults?.expanded ?? {};
|
|
||||||
const expandedDefaultArray = useMemo(
|
|
||||||
() => expandedStateToArray(expandedDefault),
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [localPage, setLocalPage] = useState(pageDefault);
|
|
||||||
const [localLimit, setLocalLimit] = useState(limitDefault);
|
|
||||||
const [localOrderBy, setLocalOrderBy] = useState<SortState | null>(
|
|
||||||
orderByDefault,
|
|
||||||
);
|
|
||||||
const [localExpanded, setLocalExpanded] = useState<ExpandedState>(
|
|
||||||
expandedDefault,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [urlPage, setUrlPage] = useQueryState(
|
|
||||||
pageQueryParam,
|
|
||||||
parseAsInteger.withDefault(pageDefault).withOptions(NUQS_OPTIONS),
|
|
||||||
);
|
|
||||||
const [urlLimit, setUrlLimit] = useQueryState(
|
|
||||||
limitQueryParam,
|
|
||||||
parseAsInteger.withDefault(limitDefault).withOptions(NUQS_OPTIONS),
|
|
||||||
);
|
|
||||||
const [urlOrderBy, setUrlOrderBy] = useQueryState(
|
|
||||||
orderByQueryParam,
|
|
||||||
parseAsJsonNoValidate<SortState | null>()
|
|
||||||
.withDefault(orderByDefault as never)
|
|
||||||
.withOptions(NUQS_OPTIONS),
|
|
||||||
);
|
|
||||||
const [urlExpandedArray, setUrlExpandedArray] = useQueryState(
|
|
||||||
expandedQueryParam,
|
|
||||||
parseAsJsonNoValidate<string[]>()
|
|
||||||
.withDefault(expandedDefaultArray as never)
|
|
||||||
.withOptions(NUQS_OPTIONS),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Convert URL array to ExpandedState
|
|
||||||
const urlExpanded = useMemo(
|
|
||||||
() => arrayToExpandedState(urlExpandedArray ?? []),
|
|
||||||
[urlExpandedArray],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Keep ref for updater function access
|
|
||||||
const urlExpandedRef = useRef(urlExpanded);
|
|
||||||
urlExpandedRef.current = urlExpanded;
|
|
||||||
|
|
||||||
// Wrapper to convert ExpandedState to array when setting URL state
|
|
||||||
// Supports both direct values and updater functions (TanStack pattern)
|
|
||||||
const setUrlExpanded = useCallback(
|
|
||||||
(updaterOrValue: Updater<ExpandedState>): void => {
|
|
||||||
const newState =
|
|
||||||
typeof updaterOrValue === 'function'
|
|
||||||
? updaterOrValue(urlExpandedRef.current)
|
|
||||||
: updaterOrValue;
|
|
||||||
setUrlExpandedArray(expandedStateToArray(newState));
|
|
||||||
},
|
|
||||||
[setUrlExpandedArray],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Wrapper for local expanded to match TanStack's Updater pattern
|
|
||||||
const handleSetLocalExpanded = useCallback(
|
|
||||||
(updaterOrValue: Updater<ExpandedState>): void => {
|
|
||||||
setLocalExpanded((prev) =>
|
|
||||||
typeof updaterOrValue === 'function'
|
|
||||||
? updaterOrValue(prev)
|
|
||||||
: updaterOrValue,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const orderByDefaultMemoKey = `${orderByDefault?.columnName}${orderByDefault?.order}`;
|
|
||||||
const orderByUrlMemoKey = `${urlOrderBy?.columnName}${urlOrderBy?.order}`;
|
|
||||||
const isEnabledQueryParams =
|
|
||||||
typeof enableQueryParams === 'string' ||
|
|
||||||
typeof enableQueryParams === 'object';
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isEnabledQueryParams) {
|
|
||||||
setUrlPage(pageDefault);
|
|
||||||
} else {
|
|
||||||
setLocalPage(pageDefault);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
isEnabledQueryParams,
|
|
||||||
orderByDefaultMemoKey,
|
|
||||||
orderByUrlMemoKey,
|
|
||||||
pageDefault,
|
|
||||||
setUrlPage,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (enableQueryParams) {
|
|
||||||
return {
|
|
||||||
page: urlPage,
|
|
||||||
limit: urlLimit,
|
|
||||||
orderBy: urlOrderBy as SortState | null,
|
|
||||||
expanded: urlExpanded,
|
|
||||||
setPage: setUrlPage,
|
|
||||||
setLimit: setUrlLimit,
|
|
||||||
setOrderBy: setUrlOrderBy,
|
|
||||||
setExpanded: setUrlExpanded,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
page: localPage,
|
|
||||||
limit: localLimit,
|
|
||||||
orderBy: localOrderBy,
|
|
||||||
expanded: localExpanded,
|
|
||||||
setPage: setLocalPage,
|
|
||||||
setLimit: setLocalLimit,
|
|
||||||
setOrderBy: setLocalOrderBy,
|
|
||||||
setExpanded: handleSetLocalExpanded,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import type { CSSProperties, ReactNode } from 'react';
|
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
|
||||||
|
|
||||||
import { RowKeyData, TableColumnDef } from './types';
|
|
||||||
|
|
||||||
export const getColumnId = <TData>(column: TableColumnDef<TData>): string =>
|
|
||||||
column.id;
|
|
||||||
|
|
||||||
const DEFAULT_MIN_WIDTH = 192; // 12rem * 16px
|
|
||||||
|
|
||||||
export const getColumnWidthStyle = <TData>(
|
|
||||||
column: TableColumnDef<TData>,
|
|
||||||
/** Persisted width from user resizing (overrides defined width) */
|
|
||||||
persistedWidth?: number,
|
|
||||||
/** Last column always gets width: 100% and ignores other width settings */
|
|
||||||
isLastColumn?: boolean,
|
|
||||||
): CSSProperties => {
|
|
||||||
// Last column always fills remaining space
|
|
||||||
if (isLastColumn) {
|
|
||||||
return { width: '100%' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const { width } = column;
|
|
||||||
if (!width) {
|
|
||||||
return {
|
|
||||||
width: persistedWidth ?? DEFAULT_MIN_WIDTH,
|
|
||||||
minWidth: DEFAULT_MIN_WIDTH,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (width.fixed != null) {
|
|
||||||
return {
|
|
||||||
width: width.fixed,
|
|
||||||
minWidth: width.fixed,
|
|
||||||
maxWidth: width.fixed,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
width: persistedWidth ?? width.default ?? width.min,
|
|
||||||
minWidth: width.min ?? DEFAULT_MIN_WIDTH,
|
|
||||||
maxWidth: width.max,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildAccessorFn = <TData>(
|
|
||||||
colDef: TableColumnDef<TData>,
|
|
||||||
): ((row: TData) => unknown) => {
|
|
||||||
return (row: TData): unknown => {
|
|
||||||
if (colDef.accessorFn) {
|
|
||||||
return colDef.accessorFn(row);
|
|
||||||
}
|
|
||||||
if (colDef.accessorKey) {
|
|
||||||
return (row as Record<string, unknown>)[colDef.accessorKey];
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export function buildTanstackColumnDef<TData>(
|
|
||||||
colDef: TableColumnDef<TData>,
|
|
||||||
isRowActive?: (row: TData) => boolean,
|
|
||||||
getRowKeyData?: (index: number) => RowKeyData | undefined,
|
|
||||||
): ColumnDef<TData> {
|
|
||||||
const isFixed = colDef.width?.fixed != null;
|
|
||||||
const headerFn =
|
|
||||||
typeof colDef.header === 'function' ? colDef.header : undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: colDef.id,
|
|
||||||
header:
|
|
||||||
typeof colDef.header === 'string'
|
|
||||||
? colDef.header
|
|
||||||
: (): ReactNode => headerFn?.() ?? null,
|
|
||||||
accessorFn: buildAccessorFn(colDef),
|
|
||||||
enableResizing: colDef.enableResize !== false && !isFixed,
|
|
||||||
enableSorting: colDef.enableSort === true,
|
|
||||||
cell: ({ row, getValue }): ReactNode => {
|
|
||||||
const rowData = row.original;
|
|
||||||
const keyData = getRowKeyData?.(row.index);
|
|
||||||
return colDef.cell({
|
|
||||||
row: rowData,
|
|
||||||
value: getValue() as TData[any],
|
|
||||||
isActive: isRowActive?.(rowData) ?? false,
|
|
||||||
rowIndex: row.index,
|
|
||||||
isExpanded: row.getIsExpanded(),
|
|
||||||
canExpand: row.getCanExpand(),
|
|
||||||
toggleExpanded: (): void => {
|
|
||||||
row.toggleExpanded();
|
|
||||||
},
|
|
||||||
itemKey: keyData?.itemKey ?? '',
|
|
||||||
groupMeta: keyData?.groupMeta,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,36 +1,22 @@
|
|||||||
import type { CSSProperties, MouseEvent, ReactNode } from 'react';
|
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
|
||||||
import { useCopyToClipboard } from 'react-use';
|
|
||||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||||
import { toast } from '@signozhq/sonner';
|
|
||||||
import { Card, Typography } from 'antd';
|
import { Card, Typography } from 'antd';
|
||||||
import LogDetail from 'components/LogDetail';
|
import LogDetail from 'components/LogDetail';
|
||||||
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
|
||||||
import ListLogView from 'components/Logs/ListLogView';
|
import ListLogView from 'components/Logs/ListLogView';
|
||||||
import LogLinesActionButtons from 'components/Logs/LogLinesActionButtons/LogLinesActionButtons';
|
|
||||||
import { getRowBackgroundColor } from 'components/Logs/LogStateIndicator/getRowBackgroundColor';
|
|
||||||
import { getLogIndicatorType } from 'components/Logs/LogStateIndicator/utils';
|
|
||||||
import RawLogView from 'components/Logs/RawLogView';
|
import RawLogView from 'components/Logs/RawLogView';
|
||||||
import { useLogsTableColumns } from 'components/Logs/TableView/useLogsTableColumns';
|
|
||||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||||
import type { TanStackTableHandle } from 'components/TanStackTableView';
|
|
||||||
import TanStackTable from 'components/TanStackTableView';
|
|
||||||
import { useHiddenColumnIds } from 'components/TanStackTableView/useColumnStore';
|
|
||||||
import { CARD_BODY_STYLE } from 'constants/card';
|
import { CARD_BODY_STYLE } from 'constants/card';
|
||||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||||
import { OptionFormatTypes } from 'constants/optionsFormatTypes';
|
import { OptionFormatTypes } from 'constants/optionsFormatTypes';
|
||||||
import { QueryParams } from 'constants/query';
|
|
||||||
import { InfinityWrapperStyled } from 'container/LogsExplorerList/styles';
|
import { InfinityWrapperStyled } from 'container/LogsExplorerList/styles';
|
||||||
|
import TanStackTableView from 'container/LogsExplorerList/TanStackTableView';
|
||||||
import { convertKeysToColumnFields } from 'container/LogsExplorerList/utils';
|
import { convertKeysToColumnFields } from 'container/LogsExplorerList/utils';
|
||||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||||
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
|
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
|
||||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||||
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
|
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
|
||||||
import useScrollToLog from 'hooks/logs/useScrollToLog';
|
import useScrollToLog from 'hooks/logs/useScrollToLog';
|
||||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
|
||||||
import { useEventSource } from 'providers/EventSource';
|
import { useEventSource } from 'providers/EventSource';
|
||||||
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
|
|
||||||
// interfaces
|
// interfaces
|
||||||
import { ILog } from 'types/api/logs/log';
|
import { ILog } from 'types/api/logs/log';
|
||||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||||
@@ -44,17 +30,11 @@ function LiveLogsList({
|
|||||||
isLoading,
|
isLoading,
|
||||||
handleChangeSelectedView,
|
handleChangeSelectedView,
|
||||||
}: LiveLogsListProps): JSX.Element {
|
}: LiveLogsListProps): JSX.Element {
|
||||||
const ref = useRef<TanStackTableHandle | VirtuosoHandle | null>(null);
|
const ref = useRef<VirtuosoHandle>(null);
|
||||||
const { pathname } = useLocation();
|
|
||||||
const [, setCopy] = useCopyToClipboard();
|
|
||||||
const isDarkMode = useIsDarkMode();
|
|
||||||
|
|
||||||
const { isConnectionLoading } = useEventSource();
|
const { isConnectionLoading } = useEventSource();
|
||||||
|
|
||||||
const { activeLogId } = useCopyLogLink();
|
const { activeLogId } = useCopyLogLink();
|
||||||
const { logs: logsPreferences } = usePreferenceContext();
|
|
||||||
const hiddenColumnIds = useHiddenColumnIds(LOCALSTORAGE.LOGS_LIST_COLUMNS);
|
|
||||||
const hasReconciledHiddenColumnsRef = useRef(false);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
activeLog,
|
activeLog,
|
||||||
@@ -70,7 +50,7 @@ function LiveLogsList({
|
|||||||
[logs],
|
[logs],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { options } = useOptionsMenu({
|
const { options, config } = useOptionsMenu({
|
||||||
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||||
dataSource: DataSource.LOGS,
|
dataSource: DataSource.LOGS,
|
||||||
aggregateOperator: StringOperators.NOOP,
|
aggregateOperator: StringOperators.NOOP,
|
||||||
@@ -86,63 +66,9 @@ function LiveLogsList({
|
|||||||
...options.selectColumns,
|
...options.selectColumns,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const syncedSelectedColumns = useMemo(
|
|
||||||
() =>
|
|
||||||
options.selectColumns.filter(({ name }) => !hiddenColumnIds.includes(name)),
|
|
||||||
[options.selectColumns, hiddenColumnIds],
|
|
||||||
);
|
|
||||||
|
|
||||||
const logsColumns = useLogsTableColumns({
|
|
||||||
fields: selectedFields,
|
|
||||||
fontSize: options.fontSize,
|
|
||||||
appendTo: 'end',
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasReconciledHiddenColumnsRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
hasReconciledHiddenColumnsRef.current = true;
|
|
||||||
|
|
||||||
if (syncedSelectedColumns.length === options.selectColumns.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logsPreferences.updateColumns(syncedSelectedColumns);
|
|
||||||
}, [logsPreferences, options.selectColumns.length, syncedSelectedColumns]);
|
|
||||||
|
|
||||||
const handleColumnRemove = useCallback(
|
|
||||||
(columnId: string) => {
|
|
||||||
const updatedColumns = options.selectColumns.filter(
|
|
||||||
({ name }) => name !== columnId,
|
|
||||||
);
|
|
||||||
logsPreferences.updateColumns(updatedColumns);
|
|
||||||
},
|
|
||||||
[options.selectColumns, logsPreferences],
|
|
||||||
);
|
|
||||||
|
|
||||||
const makeOnLogCopy = useCallback(
|
|
||||||
(log: ILog) => (event: MouseEvent<HTMLElement>): void => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
const urlQuery = new URLSearchParams(window.location.search);
|
|
||||||
urlQuery.delete(QueryParams.activeLogId);
|
|
||||||
urlQuery.delete(QueryParams.relativeTime);
|
|
||||||
urlQuery.set(QueryParams.activeLogId, `"${log.id}"`);
|
|
||||||
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
|
|
||||||
setCopy(link);
|
|
||||||
toast.success('Copied to clipboard', { position: 'top-right' });
|
|
||||||
},
|
|
||||||
[pathname, setCopy],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleScrollToLog = useScrollToLog({
|
const handleScrollToLog = useScrollToLog({
|
||||||
logs: formattedLogs,
|
logs: formattedLogs,
|
||||||
virtuosoRef: ref as React.RefObject<Pick<
|
virtuosoRef: ref,
|
||||||
VirtuosoHandle,
|
|
||||||
'scrollToIndex'
|
|
||||||
> | null>,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const getItemContent = useCallback(
|
const getItemContent = useCallback(
|
||||||
@@ -232,49 +158,29 @@ function LiveLogsList({
|
|||||||
{formattedLogs.length !== 0 && (
|
{formattedLogs.length !== 0 && (
|
||||||
<InfinityWrapperStyled>
|
<InfinityWrapperStyled>
|
||||||
{options.format === OptionFormatTypes.TABLE ? (
|
{options.format === OptionFormatTypes.TABLE ? (
|
||||||
<TanStackTable<ILog>
|
<TanStackTableView
|
||||||
ref={ref as React.Ref<TanStackTableHandle>}
|
ref={ref}
|
||||||
columns={logsColumns}
|
|
||||||
columnStorageKey={LOCALSTORAGE.LOGS_LIST_COLUMNS}
|
|
||||||
onColumnRemove={handleColumnRemove}
|
|
||||||
plainTextCellLineClamp={options.maxLines}
|
|
||||||
cellTypographySize={options.fontSize}
|
|
||||||
data={formattedLogs}
|
|
||||||
isLoading={false}
|
isLoading={false}
|
||||||
isRowActive={(log): boolean => log.id === activeLog?.id}
|
tableViewProps={{
|
||||||
getRowStyle={(log): CSSProperties =>
|
logs: formattedLogs,
|
||||||
({
|
fields: selectedFields,
|
||||||
'--row-active-bg': getRowBackgroundColor(
|
linesPerRow: options.maxLines,
|
||||||
isDarkMode,
|
fontSize: options.fontSize,
|
||||||
getLogIndicatorType(log),
|
appendTo: 'end',
|
||||||
),
|
activeLogIndex,
|
||||||
'--row-hover-bg': getRowBackgroundColor(
|
|
||||||
isDarkMode,
|
|
||||||
getLogIndicatorType(log),
|
|
||||||
),
|
|
||||||
} as CSSProperties)
|
|
||||||
}
|
|
||||||
onRowClick={(log): void => {
|
|
||||||
handleSetActiveLog(log);
|
|
||||||
}}
|
}}
|
||||||
onRowDeactivate={handleCloseLogDetail}
|
handleChangeSelectedView={handleChangeSelectedView}
|
||||||
activeRowIndex={activeLogIndex}
|
logs={formattedLogs}
|
||||||
renderRowActions={(log): ReactNode => (
|
onSetActiveLog={handleSetActiveLog}
|
||||||
<LogLinesActionButtons
|
onClearActiveLog={handleCloseLogDetail}
|
||||||
handleShowContext={(e): void => {
|
activeLog={activeLog}
|
||||||
e.preventDefault();
|
onRemoveColumn={config.addColumn?.onRemove}
|
||||||
e.stopPropagation();
|
|
||||||
handleSetActiveLog(log, VIEW_TYPES.CONTEXT);
|
|
||||||
}}
|
|
||||||
onLogCopy={makeOnLogCopy(log)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Card style={{ width: '100%' }} bodyStyle={CARD_BODY_STYLE}>
|
<Card style={{ width: '100%' }} bodyStyle={CARD_BODY_STYLE}>
|
||||||
<OverlayScrollbar isVirtuoso>
|
<OverlayScrollbar isVirtuoso>
|
||||||
<Virtuoso
|
<Virtuoso
|
||||||
ref={ref as React.Ref<VirtuosoHandle>}
|
ref={ref}
|
||||||
initialTopMostItemIndex={activeLogIndex !== -1 ? activeLogIndex : 0}
|
initialTopMostItemIndex={activeLogIndex !== -1 ? activeLogIndex : 0}
|
||||||
data={formattedLogs}
|
data={formattedLogs}
|
||||||
totalCount={formattedLogs.length}
|
totalCount={formattedLogs.length}
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ function ColumnView({
|
|||||||
onColumnOrderChange(formattedColumns);
|
onColumnOrderChange(formattedColumns);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRowClick = (row: Row<Record<string, string>>): void => {
|
const handleRowClick = (row: Row<Record<string, unknown>>): void => {
|
||||||
const currentLog = logs.find(({ id }) => id === row.original.id);
|
const currentLog = logs.find(({ id }) => id === row.original.id);
|
||||||
|
|
||||||
setShowActiveLog(true);
|
setShowActiveLog(true);
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// eslint-disable-next-line no-restricted-imports
|
||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
const RowHoverContext = createContext(false);
|
||||||
|
|
||||||
|
export const useRowHover = (): boolean => useContext(RowHoverContext);
|
||||||
|
|
||||||
|
export default RowHoverContext;
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { ComponentProps, memo, useCallback, useState } from 'react';
|
||||||
|
import { TableComponents } from 'react-virtuoso';
|
||||||
|
import {
|
||||||
|
getLogIndicatorType,
|
||||||
|
getLogIndicatorTypeForTable,
|
||||||
|
} from 'components/Logs/LogStateIndicator/utils';
|
||||||
|
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||||
|
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||||
|
import { ILog } from 'types/api/logs/log';
|
||||||
|
|
||||||
|
import { TableRowStyled } from '../InfinityTableView/styles';
|
||||||
|
import RowHoverContext from '../RowHoverContext';
|
||||||
|
import { TanStackTableRowData } from './types';
|
||||||
|
|
||||||
|
export type TableRowContext = {
|
||||||
|
activeLog?: ILog | null;
|
||||||
|
activeContextLog?: ILog | null;
|
||||||
|
logsById: Map<string, ILog>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type VirtuosoTableRowProps = ComponentProps<
|
||||||
|
NonNullable<TableComponents<TanStackTableRowData, TableRowContext>['TableRow']>
|
||||||
|
>;
|
||||||
|
|
||||||
|
type TanStackCustomTableRowProps = VirtuosoTableRowProps;
|
||||||
|
|
||||||
|
function TanStackCustomTableRow({
|
||||||
|
children,
|
||||||
|
item,
|
||||||
|
context,
|
||||||
|
...props
|
||||||
|
}: TanStackCustomTableRowProps): JSX.Element {
|
||||||
|
const { isHighlighted } = useCopyLogLink(item.currentLog.id);
|
||||||
|
const isDarkMode = useIsDarkMode();
|
||||||
|
const [hasHovered, setHasHovered] = useState(false);
|
||||||
|
const rowId = String(item.currentLog.id ?? '');
|
||||||
|
const activeLog = context?.activeLog;
|
||||||
|
const activeContextLog = context?.activeContextLog;
|
||||||
|
const logsById = context?.logsById;
|
||||||
|
const rowLog = logsById?.get(rowId) || item.currentLog;
|
||||||
|
const logType = rowLog
|
||||||
|
? getLogIndicatorType(rowLog)
|
||||||
|
: getLogIndicatorTypeForTable(item.log);
|
||||||
|
|
||||||
|
const handleMouseEnter = useCallback(() => {
|
||||||
|
if (!hasHovered) {
|
||||||
|
setHasHovered(true);
|
||||||
|
}
|
||||||
|
}, [hasHovered]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RowHoverContext.Provider value={hasHovered}>
|
||||||
|
<TableRowStyled
|
||||||
|
{...props}
|
||||||
|
$isDarkMode={isDarkMode}
|
||||||
|
$isActiveLog={
|
||||||
|
isHighlighted ||
|
||||||
|
rowId === String(activeLog?.id ?? '') ||
|
||||||
|
rowId === String(activeContextLog?.id ?? '')
|
||||||
|
}
|
||||||
|
$logType={logType}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TableRowStyled>
|
||||||
|
</RowHoverContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(TanStackCustomTableRow, (prev, next) => {
|
||||||
|
const prevId = String(prev.item.currentLog.id ?? '');
|
||||||
|
const nextId = String(next.item.currentLog.id ?? '');
|
||||||
|
if (prevId !== nextId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevIsActive =
|
||||||
|
prevId === String(prev.context?.activeLog?.id ?? '') ||
|
||||||
|
prevId === String(prev.context?.activeContextLog?.id ?? '');
|
||||||
|
const nextIsActive =
|
||||||
|
nextId === String(next.context?.activeLog?.id ?? '') ||
|
||||||
|
nextId === String(next.context?.activeContextLog?.id ?? '');
|
||||||
|
return prevIsActive === nextIsActive;
|
||||||
|
});
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import type {
|
||||||
|
CSSProperties,
|
||||||
|
MouseEvent as ReactMouseEvent,
|
||||||
|
TouchEvent as ReactTouchEvent,
|
||||||
|
} from 'react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { CloseOutlined, MoreOutlined } from '@ant-design/icons';
|
||||||
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/popover';
|
||||||
|
import { flexRender, Header as TanStackHeader } from '@tanstack/react-table';
|
||||||
|
import { GripVertical } from 'lucide-react';
|
||||||
|
|
||||||
|
import { TableHeaderCellStyled } from '../InfinityTableView/styles';
|
||||||
|
import { InfinityTableProps } from '../InfinityTableView/types';
|
||||||
|
import { OrderedColumn, TanStackTableRowData } from './types';
|
||||||
|
import { getColumnId } from './utils';
|
||||||
|
|
||||||
|
import './styles/TanStackHeaderRow.styles.scss';
|
||||||
|
|
||||||
|
type TanStackHeaderRowProps = {
|
||||||
|
column: OrderedColumn;
|
||||||
|
header?: TanStackHeader<TanStackTableRowData, unknown>;
|
||||||
|
isDarkMode: boolean;
|
||||||
|
fontSize: InfinityTableProps['tableViewProps']['fontSize'];
|
||||||
|
hasSingleColumn: boolean;
|
||||||
|
canRemoveColumn?: boolean;
|
||||||
|
onRemoveColumn?: (columnKey: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GRIP_ICON_SIZE = 12;
|
||||||
|
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||||
|
function TanStackHeaderRow({
|
||||||
|
column,
|
||||||
|
header,
|
||||||
|
isDarkMode,
|
||||||
|
fontSize,
|
||||||
|
hasSingleColumn,
|
||||||
|
canRemoveColumn = false,
|
||||||
|
onRemoveColumn,
|
||||||
|
}: TanStackHeaderRowProps): JSX.Element {
|
||||||
|
const columnId = getColumnId(column);
|
||||||
|
const isDragColumn =
|
||||||
|
column.key !== 'expand' && column.key !== 'state-indicator';
|
||||||
|
const isResizableColumn = Boolean(header?.column.getCanResize());
|
||||||
|
const isColumnRemovable = Boolean(
|
||||||
|
canRemoveColumn &&
|
||||||
|
onRemoveColumn &&
|
||||||
|
column.key !== 'expand' &&
|
||||||
|
column.key !== 'state-indicator',
|
||||||
|
);
|
||||||
|
const isResizing = Boolean(header?.column.getIsResizing());
|
||||||
|
const resizeHandler = header?.getResizeHandler();
|
||||||
|
const headerText =
|
||||||
|
typeof column.title === 'string' && column.title
|
||||||
|
? column.title
|
||||||
|
: String(header?.id ?? columnId);
|
||||||
|
const headerTitleAttr = headerText.replace(/^\w/, (c) => c.toUpperCase());
|
||||||
|
const handleResizeStart = (
|
||||||
|
event: ReactMouseEvent<HTMLElement> | ReactTouchEvent<HTMLElement>,
|
||||||
|
): void => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
resizeHandler?.(event);
|
||||||
|
};
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
setActivatorNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({
|
||||||
|
id: columnId,
|
||||||
|
disabled: !isDragColumn,
|
||||||
|
});
|
||||||
|
const headerCellStyle = useMemo(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
'--tanstack-header-translate-x': `${Math.round(transform?.x ?? 0)}px`,
|
||||||
|
'--tanstack-header-translate-y': `${Math.round(transform?.y ?? 0)}px`,
|
||||||
|
'--tanstack-header-transition': isResizing ? 'none' : transition || 'none',
|
||||||
|
} as CSSProperties),
|
||||||
|
[isResizing, transform?.x, transform?.y, transition],
|
||||||
|
);
|
||||||
|
const headerCellClassName = [
|
||||||
|
'tanstack-header-cell',
|
||||||
|
isDragging ? 'is-dragging' : '',
|
||||||
|
isResizing ? 'is-resizing' : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
const headerContentClassName = [
|
||||||
|
'tanstack-header-content',
|
||||||
|
isResizableColumn ? 'has-resize-control' : '',
|
||||||
|
isColumnRemovable ? 'has-action-control' : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableHeaderCellStyled
|
||||||
|
ref={setNodeRef}
|
||||||
|
$isLogIndicator={column.key === 'state-indicator'}
|
||||||
|
$isDarkMode={isDarkMode}
|
||||||
|
$isDragColumn={false}
|
||||||
|
className={headerCellClassName}
|
||||||
|
key={columnId}
|
||||||
|
fontSize={fontSize}
|
||||||
|
$hasSingleColumn={hasSingleColumn}
|
||||||
|
style={headerCellStyle}
|
||||||
|
>
|
||||||
|
<span className={headerContentClassName}>
|
||||||
|
{isDragColumn ? (
|
||||||
|
<span className="tanstack-grip-slot">
|
||||||
|
<span
|
||||||
|
ref={setActivatorNodeRef}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
role="button"
|
||||||
|
aria-label={`Drag ${String(
|
||||||
|
column.title || header?.id || columnId,
|
||||||
|
)} column`}
|
||||||
|
className="tanstack-grip-activator"
|
||||||
|
>
|
||||||
|
<GripVertical size={GRIP_ICON_SIZE} />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="tanstack-header-title" title={headerTitleAttr}>
|
||||||
|
{header
|
||||||
|
? flexRender(header.column.columnDef.header, header.getContext())
|
||||||
|
: String(column.title || '').replace(/^\w/, (c) => c.toUpperCase())}
|
||||||
|
</span>
|
||||||
|
{isColumnRemovable && (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
aria-label={`Column actions for ${headerTitleAttr}`}
|
||||||
|
className="tanstack-header-action-trigger"
|
||||||
|
onMouseDown={(event): void => {
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MoreOutlined />
|
||||||
|
</span>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
align="end"
|
||||||
|
sideOffset={6}
|
||||||
|
className="tanstack-column-actions-content"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tanstack-remove-column-action"
|
||||||
|
onClick={(event): void => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onRemoveColumn?.(String(column.key));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseOutlined className="tanstack-remove-column-action-icon" />
|
||||||
|
Remove column
|
||||||
|
</button>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{isResizableColumn && (
|
||||||
|
<span
|
||||||
|
role="presentation"
|
||||||
|
className="cursor-col-resize"
|
||||||
|
title="Drag to resize column"
|
||||||
|
onClick={(event): void => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
onMouseDown={(event): void => {
|
||||||
|
handleResizeStart(event);
|
||||||
|
}}
|
||||||
|
onTouchStart={(event): void => {
|
||||||
|
handleResizeStart(event);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="tanstack-resize-handle-line" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TableHeaderCellStyled>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TanStackHeaderRow;
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import {
|
||||||
|
MouseEvent as ReactMouseEvent,
|
||||||
|
MouseEventHandler,
|
||||||
|
useCallback,
|
||||||
|
} from 'react';
|
||||||
|
import { flexRender, Row as TanStackRowModel } from '@tanstack/react-table';
|
||||||
|
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
||||||
|
import LogLinesActionButtons from 'components/Logs/LogLinesActionButtons/LogLinesActionButtons';
|
||||||
|
|
||||||
|
import { TableCellStyled } from '../InfinityTableView/styles';
|
||||||
|
import { InfinityTableProps } from '../InfinityTableView/types';
|
||||||
|
import { useRowHover } from '../RowHoverContext';
|
||||||
|
import { TanStackTableRowData } from './types';
|
||||||
|
|
||||||
|
type TanStackRowCellsProps = {
|
||||||
|
row: TanStackRowModel<TanStackTableRowData>;
|
||||||
|
fontSize: InfinityTableProps['tableViewProps']['fontSize'];
|
||||||
|
onSetActiveLog?: InfinityTableProps['onSetActiveLog'];
|
||||||
|
onClearActiveLog?: InfinityTableProps['onClearActiveLog'];
|
||||||
|
isActiveLog?: boolean;
|
||||||
|
isDarkMode: boolean;
|
||||||
|
onLogCopy: (logId: string, event: ReactMouseEvent<HTMLElement>) => void;
|
||||||
|
isLogsExplorerPage: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function TanStackRowCells({
|
||||||
|
row,
|
||||||
|
fontSize,
|
||||||
|
onSetActiveLog,
|
||||||
|
onClearActiveLog,
|
||||||
|
isActiveLog = false,
|
||||||
|
isDarkMode,
|
||||||
|
onLogCopy,
|
||||||
|
isLogsExplorerPage,
|
||||||
|
}: TanStackRowCellsProps): JSX.Element {
|
||||||
|
const { currentLog } = row.original;
|
||||||
|
const hasHovered = useRowHover();
|
||||||
|
|
||||||
|
const handleShowContext: MouseEventHandler<HTMLElement> = useCallback(
|
||||||
|
(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onSetActiveLog?.(currentLog, VIEW_TYPES.CONTEXT);
|
||||||
|
},
|
||||||
|
[currentLog, onSetActiveLog],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleShowLogDetails = useCallback(() => {
|
||||||
|
if (!currentLog) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isActiveLog && onClearActiveLog) {
|
||||||
|
onClearActiveLog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSetActiveLog?.(currentLog);
|
||||||
|
}, [currentLog, isActiveLog, onClearActiveLog, onSetActiveLog]);
|
||||||
|
|
||||||
|
const visibleCells = row.getVisibleCells();
|
||||||
|
const lastCellIndex = visibleCells.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{visibleCells.map((cell, index) => {
|
||||||
|
const columnKey = cell.column.id;
|
||||||
|
const isLastCell = index === lastCellIndex;
|
||||||
|
return (
|
||||||
|
<TableCellStyled
|
||||||
|
$isDragColumn={false}
|
||||||
|
$isLogIndicator={columnKey === 'state-indicator'}
|
||||||
|
$hasSingleColumn={visibleCells.length <= 2}
|
||||||
|
$isDarkMode={isDarkMode}
|
||||||
|
key={cell.id}
|
||||||
|
fontSize={fontSize}
|
||||||
|
className={columnKey}
|
||||||
|
onClick={handleShowLogDetails}
|
||||||
|
>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
{isLastCell && isLogsExplorerPage && hasHovered && (
|
||||||
|
<LogLinesActionButtons
|
||||||
|
handleShowContext={handleShowContext}
|
||||||
|
onLogCopy={(event): void => onLogCopy(currentLog.id, event)}
|
||||||
|
customClassName="table-view-log-actions"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TableCellStyled>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TanStackRowCells;
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
|
||||||
|
import TanStackCustomTableRow, {
|
||||||
|
TableRowContext,
|
||||||
|
} from '../TanStackCustomTableRow';
|
||||||
|
import type { TanStackTableRowData } from '../types';
|
||||||
|
|
||||||
|
jest.mock('../../InfinityTableView/styles', () => ({
|
||||||
|
TableRowStyled: 'tr',
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('hooks/logs/useCopyLogLink', () => ({
|
||||||
|
useCopyLogLink: (): { isHighlighted: boolean } => ({ isHighlighted: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('hooks/useDarkMode', () => ({
|
||||||
|
useIsDarkMode: (): boolean => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('components/Logs/LogStateIndicator/utils', () => ({
|
||||||
|
getLogIndicatorType: (): string => 'info',
|
||||||
|
getLogIndicatorTypeForTable: (): string => 'info',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const item: TanStackTableRowData = {
|
||||||
|
log: {},
|
||||||
|
currentLog: { id: 'row-1' } as TanStackTableRowData['currentLog'],
|
||||||
|
rowIndex: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const virtuosoTableRowAttrs = {
|
||||||
|
'data-index': 0,
|
||||||
|
'data-item-index': 0,
|
||||||
|
'data-known-size': 40,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const defaultContext: TableRowContext = {
|
||||||
|
activeLog: null,
|
||||||
|
activeContextLog: null,
|
||||||
|
logsById: new Map(),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('TanStackCustomTableRow', () => {
|
||||||
|
it('renders children inside TableRowStyled', () => {
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TanStackCustomTableRow
|
||||||
|
{...virtuosoTableRowAttrs}
|
||||||
|
item={item}
|
||||||
|
context={defaultContext}
|
||||||
|
>
|
||||||
|
<td>cell</td>
|
||||||
|
</TanStackCustomTableRow>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('cell')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks row active when activeLog matches item id', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TanStackCustomTableRow
|
||||||
|
{...virtuosoTableRowAttrs}
|
||||||
|
item={item}
|
||||||
|
context={{
|
||||||
|
...defaultContext,
|
||||||
|
activeLog: { id: 'row-1' } as never,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td>x</td>
|
||||||
|
</TanStackCustomTableRow>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = container.querySelector('tr');
|
||||||
|
expect(row).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses logsById entry when present for indicator type', () => {
|
||||||
|
const logFromMap = { id: 'row-1', severity_text: 'error' } as never;
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TanStackCustomTableRow
|
||||||
|
{...virtuosoTableRowAttrs}
|
||||||
|
item={item}
|
||||||
|
context={{
|
||||||
|
...defaultContext,
|
||||||
|
logsById: new Map([['row-1', logFromMap]]),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td>x</td>
|
||||||
|
</TanStackCustomTableRow>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('x')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import type { Header } from '@tanstack/react-table';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { FontSize } from 'container/OptionsMenu/types';
|
||||||
|
|
||||||
|
import TanStackHeaderRow from '../TanStackHeaderRow';
|
||||||
|
import type { OrderedColumn, TanStackTableRowData } from '../types';
|
||||||
|
|
||||||
|
jest.mock('../../InfinityTableView/styles', () => ({
|
||||||
|
TableHeaderCellStyled: 'th',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockUseSortable = jest.fn((_args?: unknown) => ({
|
||||||
|
attributes: {},
|
||||||
|
listeners: {},
|
||||||
|
setNodeRef: jest.fn(),
|
||||||
|
setActivatorNodeRef: jest.fn(),
|
||||||
|
transform: null,
|
||||||
|
transition: undefined,
|
||||||
|
isDragging: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@dnd-kit/sortable', () => ({
|
||||||
|
useSortable: (args: unknown): ReturnType<typeof mockUseSortable> =>
|
||||||
|
mockUseSortable(args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@tanstack/react-table', () => ({
|
||||||
|
flexRender: (def: unknown, ctx: unknown): unknown => {
|
||||||
|
if (typeof def === 'string') {
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
if (typeof def === 'function') {
|
||||||
|
return (def as (c: unknown) => unknown)(ctx);
|
||||||
|
}
|
||||||
|
return def;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const column = (key: string): OrderedColumn =>
|
||||||
|
({ key, title: key } as OrderedColumn);
|
||||||
|
|
||||||
|
const mockHeader = (
|
||||||
|
id: string,
|
||||||
|
canResize = true,
|
||||||
|
): Header<TanStackTableRowData, unknown> =>
|
||||||
|
(({
|
||||||
|
id,
|
||||||
|
column: {
|
||||||
|
getCanResize: (): boolean => canResize,
|
||||||
|
getIsResizing: (): boolean => false,
|
||||||
|
columnDef: { header: id },
|
||||||
|
},
|
||||||
|
getContext: (): unknown => ({}),
|
||||||
|
getResizeHandler: (): (() => void) => jest.fn(),
|
||||||
|
flexRender: undefined,
|
||||||
|
} as unknown) as Header<TanStackTableRowData, unknown>);
|
||||||
|
|
||||||
|
describe('TanStackHeaderRow', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseSortable.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders column title when header is undefined', () => {
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<TanStackHeaderRow
|
||||||
|
column={column('timestamp')}
|
||||||
|
isDarkMode={false}
|
||||||
|
fontSize={FontSize.SMALL}
|
||||||
|
hasSingleColumn={false}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Timestamp')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enables useSortable for draggable columns', () => {
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<TanStackHeaderRow
|
||||||
|
column={column('body')}
|
||||||
|
header={mockHeader('body')}
|
||||||
|
isDarkMode={false}
|
||||||
|
fontSize={FontSize.SMALL}
|
||||||
|
hasSingleColumn={false}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockUseSortable).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'body',
|
||||||
|
disabled: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables sortable for expand column', () => {
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<TanStackHeaderRow
|
||||||
|
column={column('expand')}
|
||||||
|
header={mockHeader('expand', false)}
|
||||||
|
isDarkMode={false}
|
||||||
|
fontSize={FontSize.SMALL}
|
||||||
|
hasSingleColumn={false}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockUseSortable).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
disabled: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows drag grip for draggable columns', () => {
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<TanStackHeaderRow
|
||||||
|
column={column('body')}
|
||||||
|
header={mockHeader('body')}
|
||||||
|
isDarkMode={false}
|
||||||
|
fontSize={FontSize.SMALL}
|
||||||
|
hasSingleColumn={false}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: /Drag body column/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import RowHoverContext from 'container/LogsExplorerList/RowHoverContext';
|
||||||
|
import { FontSize } from 'container/OptionsMenu/types';
|
||||||
|
|
||||||
|
import TanStackRowCells from '../TanStackRow';
|
||||||
|
import type { TanStackTableRowData } from '../types';
|
||||||
|
|
||||||
|
jest.mock('../../InfinityTableView/styles', () => ({
|
||||||
|
TableCellStyled: 'td',
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock(
|
||||||
|
'components/Logs/LogLinesActionButtons/LogLinesActionButtons',
|
||||||
|
() => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({
|
||||||
|
onLogCopy,
|
||||||
|
}: {
|
||||||
|
onLogCopy: (e: React.MouseEvent) => void;
|
||||||
|
}): JSX.Element => (
|
||||||
|
<button type="button" data-testid="copy-btn" onClick={onLogCopy}>
|
||||||
|
copy
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const flexRenderMock = jest.fn((def: unknown, _ctx?: unknown) =>
|
||||||
|
typeof def === 'function' ? def({}) : def,
|
||||||
|
);
|
||||||
|
|
||||||
|
jest.mock('@tanstack/react-table', () => ({
|
||||||
|
flexRender: (def: unknown, ctx: unknown): unknown => flexRenderMock(def, ctx),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function buildMockRow(
|
||||||
|
visibleCells: Array<{ columnId: string }>,
|
||||||
|
): Parameters<typeof TanStackRowCells>[0]['row'] {
|
||||||
|
return {
|
||||||
|
original: {
|
||||||
|
currentLog: { id: 'log-1' } as TanStackTableRowData['currentLog'],
|
||||||
|
log: {},
|
||||||
|
rowIndex: 0,
|
||||||
|
},
|
||||||
|
getVisibleCells: () =>
|
||||||
|
visibleCells.map((cell, index) => ({
|
||||||
|
id: `cell-${index}`,
|
||||||
|
column: {
|
||||||
|
id: cell.columnId,
|
||||||
|
columnDef: {
|
||||||
|
cell: (): string => `content-${cell.columnId}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getContext: (): Record<string, unknown> => ({}),
|
||||||
|
})),
|
||||||
|
} as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TanStackRowCells', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
flexRenderMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a cell per visible column and calls flexRender', () => {
|
||||||
|
const row = buildMockRow([
|
||||||
|
{ columnId: 'state-indicator' },
|
||||||
|
{ columnId: 'body' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<TanStackRowCells
|
||||||
|
row={row}
|
||||||
|
fontSize={FontSize.SMALL}
|
||||||
|
isDarkMode={false}
|
||||||
|
onLogCopy={jest.fn()}
|
||||||
|
isLogsExplorerPage={false}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getAllByRole('cell')).toHaveLength(2);
|
||||||
|
expect(flexRenderMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies state-indicator styling class on the indicator cell', () => {
|
||||||
|
const row = buildMockRow([{ columnId: 'state-indicator' }]);
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<TanStackRowCells
|
||||||
|
row={row}
|
||||||
|
fontSize={FontSize.SMALL}
|
||||||
|
isDarkMode={false}
|
||||||
|
onLogCopy={jest.fn()}
|
||||||
|
isLogsExplorerPage={false}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector('td.state-indicator')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders row actions on logs explorer page after hover', () => {
|
||||||
|
const row = buildMockRow([{ columnId: 'body' }]);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<RowHoverContext.Provider value>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<TanStackRowCells
|
||||||
|
row={row}
|
||||||
|
fontSize={FontSize.SMALL}
|
||||||
|
isDarkMode={false}
|
||||||
|
onLogCopy={jest.fn()}
|
||||||
|
isLogsExplorerPage
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</RowHoverContext.Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('copy-btn')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('click on a data cell calls onSetActiveLog with current log', () => {
|
||||||
|
const onSetActiveLog = jest.fn();
|
||||||
|
const row = buildMockRow([{ columnId: 'body' }]);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<TanStackRowCells
|
||||||
|
row={row}
|
||||||
|
fontSize={FontSize.SMALL}
|
||||||
|
isDarkMode={false}
|
||||||
|
onSetActiveLog={onSetActiveLog}
|
||||||
|
onLogCopy={jest.fn()}
|
||||||
|
isLogsExplorerPage={false}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getAllByRole('cell')[0]);
|
||||||
|
|
||||||
|
expect(onSetActiveLog).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: 'log-1' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('when row is active log, click on cell clears active log', () => {
|
||||||
|
const onSetActiveLog = jest.fn();
|
||||||
|
const onClearActiveLog = jest.fn();
|
||||||
|
const row = buildMockRow([{ columnId: 'body' }]);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<TanStackRowCells
|
||||||
|
row={row}
|
||||||
|
fontSize={FontSize.SMALL}
|
||||||
|
isDarkMode={false}
|
||||||
|
isActiveLog
|
||||||
|
onSetActiveLog={onSetActiveLog}
|
||||||
|
onClearActiveLog={onClearActiveLog}
|
||||||
|
onLogCopy={jest.fn()}
|
||||||
|
isLogsExplorerPage={false}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getAllByRole('cell')[0]);
|
||||||
|
|
||||||
|
expect(onClearActiveLog).toHaveBeenCalled();
|
||||||
|
expect(onSetActiveLog).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { forwardRef } from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { FontSize } from 'container/OptionsMenu/types';
|
||||||
|
|
||||||
|
import type { InfinityTableProps } from '../../InfinityTableView/types';
|
||||||
|
import TanStackTableView from '../index';
|
||||||
|
|
||||||
|
jest.mock('react-virtuoso', () => ({
|
||||||
|
TableVirtuoso: forwardRef<
|
||||||
|
unknown,
|
||||||
|
{
|
||||||
|
fixedHeaderContent?: () => JSX.Element;
|
||||||
|
itemContent: (i: number) => JSX.Element;
|
||||||
|
}
|
||||||
|
>(function MockVirtuoso({ fixedHeaderContent, itemContent }, _ref) {
|
||||||
|
return (
|
||||||
|
<div data-testid="virtuoso">
|
||||||
|
{fixedHeaderContent?.()}
|
||||||
|
{itemContent(0)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('components/Logs/TableView/useTableView', () => ({
|
||||||
|
useTableView: (): {
|
||||||
|
dataSource: Record<string, string>[];
|
||||||
|
columns: unknown[];
|
||||||
|
} => ({
|
||||||
|
dataSource: [{ id: '1' }],
|
||||||
|
columns: [
|
||||||
|
{ key: 'body', title: 'body', render: (): string => 'x' },
|
||||||
|
{ key: 'state-indicator', title: 's', render: (): string => 'y' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('hooks/useDragColumns', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: (): {
|
||||||
|
draggedColumns: unknown[];
|
||||||
|
onColumnOrderChange: () => void;
|
||||||
|
} => ({
|
||||||
|
draggedColumns: [],
|
||||||
|
onColumnOrderChange: jest.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('hooks/logs/useActiveLog', () => ({
|
||||||
|
useActiveLog: (): { activeLog: null } => ({ activeLog: null }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('hooks/logs/useCopyLogLink', () => ({
|
||||||
|
useCopyLogLink: (): { activeLogId: null } => ({ activeLogId: null }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('hooks/useDarkMode', () => ({
|
||||||
|
useIsDarkMode: (): boolean => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
useLocation: (): { pathname: string } => ({ pathname: '/logs' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('react-use', () => ({
|
||||||
|
useCopyToClipboard: (): [unknown, () => void] => [null, jest.fn()],
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@signozhq/sonner', () => ({
|
||||||
|
toast: { success: jest.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('components/Spinner', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({ tip }: { tip: string }): JSX.Element => (
|
||||||
|
<div data-testid="spinner">{tip}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const baseProps: InfinityTableProps = {
|
||||||
|
isLoading: false,
|
||||||
|
tableViewProps: {
|
||||||
|
logs: [{ id: '1' } as never],
|
||||||
|
fields: [],
|
||||||
|
linesPerRow: 3,
|
||||||
|
fontSize: FontSize.SMALL,
|
||||||
|
appendTo: 'end',
|
||||||
|
activeLogIndex: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('TanStackTableView', () => {
|
||||||
|
it('shows spinner while loading', () => {
|
||||||
|
render(<TanStackTableView {...baseProps} isLoading />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('spinner')).toHaveTextContent('Getting Logs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders virtuoso when not loading', () => {
|
||||||
|
render(<TanStackTableView {...baseProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('virtuoso')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { act, renderHook } from '@testing-library/react';
|
||||||
|
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||||
|
|
||||||
|
import type { OrderedColumn } from '../types';
|
||||||
|
import { useColumnSizingPersistence } from '../useColumnSizingPersistence';
|
||||||
|
|
||||||
|
const mockGet = jest.fn();
|
||||||
|
const mockSet = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('api/browser/localstorage/get', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: (key: string): string | null => mockGet(key),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('api/browser/localstorage/set', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: (key: string, value: string): void => {
|
||||||
|
mockSet(key, value);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const col = (key: string): OrderedColumn =>
|
||||||
|
({ key, title: key } as OrderedColumn);
|
||||||
|
|
||||||
|
describe('useColumnSizingPersistence', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockGet.mockReturnValue(null);
|
||||||
|
jest.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.runOnlyPendingTimers();
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initializes with empty sizing when localStorage is empty', () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useColumnSizingPersistence([col('body'), col('timestamp')]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.columnSizing).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses flat ColumnSizingState from localStorage', () => {
|
||||||
|
mockGet.mockReturnValue(JSON.stringify({ body: 400, timestamp: 180 }));
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useColumnSizingPersistence([col('body'), col('timestamp')]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.columnSizing).toEqual({ body: 400, timestamp: 180 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses PersistedColumnSizing wrapper with sizing + columnIdsSignature', () => {
|
||||||
|
mockGet.mockReturnValue(
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
columnIdsSignature: 'body|timestamp',
|
||||||
|
sizing: { body: 300 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useColumnSizingPersistence([col('body'), col('timestamp')]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.columnSizing).toEqual({ body: 300 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops invalid numeric entries when reading from localStorage', () => {
|
||||||
|
mockGet.mockReturnValue(
|
||||||
|
JSON.stringify({
|
||||||
|
body: 200,
|
||||||
|
bad: NaN,
|
||||||
|
zero: 0,
|
||||||
|
neg: -1,
|
||||||
|
str: 'wide',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useColumnSizingPersistence([col('body'), col('bad'), col('zero')]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.columnSizing).toEqual({ body: 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty sizing when JSON is invalid', () => {
|
||||||
|
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
mockGet.mockReturnValue('not-json');
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useColumnSizingPersistence([col('body')]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.columnSizing).toEqual({});
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prunes sizing for columns not in orderedColumns and strips fixed columns', () => {
|
||||||
|
mockGet.mockReturnValue(JSON.stringify({ body: 400, expand: 32, gone: 100 }));
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ columns }: { columns: OrderedColumn[] }) =>
|
||||||
|
useColumnSizingPersistence(columns),
|
||||||
|
{
|
||||||
|
initialProps: {
|
||||||
|
columns: [
|
||||||
|
col('body'),
|
||||||
|
col('expand'),
|
||||||
|
col('state-indicator'),
|
||||||
|
] as OrderedColumn[],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.columnSizing).toEqual({ body: 400 });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
rerender({
|
||||||
|
columns: [col('body'), col('expand'), col('state-indicator')],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.columnSizing).toEqual({ body: 400 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates setColumnSizing manually', () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useColumnSizingPersistence([col('body')]),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setColumnSizing({ body: 500 });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.columnSizing).toEqual({ body: 500 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debounces writes to localStorage', () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useColumnSizingPersistence([col('body')]),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setColumnSizing({ body: 600 });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSet).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(250);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSet).toHaveBeenCalledWith(
|
||||||
|
LOCALSTORAGE.LOGS_LIST_COLUMN_SIZING,
|
||||||
|
expect.stringContaining('"body":600'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not persist when ordered columns signature effect runs with empty ids early — still debounces empty sizing', () => {
|
||||||
|
const { result } = renderHook(() => useColumnSizingPersistence([]));
|
||||||
|
|
||||||
|
expect(result.current.columnSizing).toEqual({});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(250);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSet).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { act, renderHook } from '@testing-library/react';
|
||||||
|
|
||||||
|
import type { OrderedColumn } from '../types';
|
||||||
|
import { useOrderedColumns } from '../useOrderedColumns';
|
||||||
|
|
||||||
|
const mockGetDraggedColumns = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('hooks/useDragColumns/utils', () => ({
|
||||||
|
getDraggedColumns: <T,>(current: unknown[], dragged: unknown[]): T[] =>
|
||||||
|
mockGetDraggedColumns(current, dragged) as T[],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const col = (key: string, title?: string): OrderedColumn =>
|
||||||
|
({ key, title: title ?? key } as OrderedColumn);
|
||||||
|
|
||||||
|
describe('useOrderedColumns', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns columns from getDraggedColumns filtered to keys with string or number', () => {
|
||||||
|
mockGetDraggedColumns.mockReturnValue([
|
||||||
|
col('body'),
|
||||||
|
col('timestamp'),
|
||||||
|
{ title: 'no-key' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useOrderedColumns({
|
||||||
|
columns: [],
|
||||||
|
draggedColumns: [],
|
||||||
|
onColumnOrderChange: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.orderedColumns).toEqual([
|
||||||
|
col('body'),
|
||||||
|
col('timestamp'),
|
||||||
|
]);
|
||||||
|
expect(result.current.orderedColumnIds).toEqual(['body', 'timestamp']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasSingleColumn is true when exactly one column is not state-indicator', () => {
|
||||||
|
mockGetDraggedColumns.mockReturnValue([col('state-indicator'), col('body')]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useOrderedColumns({
|
||||||
|
columns: [],
|
||||||
|
draggedColumns: [],
|
||||||
|
onColumnOrderChange: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.hasSingleColumn).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasSingleColumn is false when more than one non-state-indicator column exists', () => {
|
||||||
|
mockGetDraggedColumns.mockReturnValue([
|
||||||
|
col('state-indicator'),
|
||||||
|
col('body'),
|
||||||
|
col('timestamp'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useOrderedColumns({
|
||||||
|
columns: [],
|
||||||
|
draggedColumns: [],
|
||||||
|
onColumnOrderChange: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.hasSingleColumn).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handleDragEnd reorders columns and calls onColumnOrderChange', () => {
|
||||||
|
const onColumnOrderChange = jest.fn();
|
||||||
|
mockGetDraggedColumns.mockReturnValue([col('a'), col('b'), col('c')]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useOrderedColumns({
|
||||||
|
columns: [],
|
||||||
|
draggedColumns: [],
|
||||||
|
onColumnOrderChange,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleDragEnd({
|
||||||
|
active: { id: 'a' },
|
||||||
|
over: { id: 'c' },
|
||||||
|
} as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onColumnOrderChange).toHaveBeenCalledWith([
|
||||||
|
expect.objectContaining({ key: 'b' }),
|
||||||
|
expect.objectContaining({ key: 'c' }),
|
||||||
|
expect.objectContaining({ key: 'a' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Derived-only: orderedColumns should remain until draggedColumns (URL/localStorage) updates.
|
||||||
|
expect(result.current.orderedColumns.map((c) => c.key)).toEqual([
|
||||||
|
'a',
|
||||||
|
'b',
|
||||||
|
'c',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handleDragEnd no-ops when over is null', () => {
|
||||||
|
const onColumnOrderChange = jest.fn();
|
||||||
|
mockGetDraggedColumns.mockReturnValue([col('a'), col('b')]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useOrderedColumns({
|
||||||
|
columns: [],
|
||||||
|
draggedColumns: [],
|
||||||
|
onColumnOrderChange,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const before = result.current.orderedColumns;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleDragEnd({
|
||||||
|
active: { id: 'a' },
|
||||||
|
over: null,
|
||||||
|
} as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.orderedColumns).toBe(before);
|
||||||
|
expect(onColumnOrderChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handleDragEnd no-ops when active.id equals over.id', () => {
|
||||||
|
const onColumnOrderChange = jest.fn();
|
||||||
|
mockGetDraggedColumns.mockReturnValue([col('a'), col('b')]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useOrderedColumns({
|
||||||
|
columns: [],
|
||||||
|
draggedColumns: [],
|
||||||
|
onColumnOrderChange,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleDragEnd({
|
||||||
|
active: { id: 'a' },
|
||||||
|
over: { id: 'a' },
|
||||||
|
} as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onColumnOrderChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handleDragEnd no-ops when indices cannot be resolved', () => {
|
||||||
|
const onColumnOrderChange = jest.fn();
|
||||||
|
mockGetDraggedColumns.mockReturnValue([col('a'), col('b')]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useOrderedColumns({
|
||||||
|
columns: [],
|
||||||
|
draggedColumns: [],
|
||||||
|
onColumnOrderChange,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleDragEnd({
|
||||||
|
active: { id: 'missing' },
|
||||||
|
over: { id: 'a' },
|
||||||
|
} as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onColumnOrderChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes sensors from useSensors', () => {
|
||||||
|
mockGetDraggedColumns.mockReturnValue([col('a')]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useOrderedColumns({
|
||||||
|
columns: [],
|
||||||
|
draggedColumns: [],
|
||||||
|
onColumnOrderChange: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.sensors).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('syncs ordered columns when base order changes externally (e.g. URL / localStorage)', () => {
|
||||||
|
mockGetDraggedColumns.mockReturnValue([col('a'), col('b'), col('c')]);
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ draggedColumns }: { draggedColumns: unknown[] }) =>
|
||||||
|
useOrderedColumns({
|
||||||
|
columns: [],
|
||||||
|
draggedColumns,
|
||||||
|
onColumnOrderChange: jest.fn(),
|
||||||
|
}),
|
||||||
|
{ initialProps: { draggedColumns: [] as unknown[] } },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.orderedColumns.map((column) => column.key)).toEqual([
|
||||||
|
'a',
|
||||||
|
'b',
|
||||||
|
'c',
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockGetDraggedColumns.mockReturnValue([col('c'), col('b'), col('a')]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
rerender({ draggedColumns: [{ title: 'from-url' }] as unknown[] });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.orderedColumns.map((column) => column.key)).toEqual([
|
||||||
|
'c',
|
||||||
|
'b',
|
||||||
|
'a',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
memo,
|
||||||
|
MouseEvent as ReactMouseEvent,
|
||||||
|
ReactElement,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { useCopyToClipboard } from 'react-use';
|
||||||
|
import { TableVirtuoso, TableVirtuosoHandle } from 'react-virtuoso';
|
||||||
|
import { DndContext, pointerWithin } from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
horizontalListSortingStrategy,
|
||||||
|
SortableContext,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { toast } from '@signozhq/sonner';
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
||||||
|
import { ColumnTypeRender } from 'components/Logs/TableView/types';
|
||||||
|
import { useTableView } from 'components/Logs/TableView/useTableView';
|
||||||
|
import Spinner from 'components/Spinner';
|
||||||
|
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||||
|
import { QueryParams } from 'constants/query';
|
||||||
|
import ROUTES from 'constants/routes';
|
||||||
|
import { useActiveLog } from 'hooks/logs/useActiveLog';
|
||||||
|
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||||
|
import useDragColumns from 'hooks/useDragColumns';
|
||||||
|
|
||||||
|
import { infinityDefaultStyles } from '../InfinityTableView/config';
|
||||||
|
import { TanStackTableStyled } from '../InfinityTableView/styles';
|
||||||
|
import { InfinityTableProps } from '../InfinityTableView/types';
|
||||||
|
import TanStackCustomTableRow from './TanStackCustomTableRow';
|
||||||
|
import TanStackHeaderRow from './TanStackHeaderRow';
|
||||||
|
import TanStackRowCells from './TanStackRow';
|
||||||
|
import { TableRecord, TanStackTableRowData } from './types';
|
||||||
|
import { useColumnSizingPersistence } from './useColumnSizingPersistence';
|
||||||
|
import { useOrderedColumns } from './useOrderedColumns';
|
||||||
|
import {
|
||||||
|
getColumnId,
|
||||||
|
getColumnMinWidthPx,
|
||||||
|
resolveColumnTypeRender,
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
|
import '../logsTableVirtuosoScrollbar.scss';
|
||||||
|
import './styles/TanStackTableView.styles.scss';
|
||||||
|
|
||||||
|
const COLUMN_DND_AUTO_SCROLL = {
|
||||||
|
layoutShiftCompensation: false as const,
|
||||||
|
threshold: { x: 0.2, y: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const TanStackTableView = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
|
||||||
|
function TanStackTableView(
|
||||||
|
{
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
onRemoveColumn,
|
||||||
|
tableViewProps,
|
||||||
|
infitiyTableProps,
|
||||||
|
onSetActiveLog,
|
||||||
|
onClearActiveLog,
|
||||||
|
activeLog,
|
||||||
|
}: InfinityTableProps,
|
||||||
|
forwardedRef,
|
||||||
|
): JSX.Element {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
|
||||||
|
// could avoid this if directly use forwardedRef in TableVirtuoso, but need to verify if it causes any issue with react-virtuoso internal ref handling
|
||||||
|
useImperativeHandle(
|
||||||
|
forwardedRef,
|
||||||
|
() => virtuosoRef.current as TableVirtuosoHandle,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const [, setCopy] = useCopyToClipboard();
|
||||||
|
const isDarkMode = useIsDarkMode();
|
||||||
|
const isLogsExplorerPage = pathname === ROUTES.LOGS_EXPLORER;
|
||||||
|
const { activeLog: activeContextLog } = useActiveLog();
|
||||||
|
|
||||||
|
// Column definitions (shared with existing logs table)
|
||||||
|
const { dataSource, columns } = useTableView({
|
||||||
|
...tableViewProps,
|
||||||
|
onClickExpand: onSetActiveLog,
|
||||||
|
onOpenLogsContext: (log): void => onSetActiveLog?.(log, VIEW_TYPES.CONTEXT),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Column order (drag + persisted order)
|
||||||
|
const { draggedColumns, onColumnOrderChange } = useDragColumns<TableRecord>(
|
||||||
|
LOCALSTORAGE.LOGS_LIST_COLUMNS,
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
orderedColumns,
|
||||||
|
orderedColumnIds,
|
||||||
|
hasSingleColumn,
|
||||||
|
handleDragEnd,
|
||||||
|
sensors,
|
||||||
|
} = useOrderedColumns({
|
||||||
|
columns,
|
||||||
|
draggedColumns,
|
||||||
|
onColumnOrderChange: onColumnOrderChange as (columns: unknown[]) => void,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Column sizing (persisted). stored to localStorage.
|
||||||
|
const { columnSizing, setColumnSizing } = useColumnSizingPersistence(
|
||||||
|
orderedColumns,
|
||||||
|
);
|
||||||
|
|
||||||
|
// don't allow "remove column" when only state-indicator + one data col remain
|
||||||
|
const isAtMinimumRemovableColumns = useMemo(
|
||||||
|
() =>
|
||||||
|
orderedColumns.filter(
|
||||||
|
(column) => column.key !== 'state-indicator' && column.key !== 'expand',
|
||||||
|
).length <= 1,
|
||||||
|
[orderedColumns],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Table data (TanStack row data shape)
|
||||||
|
// useTableView sends flattened log data. this would not be needed once we move to new log details view
|
||||||
|
const tableData = useMemo<TanStackTableRowData[]>(
|
||||||
|
() =>
|
||||||
|
dataSource
|
||||||
|
.map((log, rowIndex) => {
|
||||||
|
const currentLog = tableViewProps.logs[rowIndex];
|
||||||
|
if (!currentLog) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { log, currentLog, rowIndex };
|
||||||
|
})
|
||||||
|
.filter(Boolean) as TanStackTableRowData[],
|
||||||
|
[dataSource, tableViewProps.logs],
|
||||||
|
);
|
||||||
|
|
||||||
|
// TanStack columns + table instance
|
||||||
|
const tanstackColumns = useMemo<ColumnDef<TanStackTableRowData>[]>(
|
||||||
|
() =>
|
||||||
|
orderedColumns.map((column, index) => {
|
||||||
|
const isStateIndicator = column.key === 'state-indicator';
|
||||||
|
const isExpand = column.key === 'expand';
|
||||||
|
const isFixedColumn = isStateIndicator || isExpand;
|
||||||
|
const fixedWidth = isFixedColumn ? 32 : undefined;
|
||||||
|
const minWidthPx = getColumnMinWidthPx(column, orderedColumns);
|
||||||
|
const headerTitle = String(column.title || '');
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: getColumnId(column),
|
||||||
|
header: headerTitle.replace(/^\w/, (character) =>
|
||||||
|
character.toUpperCase(),
|
||||||
|
),
|
||||||
|
accessorFn: (row): unknown => row.log[column.key as keyof TableRecord],
|
||||||
|
enableResizing: !isFixedColumn && index !== orderedColumns.length - 1,
|
||||||
|
minSize: fixedWidth ?? minWidthPx,
|
||||||
|
size: fixedWidth, // last column gets remaining space, so don't set initial size to avoid conflict with resizing
|
||||||
|
maxSize: fixedWidth,
|
||||||
|
cell: ({ row, getValue }): ReactElement | string | number | null => {
|
||||||
|
if (!column.render) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolveColumnTypeRender(
|
||||||
|
column.render(
|
||||||
|
getValue(),
|
||||||
|
row.original.log,
|
||||||
|
row.original.rowIndex,
|
||||||
|
) as ColumnTypeRender<Record<string, unknown>>,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
[orderedColumns],
|
||||||
|
);
|
||||||
|
const table = useReactTable({
|
||||||
|
data: tableData,
|
||||||
|
columns: tanstackColumns,
|
||||||
|
enableColumnResizing: true,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
columnResizeMode: 'onChange',
|
||||||
|
onColumnSizingChange: setColumnSizing,
|
||||||
|
state: {
|
||||||
|
columnSizing,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const tableRows = table.getRowModel().rows;
|
||||||
|
|
||||||
|
// Infinite-scroll footer UI state
|
||||||
|
const [loadMoreState, setLoadMoreState] = useState<{
|
||||||
|
active: boolean;
|
||||||
|
startCount: number;
|
||||||
|
}>({
|
||||||
|
active: false,
|
||||||
|
startCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Map to resolve full log object by id (row highlighting + indicator)
|
||||||
|
const logsById = useMemo(
|
||||||
|
() => new Map(tableViewProps.logs.map((log) => [String(log.id), log])),
|
||||||
|
[tableViewProps.logs],
|
||||||
|
);
|
||||||
|
|
||||||
|
// this is already written in parent. Check if this is needed.
|
||||||
|
useEffect(() => {
|
||||||
|
const activeLogIndex = tableViewProps.activeLogIndex ?? -1;
|
||||||
|
if (activeLogIndex < 0 || activeLogIndex >= tableRows.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
virtuosoRef.current?.scrollToIndex({
|
||||||
|
index: activeLogIndex,
|
||||||
|
align: 'center',
|
||||||
|
behavior: 'auto',
|
||||||
|
});
|
||||||
|
}, [tableRows.length, tableViewProps.activeLogIndex]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loadMoreState.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFetching || tableRows.length > loadMoreState.startCount) {
|
||||||
|
setLoadMoreState((prev) =>
|
||||||
|
prev.active ? { active: false, startCount: prev.startCount } : prev,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [isFetching, loadMoreState, tableRows.length]);
|
||||||
|
|
||||||
|
const handleLogCopy = useCallback(
|
||||||
|
(logId: string, event: ReactMouseEvent<HTMLElement>): void => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const urlQuery = new URLSearchParams(window.location.search);
|
||||||
|
urlQuery.delete(QueryParams.activeLogId);
|
||||||
|
urlQuery.delete(QueryParams.relativeTime);
|
||||||
|
urlQuery.set(QueryParams.activeLogId, `"${logId}"`);
|
||||||
|
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
|
||||||
|
|
||||||
|
setCopy(link);
|
||||||
|
toast.success('Copied to clipboard', { position: 'top-right' });
|
||||||
|
},
|
||||||
|
[pathname, setCopy],
|
||||||
|
);
|
||||||
|
|
||||||
|
const itemContent = useCallback(
|
||||||
|
(index: number): JSX.Element | null => {
|
||||||
|
const row = tableRows[index];
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TanStackRowCells
|
||||||
|
row={row}
|
||||||
|
fontSize={tableViewProps.fontSize}
|
||||||
|
onSetActiveLog={onSetActiveLog}
|
||||||
|
onClearActiveLog={onClearActiveLog}
|
||||||
|
isActiveLog={
|
||||||
|
String(activeLog?.id ?? '') === String(row.original.currentLog.id ?? '')
|
||||||
|
}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
onLogCopy={handleLogCopy}
|
||||||
|
isLogsExplorerPage={isLogsExplorerPage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
activeLog?.id,
|
||||||
|
handleLogCopy,
|
||||||
|
isDarkMode,
|
||||||
|
isLogsExplorerPage,
|
||||||
|
onClearActiveLog,
|
||||||
|
onSetActiveLog,
|
||||||
|
tableRows,
|
||||||
|
tableViewProps.fontSize,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const flatHeaders = useMemo(
|
||||||
|
() => table.getFlatHeaders().filter((header) => !header.isPlaceholder),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[tanstackColumns],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tableHeader = useCallback(() => {
|
||||||
|
const orderedColumnsById = new Map(
|
||||||
|
orderedColumns.map((column) => [getColumnId(column), column] as const),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={pointerWithin}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
autoScroll={COLUMN_DND_AUTO_SCROLL}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={orderedColumnIds}
|
||||||
|
strategy={horizontalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
{flatHeaders.map((header) => {
|
||||||
|
const column = orderedColumnsById.get(header.id);
|
||||||
|
if (!column) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TanStackHeaderRow
|
||||||
|
key={header.id}
|
||||||
|
column={column}
|
||||||
|
header={header}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
fontSize={tableViewProps.fontSize}
|
||||||
|
hasSingleColumn={hasSingleColumn}
|
||||||
|
onRemoveColumn={onRemoveColumn}
|
||||||
|
canRemoveColumn={!isAtMinimumRemovableColumns}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
flatHeaders,
|
||||||
|
handleDragEnd,
|
||||||
|
hasSingleColumn,
|
||||||
|
isDarkMode,
|
||||||
|
orderedColumnIds,
|
||||||
|
orderedColumns,
|
||||||
|
onRemoveColumn,
|
||||||
|
isAtMinimumRemovableColumns,
|
||||||
|
sensors,
|
||||||
|
tableViewProps.fontSize,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleEndReached = useCallback(
|
||||||
|
(index: number): void => {
|
||||||
|
if (!infitiyTableProps?.onEndReached) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadMoreState({
|
||||||
|
active: true,
|
||||||
|
startCount: tableRows.length,
|
||||||
|
});
|
||||||
|
infitiyTableProps.onEndReached(index);
|
||||||
|
},
|
||||||
|
[infitiyTableProps, tableRows.length],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Spinner height="35px" tip="Getting Logs" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tanstack-table-view-wrapper">
|
||||||
|
<TableVirtuoso
|
||||||
|
className="logs-table-virtuoso-scroll"
|
||||||
|
ref={virtuosoRef}
|
||||||
|
style={infinityDefaultStyles}
|
||||||
|
data={tableData}
|
||||||
|
totalCount={tableRows.length}
|
||||||
|
increaseViewportBy={{ top: 500, bottom: 500 }}
|
||||||
|
initialTopMostItemIndex={
|
||||||
|
tableViewProps.activeLogIndex !== -1 ? tableViewProps.activeLogIndex : 0
|
||||||
|
}
|
||||||
|
context={{ activeLog, activeContextLog, logsById }}
|
||||||
|
fixedHeaderContent={tableHeader}
|
||||||
|
itemContent={itemContent}
|
||||||
|
components={{
|
||||||
|
Table: ({ style, children }): JSX.Element => (
|
||||||
|
<TanStackTableStyled style={style}>
|
||||||
|
<colgroup>
|
||||||
|
{orderedColumns.map((column, colIndex) => {
|
||||||
|
const columnId = getColumnId(column);
|
||||||
|
const isFixedColumn =
|
||||||
|
column.key === 'expand' || column.key === 'state-indicator';
|
||||||
|
const minWidthPx = getColumnMinWidthPx(column, orderedColumns);
|
||||||
|
const persistedWidth = columnSizing[columnId];
|
||||||
|
const computedWidth = table.getColumn(columnId)?.getSize();
|
||||||
|
const effectiveWidth = persistedWidth ?? computedWidth;
|
||||||
|
if (isFixedColumn) {
|
||||||
|
return <col key={columnId} className="tanstack-fixed-col" />;
|
||||||
|
}
|
||||||
|
// Last data column should stretch to fill remaining space
|
||||||
|
const isLastColumn = colIndex === orderedColumns.length - 1;
|
||||||
|
if (isLastColumn) {
|
||||||
|
return (
|
||||||
|
<col
|
||||||
|
key={columnId}
|
||||||
|
style={{ width: '100%', minWidth: `${minWidthPx}px` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const widthPx =
|
||||||
|
effectiveWidth != null
|
||||||
|
? Math.max(effectiveWidth, minWidthPx)
|
||||||
|
: minWidthPx;
|
||||||
|
return (
|
||||||
|
<col
|
||||||
|
key={columnId}
|
||||||
|
style={{ width: `${widthPx}px`, minWidth: `${minWidthPx}px` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</colgroup>
|
||||||
|
{children}
|
||||||
|
</TanStackTableStyled>
|
||||||
|
),
|
||||||
|
TableRow: TanStackCustomTableRow,
|
||||||
|
}}
|
||||||
|
{...(infitiyTableProps?.onEndReached
|
||||||
|
? { endReached: handleEndReached }
|
||||||
|
: {})}
|
||||||
|
/>
|
||||||
|
{loadMoreState.active && (
|
||||||
|
<div className="tanstack-load-more-container">
|
||||||
|
<Spinner height="20px" tip="Getting Logs" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default memo(TanStackTableView);
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
.tanstackHeaderCell {
|
.tanstack-header-cell {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
padding: 0.3rem;
|
padding: 0;
|
||||||
transform: translate3d(
|
transform: translate3d(
|
||||||
var(--tanstack-header-translate-x, 0px),
|
var(--tanstack-header-translate-x, 0px),
|
||||||
var(--tanstack-header-translate-y, 0px),
|
var(--tanstack-header-translate-y, 0px),
|
||||||
@@ -10,16 +10,16 @@
|
|||||||
);
|
);
|
||||||
transition: var(--tanstack-header-transition, none);
|
transition: var(--tanstack-header-transition, none);
|
||||||
|
|
||||||
&.isDragging {
|
&.is-dragging {
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.isResizing {
|
&.is-resizing {
|
||||||
background: var(--l2-background-hover);
|
background: var(--l2-background-hover);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tanstackHeaderContent {
|
.tanstack-header-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -28,20 +28,20 @@
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|
||||||
&.hasResizeControl {
|
&.has-resize-control {
|
||||||
max-width: calc(100% - 5px);
|
max-width: calc(100% - 5px);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.hasActionControl {
|
&.has-action-control {
|
||||||
max-width: calc(100% - 5px);
|
max-width: calc(100% - 5px);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.hasResizeControl.hasActionControl {
|
&.has-resize-control.has-action-control {
|
||||||
max-width: calc(100% - 10px);
|
max-width: calc(100% - 10px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tanstackGripSlot {
|
.tanstack-grip-slot {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tanstackGripActivator {
|
.tanstack-grip-activator {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
touch-action: none;
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tanstackHeaderActionTrigger {
|
.tanstack-header-action-trigger {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -72,11 +72,9 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: var(--l2-foreground);
|
color: var(--l2-foreground);
|
||||||
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tanstackColumnActionsContent {
|
.tanstack-column-actions-content {
|
||||||
width: 140px;
|
width: 140px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: var(--l2-background);
|
background: var(--l2-background);
|
||||||
@@ -85,7 +83,7 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tanstackRemoveColumnAction {
|
.tanstack-remove-column-action {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -107,19 +105,19 @@
|
|||||||
background: var(--l2-background-hover);
|
background: var(--l2-background-hover);
|
||||||
color: var(--l2-foreground);
|
color: var(--l2-foreground);
|
||||||
|
|
||||||
.tanstackRemoveColumnActionIcon {
|
.tanstack-remove-column-action-icon {
|
||||||
color: var(--l2-foreground);
|
color: var(--l2-foreground);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tanstackRemoveColumnActionIcon {
|
.tanstack-remove-column-action-icon {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--l2-foreground);
|
color: var(--l2-foreground);
|
||||||
opacity: 0.95;
|
opacity: 0.95;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tanstackHeaderCell .cursorColResize {
|
.tanstack-header-cell .cursor-col-resize {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
@@ -131,84 +129,25 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tanstackHeaderCell.isResizing .cursorColResize {
|
.tanstack-header-cell.is-resizing .cursor-col-resize {
|
||||||
background: var(--bg-robin-300);
|
background: var(--bg-robin-300);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tanstackResizeHandleLine {
|
.tanstack-resize-handle-line {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
width: 4px;
|
width: 4px;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
|
background: var(--l2-border);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: background 120ms ease, width 120ms ease;
|
transition: background 120ms ease, width 120ms ease;
|
||||||
background: transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cursorColResize:hover .tanstackResizeHandleLine {
|
.tanstack-header-cell.is-resizing .tanstack-resize-handle-line {
|
||||||
background: var(--l2-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tanstackHeaderCell.isResizing .tanstackResizeHandleLine {
|
|
||||||
width: 2px;
|
width: 2px;
|
||||||
background: var(--bg-robin-500);
|
background: var(--bg-robin-500);
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tanstackSortButton {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
font: inherit;
|
|
||||||
color: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
min-width: 0;
|
|
||||||
max-width: 100%;
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--l2-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.isSorted {
|
|
||||||
color: var(--l2-foreground);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tanstackHeaderTitle {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tanstackSortLabel {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tanstackSortIndicator {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
color: var(--l2-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.isSortable {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
.tanstack-table-view-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tanstack-fixed-col {
|
||||||
|
width: 32px;
|
||||||
|
min-width: 32px;
|
||||||
|
max-width: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tanstack-filler-col {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tanstack-actions-col {
|
||||||
|
width: 0;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tanstack-load-more-container {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 0 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tanstack-table-virtuoso {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tanstack-fontSize-small {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tanstack-fontSize-medium {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tanstack-fontSize-large {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tanstack-table-foot-loader-cell {
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { ColumnSizingState } from '@tanstack/react-table';
|
||||||
|
import { ColumnTypeRender } from 'components/Logs/TableView/types';
|
||||||
|
import { ILog } from 'types/api/logs/log';
|
||||||
|
|
||||||
|
export type TableRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
export type LogsTableColumnDef = {
|
||||||
|
key?: string | number;
|
||||||
|
title?: string;
|
||||||
|
render?: (
|
||||||
|
value: unknown,
|
||||||
|
record: TableRecord,
|
||||||
|
index: number,
|
||||||
|
) => ColumnTypeRender<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrderedColumn = LogsTableColumnDef & {
|
||||||
|
key: string | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TanStackTableRowData = {
|
||||||
|
log: TableRecord;
|
||||||
|
currentLog: ILog;
|
||||||
|
rowIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PersistedColumnSizing = {
|
||||||
|
sizing: ColumnSizingState;
|
||||||
|
};
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { ColumnSizingState } from '@tanstack/react-table';
|
||||||
|
import getFromLocalstorage from 'api/browser/localstorage/get';
|
||||||
|
import setToLocalstorage from 'api/browser/localstorage/set';
|
||||||
|
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||||
|
|
||||||
|
import { OrderedColumn, PersistedColumnSizing } from './types';
|
||||||
|
import { getColumnId } from './utils';
|
||||||
|
|
||||||
|
const COLUMN_SIZING_PERSIST_DEBOUNCE_MS = 250;
|
||||||
|
|
||||||
|
const sanitizeSizing = (input: unknown): ColumnSizingState => {
|
||||||
|
if (!input || typeof input !== 'object') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return Object.entries(
|
||||||
|
input as Record<string, unknown>,
|
||||||
|
).reduce<ColumnSizingState>((acc, [key, value]) => {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
acc[key] = value;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const readPersistedColumnSizing = (): ColumnSizingState => {
|
||||||
|
const rawSizing = getFromLocalstorage(LOCALSTORAGE.LOGS_LIST_COLUMN_SIZING);
|
||||||
|
if (!rawSizing) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawSizing) as
|
||||||
|
| PersistedColumnSizing
|
||||||
|
| ColumnSizingState;
|
||||||
|
const sizing = ('sizing' in parsed
|
||||||
|
? parsed.sizing
|
||||||
|
: parsed) as ColumnSizingState;
|
||||||
|
return sanitizeSizing(sizing);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse persisted log column sizing', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
type UseColumnSizingPersistenceResult = {
|
||||||
|
columnSizing: ColumnSizingState;
|
||||||
|
setColumnSizing: Dispatch<SetStateAction<ColumnSizingState>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useColumnSizingPersistence = (
|
||||||
|
orderedColumns: OrderedColumn[],
|
||||||
|
): UseColumnSizingPersistenceResult => {
|
||||||
|
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>(() =>
|
||||||
|
readPersistedColumnSizing(),
|
||||||
|
);
|
||||||
|
const orderedColumnIds = useMemo(
|
||||||
|
() => orderedColumns.map((column) => getColumnId(column)),
|
||||||
|
[orderedColumns],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (orderedColumnIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validColumnIds = new Set(orderedColumnIds);
|
||||||
|
const nonResizableColumnIds = new Set(
|
||||||
|
orderedColumns
|
||||||
|
.filter(
|
||||||
|
(column) => column.key === 'expand' || column.key === 'state-indicator',
|
||||||
|
)
|
||||||
|
.map((column) => getColumnId(column)),
|
||||||
|
);
|
||||||
|
|
||||||
|
setColumnSizing((previousSizing) => {
|
||||||
|
const nextSizing = Object.entries(previousSizing).reduce<ColumnSizingState>(
|
||||||
|
(acc, [columnId, size]) => {
|
||||||
|
if (!validColumnIds.has(columnId) || nonResizableColumnIds.has(columnId)) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
acc[columnId] = size;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const hasChanged =
|
||||||
|
Object.keys(nextSizing).length !== Object.keys(previousSizing).length ||
|
||||||
|
Object.entries(nextSizing).some(
|
||||||
|
([columnId, size]) => previousSizing[columnId] !== size,
|
||||||
|
);
|
||||||
|
|
||||||
|
return hasChanged ? nextSizing : previousSizing;
|
||||||
|
});
|
||||||
|
}, [orderedColumnIds, orderedColumns]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
const persistedSizing = { sizing: columnSizing };
|
||||||
|
setToLocalstorage(
|
||||||
|
LOCALSTORAGE.LOGS_LIST_COLUMN_SIZING,
|
||||||
|
JSON.stringify(persistedSizing),
|
||||||
|
);
|
||||||
|
}, COLUMN_SIZING_PERSIST_DEBOUNCE_MS);
|
||||||
|
|
||||||
|
return (): void => window.clearTimeout(timeoutId);
|
||||||
|
}, [columnSizing]);
|
||||||
|
|
||||||
|
return { columnSizing, setColumnSizing };
|
||||||
|
};
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
DragEndEvent,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import { arrayMove } from '@dnd-kit/sortable';
|
||||||
|
import { getDraggedColumns } from 'hooks/useDragColumns/utils';
|
||||||
|
|
||||||
|
import { OrderedColumn, TableRecord } from './types';
|
||||||
|
import { getColumnId } from './utils';
|
||||||
|
|
||||||
|
type UseOrderedColumnsProps = {
|
||||||
|
columns: unknown[];
|
||||||
|
draggedColumns: unknown[];
|
||||||
|
onColumnOrderChange: (columns: unknown[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UseOrderedColumnsResult = {
|
||||||
|
orderedColumns: OrderedColumn[];
|
||||||
|
orderedColumnIds: string[];
|
||||||
|
hasSingleColumn: boolean;
|
||||||
|
handleDragEnd: (event: DragEndEvent) => void;
|
||||||
|
sensors: ReturnType<typeof useSensors>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useOrderedColumns = ({
|
||||||
|
columns,
|
||||||
|
draggedColumns,
|
||||||
|
onColumnOrderChange,
|
||||||
|
}: UseOrderedColumnsProps): UseOrderedColumnsResult => {
|
||||||
|
const baseColumns = useMemo<OrderedColumn[]>(
|
||||||
|
() =>
|
||||||
|
getDraggedColumns<TableRecord>(
|
||||||
|
columns as never[],
|
||||||
|
draggedColumns as never[],
|
||||||
|
).filter(
|
||||||
|
(column): column is OrderedColumn =>
|
||||||
|
typeof column.key === 'string' || typeof column.key === 'number',
|
||||||
|
),
|
||||||
|
[columns, draggedColumns],
|
||||||
|
);
|
||||||
|
|
||||||
|
const orderedColumns = useMemo(() => {
|
||||||
|
const stateIndicatorIndex = baseColumns.findIndex(
|
||||||
|
(column) => column.key === 'state-indicator',
|
||||||
|
);
|
||||||
|
if (stateIndicatorIndex <= 0) {
|
||||||
|
return baseColumns;
|
||||||
|
}
|
||||||
|
const pinned = baseColumns[stateIndicatorIndex];
|
||||||
|
const rest = baseColumns.filter((_, i) => i !== stateIndicatorIndex);
|
||||||
|
return [pinned, ...rest];
|
||||||
|
}, [baseColumns]);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
(event: DragEndEvent): void => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't allow moving the state-indicator column
|
||||||
|
if (String(active.id) === 'state-indicator') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldIndex = orderedColumns.findIndex(
|
||||||
|
(column) => getColumnId(column) === String(active.id),
|
||||||
|
);
|
||||||
|
const newIndex = orderedColumns.findIndex(
|
||||||
|
(column) => getColumnId(column) === String(over.id),
|
||||||
|
);
|
||||||
|
if (oldIndex === -1 || newIndex === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextColumns = arrayMove(orderedColumns, oldIndex, newIndex);
|
||||||
|
onColumnOrderChange(nextColumns as unknown[]);
|
||||||
|
},
|
||||||
|
[onColumnOrderChange, orderedColumns],
|
||||||
|
);
|
||||||
|
|
||||||
|
const orderedColumnIds = useMemo(
|
||||||
|
() => orderedColumns.map((column) => getColumnId(column)),
|
||||||
|
[orderedColumns],
|
||||||
|
);
|
||||||
|
const hasSingleColumn = useMemo(
|
||||||
|
() =>
|
||||||
|
orderedColumns.filter((column) => column.key !== 'state-indicator')
|
||||||
|
.length === 1,
|
||||||
|
[orderedColumns],
|
||||||
|
);
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: { distance: 4 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
orderedColumns,
|
||||||
|
orderedColumnIds,
|
||||||
|
hasSingleColumn,
|
||||||
|
handleDragEnd,
|
||||||
|
sensors,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { cloneElement, isValidElement, ReactElement } from 'react';
|
||||||
|
import { ColumnTypeRender } from 'components/Logs/TableView/types';
|
||||||
|
|
||||||
|
import { OrderedColumn } from './types';
|
||||||
|
|
||||||
|
export const getColumnId = (column: OrderedColumn): string =>
|
||||||
|
String(column.key);
|
||||||
|
|
||||||
|
/** Browser default root font size; TanStack column sizing uses px. */
|
||||||
|
const REM_PX = 16;
|
||||||
|
const MIN_WIDTH_OTHER_REM = 12;
|
||||||
|
const MIN_WIDTH_BODY_REM = 40;
|
||||||
|
|
||||||
|
/** When total column count is below this, body column min width is doubled (more horizontal space for few columns). */
|
||||||
|
export const FEW_COLUMNS_BODY_MIN_WIDTH_THRESHOLD = 4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum width (px) for TanStack column defs + colgroup.
|
||||||
|
* Design: state/expand 32px; body min 40rem (doubled when fewer than
|
||||||
|
* {@link FEW_COLUMNS_BODY_MIN_WIDTH_THRESHOLD} total columns); other columns use rem→px (16px root).
|
||||||
|
*/
|
||||||
|
export const getColumnMinWidthPx = (
|
||||||
|
column: OrderedColumn,
|
||||||
|
orderedColumns?: OrderedColumn[],
|
||||||
|
): number => {
|
||||||
|
const key = String(column.key);
|
||||||
|
if (key === 'state-indicator' || key === 'expand') {
|
||||||
|
return 32;
|
||||||
|
}
|
||||||
|
if (key === 'body') {
|
||||||
|
const base = MIN_WIDTH_BODY_REM * REM_PX;
|
||||||
|
const fewColumns =
|
||||||
|
orderedColumns != null &&
|
||||||
|
orderedColumns.length < FEW_COLUMNS_BODY_MIN_WIDTH_THRESHOLD;
|
||||||
|
return fewColumns ? base * 1.5 : base;
|
||||||
|
}
|
||||||
|
return MIN_WIDTH_OTHER_REM * REM_PX;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveColumnTypeRender = (
|
||||||
|
rendered: ColumnTypeRender<Record<string, unknown>>,
|
||||||
|
): ReactElement | string | number | null => {
|
||||||
|
if (
|
||||||
|
rendered &&
|
||||||
|
typeof rendered === 'object' &&
|
||||||
|
'children' in rendered &&
|
||||||
|
isValidElement(rendered.children)
|
||||||
|
) {
|
||||||
|
const { children, props } = rendered as {
|
||||||
|
children: ReactElement;
|
||||||
|
props?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
return cloneElement(children, props || {});
|
||||||
|
}
|
||||||
|
if (rendered && typeof rendered === 'object' && isValidElement(rendered)) {
|
||||||
|
return rendered;
|
||||||
|
}
|
||||||
|
return typeof rendered === 'string' || typeof rendered === 'number'
|
||||||
|
? rendered
|
||||||
|
: null;
|
||||||
|
};
|
||||||
@@ -1,39 +1,24 @@
|
|||||||
import type { CSSProperties, MouseEvent, ReactNode } from 'react';
|
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
|
||||||
import { useCopyToClipboard } from 'react-use';
|
|
||||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||||
import { toast } from '@signozhq/sonner';
|
|
||||||
import { Card } from 'antd';
|
import { Card } from 'antd';
|
||||||
import logEvent from 'api/common/logEvent';
|
import logEvent from 'api/common/logEvent';
|
||||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||||
import LogDetail from 'components/LogDetail';
|
import LogDetail from 'components/LogDetail';
|
||||||
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
// components
|
||||||
import ListLogView from 'components/Logs/ListLogView';
|
import ListLogView from 'components/Logs/ListLogView';
|
||||||
import LogLinesActionButtons from 'components/Logs/LogLinesActionButtons/LogLinesActionButtons';
|
|
||||||
import { getRowBackgroundColor } from 'components/Logs/LogStateIndicator/getRowBackgroundColor';
|
|
||||||
import { getLogIndicatorType } from 'components/Logs/LogStateIndicator/utils';
|
|
||||||
import RawLogView from 'components/Logs/RawLogView';
|
import RawLogView from 'components/Logs/RawLogView';
|
||||||
import { useLogsTableColumns } from 'components/Logs/TableView/useLogsTableColumns';
|
|
||||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||||
import Spinner from 'components/Spinner';
|
import Spinner from 'components/Spinner';
|
||||||
import type { TanStackTableHandle } from 'components/TanStackTableView';
|
|
||||||
import TanStackTable from 'components/TanStackTableView';
|
|
||||||
import { useHiddenColumnIds } from 'components/TanStackTableView/useColumnStore';
|
|
||||||
import { CARD_BODY_STYLE } from 'constants/card';
|
import { CARD_BODY_STYLE } from 'constants/card';
|
||||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||||
import { QueryParams } from 'constants/query';
|
|
||||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||||
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
||||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||||
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
|
|
||||||
import { FontSize } from 'container/OptionsMenu/types';
|
import { FontSize } from 'container/OptionsMenu/types';
|
||||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||||
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
|
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
|
||||||
import useScrollToLog from 'hooks/logs/useScrollToLog';
|
import useScrollToLog from 'hooks/logs/useScrollToLog';
|
||||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
|
||||||
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
|
|
||||||
import APIError from 'types/api/error';
|
import APIError from 'types/api/error';
|
||||||
// interfaces
|
// interfaces
|
||||||
import { ILog } from 'types/api/logs/log';
|
import { ILog } from 'types/api/logs/log';
|
||||||
@@ -42,6 +27,7 @@ import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
|||||||
import NoLogs from '../NoLogs/NoLogs';
|
import NoLogs from '../NoLogs/NoLogs';
|
||||||
import { LogsExplorerListProps } from './LogsExplorerList.interfaces';
|
import { LogsExplorerListProps } from './LogsExplorerList.interfaces';
|
||||||
import { InfinityWrapperStyled } from './styles';
|
import { InfinityWrapperStyled } from './styles';
|
||||||
|
import TanStackTableView from './TanStackTableView';
|
||||||
import {
|
import {
|
||||||
convertKeysToColumnFields,
|
convertKeysToColumnFields,
|
||||||
getEmptyLogsListConfig,
|
getEmptyLogsListConfig,
|
||||||
@@ -64,14 +50,8 @@ function LogsExplorerList({
|
|||||||
isFilterApplied,
|
isFilterApplied,
|
||||||
handleChangeSelectedView,
|
handleChangeSelectedView,
|
||||||
}: LogsExplorerListProps): JSX.Element {
|
}: LogsExplorerListProps): JSX.Element {
|
||||||
const ref = useRef<TanStackTableHandle | VirtuosoHandle | null>(null);
|
const ref = useRef<VirtuosoHandle>(null);
|
||||||
const { pathname } = useLocation();
|
|
||||||
const [, setCopy] = useCopyToClipboard();
|
|
||||||
const isDarkMode = useIsDarkMode();
|
|
||||||
const { activeLogId } = useCopyLogLink();
|
const { activeLogId } = useCopyLogLink();
|
||||||
const { logs: logsPreferences } = usePreferenceContext();
|
|
||||||
const hiddenColumnIds = useHiddenColumnIds(LOCALSTORAGE.LOGS_LIST_COLUMNS);
|
|
||||||
const hasReconciledHiddenColumnsRef = useRef(false);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
activeLog,
|
activeLog,
|
||||||
@@ -81,7 +61,7 @@ function LogsExplorerList({
|
|||||||
handleCloseLogDetail,
|
handleCloseLogDetail,
|
||||||
} = useLogDetailHandlers();
|
} = useLogDetailHandlers();
|
||||||
|
|
||||||
const { options } = useOptionsMenu({
|
const { options, config } = useOptionsMenu({
|
||||||
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||||
dataSource: DataSource.LOGS,
|
dataSource: DataSource.LOGS,
|
||||||
aggregateOperator:
|
aggregateOperator:
|
||||||
@@ -100,57 +80,13 @@ function LogsExplorerList({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const selectedFields = useMemo(
|
const selectedFields = useMemo(
|
||||||
() =>
|
() => convertKeysToColumnFields(options.selectColumns),
|
||||||
convertKeysToColumnFields([
|
|
||||||
...defaultLogsSelectedColumns,
|
|
||||||
...options.selectColumns,
|
|
||||||
]),
|
|
||||||
[options],
|
[options],
|
||||||
);
|
);
|
||||||
|
|
||||||
const syncedSelectedColumns = useMemo(
|
|
||||||
() =>
|
|
||||||
options.selectColumns.filter(({ name }) => !hiddenColumnIds.includes(name)),
|
|
||||||
[options.selectColumns, hiddenColumnIds],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleColumnRemove = useCallback(
|
|
||||||
(columnId: string) => {
|
|
||||||
const updatedColumns = options.selectColumns.filter(
|
|
||||||
({ name }) => name !== columnId,
|
|
||||||
);
|
|
||||||
logsPreferences.updateColumns(updatedColumns);
|
|
||||||
},
|
|
||||||
[options.selectColumns, logsPreferences],
|
|
||||||
);
|
|
||||||
|
|
||||||
const logsColumns = useLogsTableColumns({
|
|
||||||
fields: selectedFields,
|
|
||||||
fontSize: options.fontSize,
|
|
||||||
appendTo: 'end',
|
|
||||||
});
|
|
||||||
|
|
||||||
const makeOnLogCopy = useCallback(
|
|
||||||
(log: ILog) => (event: MouseEvent<HTMLElement>): void => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
const urlQuery = new URLSearchParams(window.location.search);
|
|
||||||
urlQuery.delete(QueryParams.activeLogId);
|
|
||||||
urlQuery.delete(QueryParams.relativeTime);
|
|
||||||
urlQuery.set(QueryParams.activeLogId, `"${log.id}"`);
|
|
||||||
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
|
|
||||||
setCopy(link);
|
|
||||||
toast.success('Copied to clipboard', { position: 'top-right' });
|
|
||||||
},
|
|
||||||
[pathname, setCopy],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleScrollToLog = useScrollToLog({
|
const handleScrollToLog = useScrollToLog({
|
||||||
logs,
|
logs,
|
||||||
virtuosoRef: ref as React.RefObject<Pick<
|
virtuosoRef: ref,
|
||||||
VirtuosoHandle,
|
|
||||||
'scrollToIndex'
|
|
||||||
> | null>,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -161,20 +97,6 @@ function LogsExplorerList({
|
|||||||
}
|
}
|
||||||
}, [isLoading, isFetching, isError, logs.length]);
|
}, [isLoading, isFetching, isError, logs.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasReconciledHiddenColumnsRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
hasReconciledHiddenColumnsRef.current = true;
|
|
||||||
|
|
||||||
if (syncedSelectedColumns.length === options.selectColumns.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logsPreferences.updateColumns(syncedSelectedColumns);
|
|
||||||
}, [logsPreferences, options.selectColumns.length, syncedSelectedColumns]);
|
|
||||||
|
|
||||||
const getItemContent = useCallback(
|
const getItemContent = useCallback(
|
||||||
(_: number, log: ILog): JSX.Element => {
|
(_: number, log: ILog): JSX.Element => {
|
||||||
if (options.format === 'raw') {
|
if (options.format === 'raw') {
|
||||||
@@ -233,46 +155,25 @@ function LogsExplorerList({
|
|||||||
|
|
||||||
if (options.format === 'table') {
|
if (options.format === 'table') {
|
||||||
return (
|
return (
|
||||||
<TanStackTable<ILog>
|
<TanStackTableView
|
||||||
ref={ref as React.Ref<TanStackTableHandle>}
|
ref={ref}
|
||||||
columns={logsColumns}
|
isLoading={isLoading}
|
||||||
columnStorageKey={LOCALSTORAGE.LOGS_LIST_COLUMNS}
|
isFetching={isFetching}
|
||||||
onColumnRemove={handleColumnRemove}
|
tableViewProps={{
|
||||||
plainTextCellLineClamp={options.maxLines}
|
logs,
|
||||||
cellTypographySize={options.fontSize}
|
fields: selectedFields,
|
||||||
data={logs}
|
linesPerRow: options.maxLines,
|
||||||
isLoading={isLoading || isFetching}
|
fontSize: options.fontSize,
|
||||||
onEndReached={onEndReached}
|
appendTo: 'end',
|
||||||
isRowActive={(log): boolean =>
|
activeLogIndex,
|
||||||
log.id === activeLog?.id || log.id === activeLogId
|
|
||||||
}
|
|
||||||
getRowStyle={(log): CSSProperties =>
|
|
||||||
({
|
|
||||||
'--row-active-bg': getRowBackgroundColor(
|
|
||||||
isDarkMode,
|
|
||||||
getLogIndicatorType(log),
|
|
||||||
),
|
|
||||||
'--row-hover-bg': getRowBackgroundColor(
|
|
||||||
isDarkMode,
|
|
||||||
getLogIndicatorType(log),
|
|
||||||
),
|
|
||||||
} as CSSProperties)
|
|
||||||
}
|
|
||||||
onRowClick={(log): void => {
|
|
||||||
handleSetActiveLog(log);
|
|
||||||
}}
|
}}
|
||||||
onRowDeactivate={handleCloseLogDetail}
|
infitiyTableProps={{ onEndReached }}
|
||||||
activeRowIndex={activeLogIndex}
|
handleChangeSelectedView={handleChangeSelectedView}
|
||||||
renderRowActions={(log): ReactNode => (
|
logs={logs}
|
||||||
<LogLinesActionButtons
|
onSetActiveLog={handleSetActiveLog}
|
||||||
handleShowContext={(e): void => {
|
onClearActiveLog={handleCloseLogDetail}
|
||||||
e.preventDefault();
|
activeLog={activeLog}
|
||||||
e.stopPropagation();
|
onRemoveColumn={config.addColumn?.onRemove}
|
||||||
handleSetActiveLog(log, VIEW_TYPES.CONTEXT);
|
|
||||||
}}
|
|
||||||
onLogCopy={makeOnLogCopy(log)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -297,7 +198,7 @@ function LogsExplorerList({
|
|||||||
<OverlayScrollbar isVirtuoso>
|
<OverlayScrollbar isVirtuoso>
|
||||||
<Virtuoso
|
<Virtuoso
|
||||||
key={activeLogIndex || 'logs-virtuoso'}
|
key={activeLogIndex || 'logs-virtuoso'}
|
||||||
ref={ref as React.Ref<VirtuosoHandle>}
|
ref={ref}
|
||||||
initialTopMostItemIndex={activeLogIndex !== -1 ? activeLogIndex : 0}
|
initialTopMostItemIndex={activeLogIndex !== -1 ? activeLogIndex : 0}
|
||||||
data={logs}
|
data={logs}
|
||||||
endReached={onEndReached}
|
endReached={onEndReached}
|
||||||
@@ -318,11 +219,12 @@ function LogsExplorerList({
|
|||||||
onEndReached,
|
onEndReached,
|
||||||
getItemContent,
|
getItemContent,
|
||||||
isFetching,
|
isFetching,
|
||||||
|
selectedFields,
|
||||||
|
handleChangeSelectedView,
|
||||||
handleSetActiveLog,
|
handleSetActiveLog,
|
||||||
handleCloseLogDetail,
|
handleCloseLogDetail,
|
||||||
activeLog,
|
activeLog,
|
||||||
isDarkMode,
|
config.addColumn?.onRemove,
|
||||||
makeOnLogCopy,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const isTraceToLogsNavigation = useMemo(() => {
|
const isTraceToLogsNavigation = useMemo(() => {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
.logs-table-virtuoso-scroll {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--bg-slate-300) transparent;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--bg-slate-300);
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--bg-slate-200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightMode .logs-table-virtuoso-scroll {
|
||||||
|
scrollbar-color: var(--bg-vanilla-300) transparent;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--bg-vanilla-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--bg-vanilla-100);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import type { VirtuosoHandle } from 'react-virtuoso';
|
import type { VirtuosoHandle } from 'react-virtuoso';
|
||||||
|
|
||||||
type ScrollToIndexHandle = Pick<VirtuosoHandle, 'scrollToIndex'>;
|
|
||||||
|
|
||||||
type UseScrollToLogParams = {
|
type UseScrollToLogParams = {
|
||||||
logs: Array<{ id: string }>;
|
logs: Array<{ id: string }>;
|
||||||
virtuosoRef: React.RefObject<ScrollToIndexHandle | null>;
|
virtuosoRef: React.RefObject<VirtuosoHandle | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function useScrollToLog({
|
function useScrollToLog({
|
||||||
|
|||||||
@@ -10,6 +10,26 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||||
|
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/credentials", handler.New(
|
||||||
|
provider.authZ.AdminAccess(provider.cloudIntegrationHandler.GetConnectionCredentials),
|
||||||
|
handler.OpenAPIDef{
|
||||||
|
ID: "GetConnectionCredentials",
|
||||||
|
Tags: []string{"cloudintegration"},
|
||||||
|
Summary: "Get connection credentials",
|
||||||
|
Description: "This endpoint retrieves the connection credentials required for integration",
|
||||||
|
Request: nil,
|
||||||
|
RequestContentType: "application/json",
|
||||||
|
Response: new(citypes.Credentials),
|
||||||
|
ResponseContentType: "application/json",
|
||||||
|
SuccessStatusCode: http.StatusOK,
|
||||||
|
ErrorStatusCodes: []int{},
|
||||||
|
Deprecated: false,
|
||||||
|
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||||
|
},
|
||||||
|
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts", handler.New(
|
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts", handler.New(
|
||||||
provider.authZ.AdminAccess(provider.cloudIntegrationHandler.CreateAccount),
|
provider.authZ.AdminAccess(provider.cloudIntegrationHandler.CreateAccount),
|
||||||
handler.OpenAPIDef{
|
handler.OpenAPIDef{
|
||||||
@@ -17,9 +37,9 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
|||||||
Tags: []string{"cloudintegration"},
|
Tags: []string{"cloudintegration"},
|
||||||
Summary: "Create account",
|
Summary: "Create account",
|
||||||
Description: "This endpoint creates a new cloud integration account for the specified cloud provider",
|
Description: "This endpoint creates a new cloud integration account for the specified cloud provider",
|
||||||
Request: new(citypes.PostableConnectionArtifact),
|
Request: new(citypes.PostableAccount),
|
||||||
RequestContentType: "application/json",
|
RequestContentType: "application/json",
|
||||||
Response: new(citypes.GettableAccountWithArtifact),
|
Response: new(citypes.GettableAccountWithConnectionArtifact),
|
||||||
ResponseContentType: "application/json",
|
ResponseContentType: "application/json",
|
||||||
SuccessStatusCode: http.StatusOK,
|
SuccessStatusCode: http.StatusOK,
|
||||||
ErrorStatusCodes: []int{},
|
ErrorStatusCodes: []int{},
|
||||||
@@ -59,7 +79,7 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
|||||||
Description: "This endpoint gets an account for the specified cloud provider",
|
Description: "This endpoint gets an account for the specified cloud provider",
|
||||||
Request: nil,
|
Request: nil,
|
||||||
RequestContentType: "",
|
RequestContentType: "",
|
||||||
Response: new(citypes.GettableAccount),
|
Response: new(citypes.Account),
|
||||||
ResponseContentType: "application/json",
|
ResponseContentType: "application/json",
|
||||||
SuccessStatusCode: http.StatusOK,
|
SuccessStatusCode: http.StatusOK,
|
||||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||||
@@ -139,7 +159,7 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
|||||||
Description: "This endpoint gets a service for the specified cloud provider",
|
Description: "This endpoint gets a service for the specified cloud provider",
|
||||||
Request: nil,
|
Request: nil,
|
||||||
RequestContentType: "",
|
RequestContentType: "",
|
||||||
Response: new(citypes.GettableService),
|
Response: new(citypes.Service),
|
||||||
ResponseContentType: "application/json",
|
ResponseContentType: "application/json",
|
||||||
SuccessStatusCode: http.StatusOK,
|
SuccessStatusCode: http.StatusOK,
|
||||||
ErrorStatusCodes: []int{},
|
ErrorStatusCodes: []int{},
|
||||||
@@ -150,7 +170,7 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/services/{service_id}", handler.New(
|
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}/services/{service_id}", handler.New(
|
||||||
provider.authZ.AdminAccess(provider.cloudIntegrationHandler.UpdateService),
|
provider.authZ.AdminAccess(provider.cloudIntegrationHandler.UpdateService),
|
||||||
handler.OpenAPIDef{
|
handler.OpenAPIDef{
|
||||||
ID: "UpdateService",
|
ID: "UpdateService",
|
||||||
@@ -179,9 +199,9 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
|||||||
Tags: []string{"cloudintegration"},
|
Tags: []string{"cloudintegration"},
|
||||||
Summary: "Agent check-in",
|
Summary: "Agent check-in",
|
||||||
Description: "[Deprecated] This endpoint is called by the deployed agent to check in",
|
Description: "[Deprecated] This endpoint is called by the deployed agent to check in",
|
||||||
Request: new(citypes.PostableAgentCheckInRequest),
|
Request: new(citypes.PostableAgentCheckIn),
|
||||||
RequestContentType: "application/json",
|
RequestContentType: "application/json",
|
||||||
Response: new(citypes.GettableAgentCheckInResponse),
|
Response: new(citypes.GettableAgentCheckIn),
|
||||||
ResponseContentType: "application/json",
|
ResponseContentType: "application/json",
|
||||||
SuccessStatusCode: http.StatusOK,
|
SuccessStatusCode: http.StatusOK,
|
||||||
ErrorStatusCodes: []int{},
|
ErrorStatusCodes: []int{},
|
||||||
@@ -199,9 +219,9 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
|||||||
Tags: []string{"cloudintegration"},
|
Tags: []string{"cloudintegration"},
|
||||||
Summary: "Agent check-in",
|
Summary: "Agent check-in",
|
||||||
Description: "This endpoint is called by the deployed agent to check in",
|
Description: "This endpoint is called by the deployed agent to check in",
|
||||||
Request: new(citypes.PostableAgentCheckInRequest),
|
Request: new(citypes.PostableAgentCheckIn),
|
||||||
RequestContentType: "application/json",
|
RequestContentType: "application/json",
|
||||||
Response: new(citypes.GettableAgentCheckInResponse),
|
Response: new(citypes.GettableAgentCheckIn),
|
||||||
ResponseContentType: "application/json",
|
ResponseContentType: "application/json",
|
||||||
SuccessStatusCode: http.StatusOK,
|
SuccessStatusCode: http.StatusOK,
|
||||||
ErrorStatusCodes: []int{},
|
ErrorStatusCodes: []int{},
|
||||||
|
|||||||
@@ -10,37 +10,42 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Module interface {
|
type Module interface {
|
||||||
|
GetConnectionCredentials(ctx context.Context, orgID valuer.UUID, provider citypes.CloudProviderType) (*citypes.Credentials, error)
|
||||||
|
|
||||||
CreateAccount(ctx context.Context, account *citypes.Account) error
|
CreateAccount(ctx context.Context, account *citypes.Account) error
|
||||||
|
|
||||||
// GetAccount returns cloud integration account
|
// GetAccount returns cloud integration account
|
||||||
GetAccount(ctx context.Context, orgID, accountID valuer.UUID) (*citypes.Account, error)
|
GetAccount(ctx context.Context, orgID, accountID valuer.UUID, provider citypes.CloudProviderType) (*citypes.Account, error)
|
||||||
|
|
||||||
// ListAccounts lists accounts where agent is connected
|
// ListAccounts lists accounts where agent is connected
|
||||||
ListAccounts(ctx context.Context, orgID valuer.UUID) ([]*citypes.Account, error)
|
ListAccounts(ctx context.Context, orgID valuer.UUID, provider citypes.CloudProviderType) ([]*citypes.Account, error)
|
||||||
|
|
||||||
// UpdateAccount updates the cloud integration account for a specific organization.
|
// UpdateAccount updates the cloud integration account for a specific organization.
|
||||||
UpdateAccount(ctx context.Context, account *citypes.Account) error
|
UpdateAccount(ctx context.Context, account *citypes.Account) error
|
||||||
|
|
||||||
// DisconnectAccount soft deletes/removes a cloud integration account.
|
// DisconnectAccount soft deletes/removes a cloud integration account.
|
||||||
DisconnectAccount(ctx context.Context, orgID, accountID valuer.UUID) error
|
DisconnectAccount(ctx context.Context, orgID, accountID valuer.UUID, provider citypes.CloudProviderType) error
|
||||||
|
|
||||||
// GetConnectionArtifact returns cloud provider specific connection information,
|
// GetConnectionArtifact returns cloud provider specific connection information,
|
||||||
// client side handles how this information is shown
|
// client side handles how this information is shown
|
||||||
GetConnectionArtifact(ctx context.Context, account *citypes.Account, req *citypes.ConnectionArtifactRequest) (*citypes.ConnectionArtifact, error)
|
GetConnectionArtifact(ctx context.Context, account *citypes.Account, req *citypes.GetConnectionArtifactRequest) (*citypes.ConnectionArtifact, error)
|
||||||
|
|
||||||
// ListServicesMetadata returns the list of services metadata for a cloud provider attached with the integrationID.
|
// ListServicesMetadata returns the list of supported services' metadata for a cloud provider with optional filtering for a specific integration
|
||||||
// This just returns a summary of the service and not the whole service definition
|
// This just returns a summary of the service and not the whole service definition.
|
||||||
ListServicesMetadata(ctx context.Context, orgID valuer.UUID, integrationID *valuer.UUID) ([]*citypes.ServiceMetadata, error)
|
ListServicesMetadata(ctx context.Context, orgID valuer.UUID, provider citypes.CloudProviderType, integrationID *valuer.UUID) ([]*citypes.ServiceMetadata, error)
|
||||||
|
|
||||||
// GetService returns service definition details for a serviceID. This returns config and
|
// GetService returns service definition details for a serviceID. This optionally returns the service config
|
||||||
// other details required to show in service details page on web client.
|
// for integrationID if provided.
|
||||||
GetService(ctx context.Context, orgID valuer.UUID, integrationID *valuer.UUID, serviceID string) (*citypes.Service, error)
|
GetService(ctx context.Context, orgID valuer.UUID, integrationID *valuer.UUID, serviceID citypes.ServiceID, provider citypes.CloudProviderType) (*citypes.Service, error)
|
||||||
|
|
||||||
|
// CreateService creates a new service for a cloud integration account.
|
||||||
|
CreateService(ctx context.Context, orgID valuer.UUID, service *citypes.CloudIntegrationService, provider citypes.CloudProviderType) error
|
||||||
|
|
||||||
// UpdateService updates cloud integration service
|
// UpdateService updates cloud integration service
|
||||||
UpdateService(ctx context.Context, orgID valuer.UUID, service *citypes.CloudIntegrationService) error
|
UpdateService(ctx context.Context, orgID valuer.UUID, service *citypes.CloudIntegrationService, provider citypes.CloudProviderType) error
|
||||||
|
|
||||||
// AgentCheckIn is called by agent to heartbeat and get latest config in response.
|
// AgentCheckIn is called by agent to send heartbeat and get latest config in response.
|
||||||
AgentCheckIn(ctx context.Context, orgID valuer.UUID, req *citypes.AgentCheckInRequest) (*citypes.AgentCheckInResponse, error)
|
AgentCheckIn(ctx context.Context, orgID valuer.UUID, provider citypes.CloudProviderType, req *citypes.AgentCheckInRequest) (*citypes.AgentCheckInResponse, error)
|
||||||
|
|
||||||
// GetDashboardByID returns dashboard JSON for a given dashboard id.
|
// GetDashboardByID returns dashboard JSON for a given dashboard id.
|
||||||
// this only returns the dashboard when the service (embedded in dashboard id) is enabled
|
// this only returns the dashboard when the service (embedded in dashboard id) is enabled
|
||||||
@@ -52,7 +57,22 @@ type Module interface {
|
|||||||
ListDashboards(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error)
|
ListDashboards(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CloudProviderModule interface {
|
||||||
|
GetConnectionArtifact(ctx context.Context, account *citypes.Account, req *citypes.GetConnectionArtifactRequest) (*citypes.ConnectionArtifact, error)
|
||||||
|
|
||||||
|
// ListServiceDefinitions returns all service definitions for this cloud provider.
|
||||||
|
ListServiceDefinitions(ctx context.Context) ([]*citypes.ServiceDefinition, error)
|
||||||
|
|
||||||
|
// GetServiceDefinition returns the service definition for the given service ID.
|
||||||
|
GetServiceDefinition(ctx context.Context, serviceID citypes.ServiceID) (*citypes.ServiceDefinition, error)
|
||||||
|
|
||||||
|
// BuildIntegrationConfig compiles the provider-specific integration config from the account
|
||||||
|
// and list of configured services. This is the config returned to the agent on check-in.
|
||||||
|
BuildIntegrationConfig(ctx context.Context, account *citypes.Account, services []*citypes.StorableCloudIntegrationService) (*citypes.ProviderIntegrationConfig, error)
|
||||||
|
}
|
||||||
|
|
||||||
type Handler interface {
|
type Handler interface {
|
||||||
|
GetConnectionCredentials(http.ResponseWriter, *http.Request)
|
||||||
CreateAccount(http.ResponseWriter, *http.Request)
|
CreateAccount(http.ResponseWriter, *http.Request)
|
||||||
ListAccounts(http.ResponseWriter, *http.Request)
|
ListAccounts(http.ResponseWriter, *http.Request)
|
||||||
GetAccount(http.ResponseWriter, *http.Request)
|
GetAccount(http.ResponseWriter, *http.Request)
|
||||||
|
|||||||
@@ -447,9 +447,9 @@
|
|||||||
"telemetryCollectionStrategy": {
|
"telemetryCollectionStrategy": {
|
||||||
"aws": {
|
"aws": {
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"cloudwatchMetricStreamFilters": [
|
"streamFilters": [
|
||||||
{
|
{
|
||||||
"Namespace": "AWS/ApplicationELB"
|
"namespace": "AWS/ApplicationELB"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,14 +171,14 @@
|
|||||||
"telemetryCollectionStrategy": {
|
"telemetryCollectionStrategy": {
|
||||||
"aws": {
|
"aws": {
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"cloudwatchMetricStreamFilters": [
|
"streamFilters": [
|
||||||
{
|
{
|
||||||
"Namespace": "AWS/ApiGateway"
|
"namespace": "AWS/ApiGateway"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
"cloudwatchLogsSubscriptions": [
|
"subscriptions": [
|
||||||
{
|
{
|
||||||
"logGroupNamePrefix": "API-Gateway",
|
"logGroupNamePrefix": "API-Gateway",
|
||||||
"filterPattern": ""
|
"filterPattern": ""
|
||||||
|
|||||||
@@ -374,9 +374,9 @@
|
|||||||
"telemetryCollectionStrategy": {
|
"telemetryCollectionStrategy": {
|
||||||
"aws": {
|
"aws": {
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"cloudwatchMetricStreamFilters": [
|
"streamFilters": [
|
||||||
{
|
{
|
||||||
"Namespace": "AWS/DynamoDB"
|
"namespace": "AWS/DynamoDB"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -495,12 +495,12 @@
|
|||||||
"telemetryCollectionStrategy": {
|
"telemetryCollectionStrategy": {
|
||||||
"aws": {
|
"aws": {
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"cloudwatchMetricStreamFilters": [
|
"streamFilters": [
|
||||||
{
|
{
|
||||||
"Namespace": "AWS/EC2"
|
"namespace": "AWS/EC2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Namespace": "CWAgent"
|
"namespace": "CWAgent"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -823,17 +823,17 @@
|
|||||||
"telemetryCollectionStrategy": {
|
"telemetryCollectionStrategy": {
|
||||||
"aws": {
|
"aws": {
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"cloudwatchMetricStreamFilters": [
|
"streamFilters": [
|
||||||
{
|
{
|
||||||
"Namespace": "AWS/ECS"
|
"namespace": "AWS/ECS"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Namespace": "ECS/ContainerInsights"
|
"namespace": "ECS/ContainerInsights"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
"cloudwatchLogsSubscriptions": [
|
"subscriptions": [
|
||||||
{
|
{
|
||||||
"logGroupNamePrefix": "/ecs",
|
"logGroupNamePrefix": "/ecs",
|
||||||
"filterPattern": ""
|
"filterPattern": ""
|
||||||
|
|||||||
@@ -2702,17 +2702,17 @@
|
|||||||
"telemetryCollectionStrategy": {
|
"telemetryCollectionStrategy": {
|
||||||
"aws": {
|
"aws": {
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"cloudwatchMetricStreamFilters": [
|
"streamFilters": [
|
||||||
{
|
{
|
||||||
"Namespace": "AWS/EKS"
|
"namespace": "AWS/EKS"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Namespace": "ContainerInsights"
|
"namespace": "ContainerInsights"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
"cloudwatchLogsSubscriptions": [
|
"subscriptions": [
|
||||||
{
|
{
|
||||||
"logGroupNamePrefix": "/aws/containerinsights",
|
"logGroupNamePrefix": "/aws/containerinsights",
|
||||||
"filterPattern": ""
|
"filterPattern": ""
|
||||||
|
|||||||
@@ -1934,9 +1934,9 @@
|
|||||||
"telemetryCollectionStrategy": {
|
"telemetryCollectionStrategy": {
|
||||||
"aws": {
|
"aws": {
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"cloudwatchMetricStreamFilters": [
|
"streamFilters": [
|
||||||
{
|
{
|
||||||
"Namespace": "AWS/ElastiCache"
|
"namespace": "AWS/ElastiCache"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -271,14 +271,14 @@
|
|||||||
"telemetryCollectionStrategy": {
|
"telemetryCollectionStrategy": {
|
||||||
"aws": {
|
"aws": {
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"cloudwatchMetricStreamFilters": [
|
"streamFilters": [
|
||||||
{
|
{
|
||||||
"Namespace": "AWS/Lambda"
|
"namespace": "AWS/Lambda"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
"cloudwatchLogsSubscriptions": [
|
"subscriptions": [
|
||||||
{
|
{
|
||||||
"logGroupNamePrefix": "/aws/lambda",
|
"logGroupNamePrefix": "/aws/lambda",
|
||||||
"filterPattern": ""
|
"filterPattern": ""
|
||||||
|
|||||||
@@ -1070,9 +1070,9 @@
|
|||||||
"telemetryCollectionStrategy": {
|
"telemetryCollectionStrategy": {
|
||||||
"aws": {
|
"aws": {
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"cloudwatchMetricStreamFilters": [
|
"streamFilters": [
|
||||||
{
|
{
|
||||||
"Namespace": "AWS/Kafka"
|
"namespace": "AWS/Kafka"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -775,14 +775,14 @@
|
|||||||
"telemetryCollectionStrategy": {
|
"telemetryCollectionStrategy": {
|
||||||
"aws": {
|
"aws": {
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"cloudwatchMetricStreamFilters": [
|
"streamFilters": [
|
||||||
{
|
{
|
||||||
"Namespace": "AWS/RDS"
|
"namespace": "AWS/RDS"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
"cloudwatchLogsSubscriptions": [
|
"subscriptions": [
|
||||||
{
|
{
|
||||||
"logGroupNamePrefix": "/aws/rds",
|
"logGroupNamePrefix": "/aws/rds",
|
||||||
"filterPattern": ""
|
"filterPattern": ""
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
"telemetryCollectionStrategy": {
|
"telemetryCollectionStrategy": {
|
||||||
"aws": {
|
"aws": {
|
||||||
"logs": {
|
"logs": {
|
||||||
"cloudwatchLogsSubscriptions": [
|
"subscriptions": [
|
||||||
{
|
{
|
||||||
"logGroupNamePrefix": "x/signoz/forwarder",
|
"logGroupNamePrefix": "x/signoz/forwarder",
|
||||||
"filterPattern": ""
|
"filterPattern": ""
|
||||||
|
|||||||
@@ -110,9 +110,9 @@
|
|||||||
"telemetryCollectionStrategy": {
|
"telemetryCollectionStrategy": {
|
||||||
"aws": {
|
"aws": {
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"cloudwatchMetricStreamFilters": [
|
"streamFilters": [
|
||||||
{
|
{
|
||||||
"Namespace": "AWS/SNS"
|
"namespace": "AWS/SNS"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,9 +230,9 @@
|
|||||||
"telemetryCollectionStrategy": {
|
"telemetryCollectionStrategy": {
|
||||||
"aws": {
|
"aws": {
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"cloudwatchMetricStreamFilters": [
|
"streamFilters": [
|
||||||
{
|
{
|
||||||
"Namespace": "AWS/SQS"
|
"namespace": "AWS/SQS"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ func NewHandler() cloudintegration.Handler {
|
|||||||
return &handler{}
|
return &handler{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (handler *handler) GetConnectionCredentials(http.ResponseWriter, *http.Request) {
|
||||||
|
panic("unimplemented")
|
||||||
|
}
|
||||||
|
|
||||||
func (handler *handler) CreateAccount(writer http.ResponseWriter, request *http.Request) {
|
func (handler *handler) CreateAccount(writer http.ResponseWriter, request *http.Request) {
|
||||||
// TODO implement me
|
// TODO implement me
|
||||||
panic("implement me")
|
panic("implement me")
|
||||||
|
|||||||
@@ -34,6 +34,25 @@ func (store *store) GetAccountByID(ctx context.Context, orgID, id valuer.UUID, p
|
|||||||
return account, nil
|
return account, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (store *store) GetConnectedAccount(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, providerAccountID string) (*cloudintegrationtypes.StorableCloudIntegration, error) {
|
||||||
|
account := new(cloudintegrationtypes.StorableCloudIntegration)
|
||||||
|
err := store.
|
||||||
|
store.
|
||||||
|
BunDBCtx(ctx).
|
||||||
|
NewSelect().
|
||||||
|
Model(account).
|
||||||
|
Where("org_id = ?", orgID).
|
||||||
|
Where("provider = ?", provider).
|
||||||
|
Where("account_id = ?", providerAccountID).
|
||||||
|
Where("last_agent_report IS NOT NULL").
|
||||||
|
Where("removed_at IS NULL").
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, store.store.WrapNotFoundErrf(err, cloudintegrationtypes.ErrCodeCloudIntegrationNotFound, "connected account with provider account id %s not found", providerAccountID)
|
||||||
|
}
|
||||||
|
return account, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (store *store) ListConnectedAccounts(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) ([]*cloudintegrationtypes.StorableCloudIntegration, error) {
|
func (store *store) ListConnectedAccounts(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) ([]*cloudintegrationtypes.StorableCloudIntegration, error) {
|
||||||
var accounts []*cloudintegrationtypes.StorableCloudIntegration
|
var accounts []*cloudintegrationtypes.StorableCloudIntegration
|
||||||
err := store.
|
err := store.
|
||||||
@@ -96,25 +115,6 @@ func (store *store) RemoveAccount(ctx context.Context, orgID, id valuer.UUID, pr
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *store) GetConnectedAccount(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, providerAccountID string) (*cloudintegrationtypes.StorableCloudIntegration, error) {
|
|
||||||
account := new(cloudintegrationtypes.StorableCloudIntegration)
|
|
||||||
err := store.
|
|
||||||
store.
|
|
||||||
BunDBCtx(ctx).
|
|
||||||
NewSelect().
|
|
||||||
Model(account).
|
|
||||||
Where("org_id = ?", orgID).
|
|
||||||
Where("provider = ?", provider).
|
|
||||||
Where("account_id = ?", providerAccountID).
|
|
||||||
Where("last_agent_report IS NOT NULL").
|
|
||||||
Where("removed_at IS NULL").
|
|
||||||
Scan(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, store.store.WrapNotFoundErrf(err, cloudintegrationtypes.ErrCodeCloudIntegrationNotFound, "connected account with provider account id %s not found", providerAccountID)
|
|
||||||
}
|
|
||||||
return account, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (store *store) GetServiceByServiceID(ctx context.Context, cloudIntegrationID valuer.UUID, serviceID cloudintegrationtypes.ServiceID) (*cloudintegrationtypes.StorableCloudIntegrationService, error) {
|
func (store *store) GetServiceByServiceID(ctx context.Context, cloudIntegrationID valuer.UUID, serviceID cloudintegrationtypes.ServiceID) (*cloudintegrationtypes.StorableCloudIntegrationService, error) {
|
||||||
service := new(cloudintegrationtypes.StorableCloudIntegrationService)
|
service := new(cloudintegrationtypes.StorableCloudIntegrationService)
|
||||||
err := store.
|
err := store.
|
||||||
@@ -172,3 +172,9 @@ func (store *store) UpdateService(ctx context.Context, service *cloudintegration
|
|||||||
Exec(ctx)
|
Exec(ctx)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (store *store) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
|
||||||
|
return store.store.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
|
||||||
|
return cb(ctx)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
19
pkg/modules/tracedetail/tracedetail.go
Normal file
19
pkg/modules/tracedetail/tracedetail.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package tracedetail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/SigNoz/signoz/pkg/types/tracedetailtypes"
|
||||||
|
"github.com/SigNoz/signoz/pkg/valuer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler exposes HTTP handlers for trace detail APIs.
|
||||||
|
type Handler interface {
|
||||||
|
GetWaterfall(http.ResponseWriter, *http.Request)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module defines the business logic for trace detail operations.
|
||||||
|
type Module interface {
|
||||||
|
GetWaterfall(ctx context.Context, orgID valuer.UUID, traceID string, req *tracedetailtypes.WaterfallRequest) (*tracedetailtypes.WaterfallResponse, error)
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ type querier struct {
|
|||||||
promEngine prometheus.Prometheus
|
promEngine prometheus.Prometheus
|
||||||
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
|
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
|
||||||
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
|
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
|
||||||
|
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
|
||||||
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
|
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
|
||||||
meterStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
|
meterStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
|
||||||
traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder
|
traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder
|
||||||
@@ -56,6 +57,7 @@ func New(
|
|||||||
promEngine prometheus.Prometheus,
|
promEngine prometheus.Prometheus,
|
||||||
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
|
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
|
||||||
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation],
|
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation],
|
||||||
|
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation],
|
||||||
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation],
|
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation],
|
||||||
meterStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation],
|
meterStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation],
|
||||||
traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder,
|
traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder,
|
||||||
@@ -69,6 +71,7 @@ func New(
|
|||||||
promEngine: promEngine,
|
promEngine: promEngine,
|
||||||
traceStmtBuilder: traceStmtBuilder,
|
traceStmtBuilder: traceStmtBuilder,
|
||||||
logStmtBuilder: logStmtBuilder,
|
logStmtBuilder: logStmtBuilder,
|
||||||
|
auditStmtBuilder: auditStmtBuilder,
|
||||||
metricStmtBuilder: metricStmtBuilder,
|
metricStmtBuilder: metricStmtBuilder,
|
||||||
meterStmtBuilder: meterStmtBuilder,
|
meterStmtBuilder: meterStmtBuilder,
|
||||||
traceOperatorStmtBuilder: traceOperatorStmtBuilder,
|
traceOperatorStmtBuilder: traceOperatorStmtBuilder,
|
||||||
@@ -361,7 +364,11 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
|
|||||||
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
|
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
|
||||||
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
|
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
|
||||||
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
|
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
|
||||||
bq := newBuilderQuery(q.logger, q.telemetryStore, q.logStmtBuilder, spec, timeRange, req.RequestType, tmplVars)
|
stmtBuilder := q.logStmtBuilder
|
||||||
|
if spec.Source == telemetrytypes.SourceAudit {
|
||||||
|
stmtBuilder = q.auditStmtBuilder
|
||||||
|
}
|
||||||
|
bq := newBuilderQuery(q.logger, q.telemetryStore, stmtBuilder, spec, timeRange, req.RequestType, tmplVars)
|
||||||
queries[spec.Name] = bq
|
queries[spec.Name] = bq
|
||||||
steps[spec.Name] = spec.StepInterval
|
steps[spec.Name] = spec.StepInterval
|
||||||
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
|
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
|
||||||
@@ -550,7 +557,11 @@ func (q *querier) QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qb
|
|||||||
case <-tick:
|
case <-tick:
|
||||||
// timestamp end is not specified here
|
// timestamp end is not specified here
|
||||||
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: tsStart}, req.RequestType)
|
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: tsStart}, req.RequestType)
|
||||||
bq := newBuilderQuery(q.logger, q.telemetryStore, q.logStmtBuilder, spec, timeRange, req.RequestType, map[string]qbtypes.VariableItem{
|
liveTailStmtBuilder := q.logStmtBuilder
|
||||||
|
if spec.Source == telemetrytypes.SourceAudit {
|
||||||
|
liveTailStmtBuilder = q.auditStmtBuilder
|
||||||
|
}
|
||||||
|
bq := newBuilderQuery(q.logger, q.telemetryStore, liveTailStmtBuilder, spec, timeRange, req.RequestType, map[string]qbtypes.VariableItem{
|
||||||
"id": {
|
"id": {
|
||||||
Value: updatedLogID,
|
Value: updatedLogID,
|
||||||
},
|
},
|
||||||
@@ -850,7 +861,11 @@ func (q *querier) createRangedQuery(originalQuery qbtypes.Query, timeRange qbtyp
|
|||||||
specCopy := qt.spec.Copy()
|
specCopy := qt.spec.Copy()
|
||||||
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
|
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
|
||||||
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
|
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
|
||||||
return newBuilderQuery(q.logger, q.telemetryStore, q.logStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables)
|
shiftStmtBuilder := q.logStmtBuilder
|
||||||
|
if qt.spec.Source == telemetrytypes.SourceAudit {
|
||||||
|
shiftStmtBuilder = q.auditStmtBuilder
|
||||||
|
}
|
||||||
|
return newBuilderQuery(q.logger, q.telemetryStore, shiftStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables)
|
||||||
|
|
||||||
case *builderQuery[qbtypes.MetricAggregation]:
|
case *builderQuery[qbtypes.MetricAggregation]:
|
||||||
specCopy := qt.spec.Copy()
|
specCopy := qt.spec.Copy()
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ func TestQueryRange_MetricTypeMissing(t *testing.T) {
|
|||||||
nil, // prometheus
|
nil, // prometheus
|
||||||
nil, // traceStmtBuilder
|
nil, // traceStmtBuilder
|
||||||
nil, // logStmtBuilder
|
nil, // logStmtBuilder
|
||||||
|
nil, // auditStmtBuilder
|
||||||
nil, // metricStmtBuilder
|
nil, // metricStmtBuilder
|
||||||
nil, // meterStmtBuilder
|
nil, // meterStmtBuilder
|
||||||
nil, // traceOperatorStmtBuilder
|
nil, // traceOperatorStmtBuilder
|
||||||
@@ -110,6 +111,7 @@ func TestQueryRange_MetricTypeFromStore(t *testing.T) {
|
|||||||
nil, // prometheus
|
nil, // prometheus
|
||||||
nil, // traceStmtBuilder
|
nil, // traceStmtBuilder
|
||||||
nil, // logStmtBuilder
|
nil, // logStmtBuilder
|
||||||
|
nil, // auditStmtBuilder
|
||||||
&mockMetricStmtBuilder{}, // metricStmtBuilder
|
&mockMetricStmtBuilder{}, // metricStmtBuilder
|
||||||
nil, // meterStmtBuilder
|
nil, // meterStmtBuilder
|
||||||
nil, // traceOperatorStmtBuilder
|
nil, // traceOperatorStmtBuilder
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||||
"github.com/SigNoz/signoz/pkg/querier"
|
"github.com/SigNoz/signoz/pkg/querier"
|
||||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||||
|
"github.com/SigNoz/signoz/pkg/telemetryaudit"
|
||||||
"github.com/SigNoz/signoz/pkg/telemetrylogs"
|
"github.com/SigNoz/signoz/pkg/telemetrylogs"
|
||||||
"github.com/SigNoz/signoz/pkg/telemetrymetadata"
|
"github.com/SigNoz/signoz/pkg/telemetrymetadata"
|
||||||
"github.com/SigNoz/signoz/pkg/telemetrymeter"
|
"github.com/SigNoz/signoz/pkg/telemetrymeter"
|
||||||
@@ -63,6 +64,11 @@ func newProvider(
|
|||||||
telemetrylogs.TagAttributesV2TableName,
|
telemetrylogs.TagAttributesV2TableName,
|
||||||
telemetrylogs.LogAttributeKeysTblName,
|
telemetrylogs.LogAttributeKeysTblName,
|
||||||
telemetrylogs.LogResourceKeysTblName,
|
telemetrylogs.LogResourceKeysTblName,
|
||||||
|
telemetryaudit.DBName,
|
||||||
|
telemetryaudit.AuditLogsTableName,
|
||||||
|
telemetryaudit.TagAttributesTableName,
|
||||||
|
telemetryaudit.LogAttributeKeysTblName,
|
||||||
|
telemetryaudit.LogResourceKeysTblName,
|
||||||
telemetrymetadata.DBName,
|
telemetrymetadata.DBName,
|
||||||
telemetrymetadata.AttributesMetadataLocalTableName,
|
telemetrymetadata.AttributesMetadataLocalTableName,
|
||||||
telemetrymetadata.ColumnEvolutionMetadataTableName,
|
telemetrymetadata.ColumnEvolutionMetadataTableName,
|
||||||
@@ -82,13 +88,13 @@ func newProvider(
|
|||||||
telemetryStore,
|
telemetryStore,
|
||||||
)
|
)
|
||||||
|
|
||||||
// ADD: Create trace operator statement builder
|
// Create trace operator statement builder
|
||||||
traceOperatorStmtBuilder := telemetrytraces.NewTraceOperatorStatementBuilder(
|
traceOperatorStmtBuilder := telemetrytraces.NewTraceOperatorStatementBuilder(
|
||||||
settings,
|
settings,
|
||||||
telemetryMetadataStore,
|
telemetryMetadataStore,
|
||||||
traceFieldMapper,
|
traceFieldMapper,
|
||||||
traceConditionBuilder,
|
traceConditionBuilder,
|
||||||
traceStmtBuilder, // Pass the regular trace statement builder
|
traceStmtBuilder,
|
||||||
traceAggExprRewriter,
|
traceAggExprRewriter,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -112,6 +118,26 @@ func newProvider(
|
|||||||
telemetrylogs.GetBodyJSONKey,
|
telemetrylogs.GetBodyJSONKey,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Create audit statement builder
|
||||||
|
auditFieldMapper := telemetryaudit.NewFieldMapper()
|
||||||
|
auditConditionBuilder := telemetryaudit.NewConditionBuilder(auditFieldMapper)
|
||||||
|
auditAggExprRewriter := querybuilder.NewAggExprRewriter(
|
||||||
|
settings,
|
||||||
|
telemetryaudit.DefaultFullTextColumn,
|
||||||
|
auditFieldMapper,
|
||||||
|
auditConditionBuilder,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
auditStmtBuilder := telemetryaudit.NewAuditQueryStatementBuilder(
|
||||||
|
settings,
|
||||||
|
telemetryMetadataStore,
|
||||||
|
auditFieldMapper,
|
||||||
|
auditConditionBuilder,
|
||||||
|
auditAggExprRewriter,
|
||||||
|
telemetryaudit.DefaultFullTextColumn,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
// Create metric statement builder
|
// Create metric statement builder
|
||||||
metricFieldMapper := telemetrymetrics.NewFieldMapper()
|
metricFieldMapper := telemetrymetrics.NewFieldMapper()
|
||||||
metricConditionBuilder := telemetrymetrics.NewConditionBuilder(metricFieldMapper)
|
metricConditionBuilder := telemetrymetrics.NewConditionBuilder(metricFieldMapper)
|
||||||
@@ -148,6 +174,7 @@ func newProvider(
|
|||||||
prometheus,
|
prometheus,
|
||||||
traceStmtBuilder,
|
traceStmtBuilder,
|
||||||
logStmtBuilder,
|
logStmtBuilder,
|
||||||
|
auditStmtBuilder,
|
||||||
metricStmtBuilder,
|
metricStmtBuilder,
|
||||||
meterStmtBuilder,
|
meterStmtBuilder,
|
||||||
traceOperatorStmtBuilder,
|
traceOperatorStmtBuilder,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.Teleme
|
|||||||
nil, // prometheus
|
nil, // prometheus
|
||||||
nil, // traceStmtBuilder
|
nil, // traceStmtBuilder
|
||||||
nil, // logStmtBuilder
|
nil, // logStmtBuilder
|
||||||
|
nil, // auditStmtBuilder
|
||||||
metricStmtBuilder,
|
metricStmtBuilder,
|
||||||
nil, // meterStmtBuilder
|
nil, // meterStmtBuilder
|
||||||
nil, // traceOperatorStmtBuilder
|
nil, // traceOperatorStmtBuilder
|
||||||
@@ -91,6 +92,7 @@ func prepareQuerierForLogs(telemetryStore telemetrystore.TelemetryStore, keysMap
|
|||||||
nil, // prometheus
|
nil, // prometheus
|
||||||
nil, // traceStmtBuilder
|
nil, // traceStmtBuilder
|
||||||
logStmtBuilder, // logStmtBuilder
|
logStmtBuilder, // logStmtBuilder
|
||||||
|
nil, // auditStmtBuilder
|
||||||
nil, // metricStmtBuilder
|
nil, // metricStmtBuilder
|
||||||
nil, // meterStmtBuilder
|
nil, // meterStmtBuilder
|
||||||
nil, // traceOperatorStmtBuilder
|
nil, // traceOperatorStmtBuilder
|
||||||
@@ -131,6 +133,7 @@ func prepareQuerierForTraces(telemetryStore telemetrystore.TelemetryStore, keysM
|
|||||||
nil, // prometheus
|
nil, // prometheus
|
||||||
traceStmtBuilder, // traceStmtBuilder
|
traceStmtBuilder, // traceStmtBuilder
|
||||||
nil, // logStmtBuilder
|
nil, // logStmtBuilder
|
||||||
|
nil, // auditStmtBuilder
|
||||||
nil, // metricStmtBuilder
|
nil, // metricStmtBuilder
|
||||||
nil, // meterStmtBuilder
|
nil, // meterStmtBuilder
|
||||||
nil, // traceOperatorStmtBuilder
|
nil, // traceOperatorStmtBuilder
|
||||||
|
|||||||
@@ -818,9 +818,9 @@ func (v *filterExpressionVisitor) VisitFunctionCall(ctx *grammar.FunctionCallCon
|
|||||||
case "has":
|
case "has":
|
||||||
cond = fmt.Sprintf("has(%s, %s)", fieldName, v.builder.Var(value[0]))
|
cond = fmt.Sprintf("has(%s, %s)", fieldName, v.builder.Var(value[0]))
|
||||||
case "hasAny":
|
case "hasAny":
|
||||||
cond = fmt.Sprintf("hasAny(%s, %s)", fieldName, v.builder.Var(value))
|
cond = fmt.Sprintf("hasAny(%s, %s)", fieldName, v.builder.Var(value[0]))
|
||||||
case "hasAll":
|
case "hasAll":
|
||||||
cond = fmt.Sprintf("hasAll(%s, %s)", fieldName, v.builder.Var(value))
|
cond = fmt.Sprintf("hasAll(%s, %s)", fieldName, v.builder.Var(value[0]))
|
||||||
}
|
}
|
||||||
conds = append(conds, cond)
|
conds = append(conds, cond)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import (
|
|||||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||||
"github.com/SigNoz/signoz/pkg/statsreporter"
|
"github.com/SigNoz/signoz/pkg/statsreporter"
|
||||||
|
"github.com/SigNoz/signoz/pkg/telemetryaudit"
|
||||||
"github.com/SigNoz/signoz/pkg/telemetrylogs"
|
"github.com/SigNoz/signoz/pkg/telemetrylogs"
|
||||||
"github.com/SigNoz/signoz/pkg/telemetrymetadata"
|
"github.com/SigNoz/signoz/pkg/telemetrymetadata"
|
||||||
"github.com/SigNoz/signoz/pkg/telemetrymeter"
|
"github.com/SigNoz/signoz/pkg/telemetrymeter"
|
||||||
@@ -395,6 +396,11 @@ func New(
|
|||||||
telemetrylogs.TagAttributesV2TableName,
|
telemetrylogs.TagAttributesV2TableName,
|
||||||
telemetrylogs.LogAttributeKeysTblName,
|
telemetrylogs.LogAttributeKeysTblName,
|
||||||
telemetrylogs.LogResourceKeysTblName,
|
telemetrylogs.LogResourceKeysTblName,
|
||||||
|
telemetryaudit.DBName,
|
||||||
|
telemetryaudit.AuditLogsTableName,
|
||||||
|
telemetryaudit.TagAttributesTableName,
|
||||||
|
telemetryaudit.LogAttributeKeysTblName,
|
||||||
|
telemetryaudit.LogResourceKeysTblName,
|
||||||
telemetrymetadata.DBName,
|
telemetrymetadata.DBName,
|
||||||
telemetrymetadata.AttributesMetadataLocalTableName,
|
telemetrymetadata.AttributesMetadataLocalTableName,
|
||||||
telemetrymetadata.ColumnEvolutionMetadataTableName,
|
telemetrymetadata.ColumnEvolutionMetadataTableName,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/SigNoz/signoz/pkg/errors"
|
"github.com/SigNoz/signoz/pkg/errors"
|
||||||
"github.com/SigNoz/signoz/pkg/factory"
|
"github.com/SigNoz/signoz/pkg/factory"
|
||||||
@@ -23,6 +25,7 @@ type provider struct {
|
|||||||
bundb *sqlstore.BunDB
|
bundb *sqlstore.BunDB
|
||||||
dialect *dialect
|
dialect *dialect
|
||||||
formatter sqlstore.SQLFormatter
|
formatter sqlstore.SQLFormatter
|
||||||
|
done chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFactory(hookFactories ...factory.ProviderFactory[sqlstore.SQLStoreHook, sqlstore.Config]) factory.ProviderFactory[sqlstore.SQLStore, sqlstore.Config] {
|
func NewFactory(hookFactories ...factory.ProviderFactory[sqlstore.SQLStoreHook, sqlstore.Config]) factory.ProviderFactory[sqlstore.SQLStore, sqlstore.Config] {
|
||||||
@@ -59,13 +62,19 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
|
|||||||
|
|
||||||
sqliteDialect := sqlitedialect.New()
|
sqliteDialect := sqlitedialect.New()
|
||||||
bunDB := sqlstore.NewBunDB(settings, sqldb, sqliteDialect, hooks)
|
bunDB := sqlstore.NewBunDB(settings, sqldb, sqliteDialect, hooks)
|
||||||
return &provider{
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
p := &provider{
|
||||||
settings: settings,
|
settings: settings,
|
||||||
sqldb: sqldb,
|
sqldb: sqldb,
|
||||||
bundb: bunDB,
|
bundb: bunDB,
|
||||||
dialect: new(dialect),
|
dialect: new(dialect),
|
||||||
formatter: newFormatter(bunDB.Dialect()),
|
formatter: newFormatter(bunDB.Dialect()),
|
||||||
}, nil
|
done: done,
|
||||||
|
}
|
||||||
|
go p.walDiagnosticLoop(config.Sqlite.Path)
|
||||||
|
|
||||||
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *provider) BunDB() *bun.DB {
|
func (provider *provider) BunDB() *bun.DB {
|
||||||
@@ -109,3 +118,73 @@ func (provider *provider) WrapAlreadyExistsErrf(err error, code errors.Code, for
|
|||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// walDiagnosticLoop periodically logs pool stats, WAL file size, and busy prepared statements
|
||||||
|
// to help diagnose WAL checkpoint failures caused by permanent read locks.
|
||||||
|
func (provider *provider) walDiagnosticLoop(dbPath string) {
|
||||||
|
ticker := time.NewTicker(60 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
logger := provider.settings.Logger()
|
||||||
|
walPath := dbPath + "-wal"
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-provider.done:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
// 1. Log pool stats (no SQL needed)
|
||||||
|
stats := provider.sqldb.Stats()
|
||||||
|
logger.Info("sqlite_pool_stats",
|
||||||
|
slog.Int("max_open", stats.MaxOpenConnections),
|
||||||
|
slog.Int("open", stats.OpenConnections),
|
||||||
|
slog.Int("in_use", stats.InUse),
|
||||||
|
slog.Int("idle", stats.Idle),
|
||||||
|
slog.Int64("wait_count", stats.WaitCount),
|
||||||
|
slog.String("wait_duration", stats.WaitDuration.String()),
|
||||||
|
slog.Int64("max_idle_closed", stats.MaxIdleClosed),
|
||||||
|
slog.Int64("max_idle_time_closed", stats.MaxIdleTimeClosed),
|
||||||
|
slog.Int64("max_lifetime_closed", stats.MaxLifetimeClosed),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 2. Log WAL file size (no SQL needed)
|
||||||
|
if info, err := os.Stat(walPath); err == nil {
|
||||||
|
logger.Info("sqlite_wal_size",
|
||||||
|
slog.Int64("bytes", info.Size()),
|
||||||
|
slog.String("path", walPath),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check for busy prepared statements on a single pool connection
|
||||||
|
provider.checkBusyStatements(logger)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *provider) checkBusyStatements(logger *slog.Logger) {
|
||||||
|
conn, err := provider.sqldb.Conn(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("sqlite_diag_conn_error", slog.String("error", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
rows, err := conn.QueryContext(context.Background(), "SELECT sql FROM sqlite_stmt WHERE busy")
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("sqlite_diag_query_error", slog.String("error", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var stmtSQL string
|
||||||
|
if err := rows.Scan(&stmtSQL); err != nil {
|
||||||
|
logger.Warn("sqlite_diag_scan_error", slog.String("error", err.Error()))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
logger.Warn("leaked_busy_statement", slog.String("sql", stmtSQL))
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
logger.Warn("sqlite_diag_rows_error", slog.String("error", err.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
200
pkg/telemetryaudit/condition_builder.go
Normal file
200
pkg/telemetryaudit/condition_builder.go
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
package telemetryaudit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
|
||||||
|
"github.com/SigNoz/signoz/pkg/errors"
|
||||||
|
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||||
|
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||||
|
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||||
|
"github.com/huandu/go-sqlbuilder"
|
||||||
|
)
|
||||||
|
|
||||||
|
type conditionBuilder struct {
|
||||||
|
fm qbtypes.FieldMapper
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConditionBuilder(fm qbtypes.FieldMapper) *conditionBuilder {
|
||||||
|
return &conditionBuilder{fm: fm}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *conditionBuilder) conditionFor(
|
||||||
|
ctx context.Context,
|
||||||
|
startNs, endNs uint64,
|
||||||
|
key *telemetrytypes.TelemetryFieldKey,
|
||||||
|
operator qbtypes.FilterOperator,
|
||||||
|
value any,
|
||||||
|
sb *sqlbuilder.SelectBuilder,
|
||||||
|
) (string, error) {
|
||||||
|
columns, err := c.fm.ColumnFor(ctx, startNs, endNs, key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if operator.IsStringSearchOperator() {
|
||||||
|
value = querybuilder.FormatValueForContains(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldExpression, err := c.fm.FieldFor(ctx, startNs, endNs, key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldExpression, value = querybuilder.DataTypeCollisionHandledFieldName(key, value, fieldExpression, operator)
|
||||||
|
|
||||||
|
switch operator {
|
||||||
|
case qbtypes.FilterOperatorEqual:
|
||||||
|
return sb.E(fieldExpression, value), nil
|
||||||
|
case qbtypes.FilterOperatorNotEqual:
|
||||||
|
return sb.NE(fieldExpression, value), nil
|
||||||
|
case qbtypes.FilterOperatorGreaterThan:
|
||||||
|
return sb.G(fieldExpression, value), nil
|
||||||
|
case qbtypes.FilterOperatorGreaterThanOrEq:
|
||||||
|
return sb.GE(fieldExpression, value), nil
|
||||||
|
case qbtypes.FilterOperatorLessThan:
|
||||||
|
return sb.LT(fieldExpression, value), nil
|
||||||
|
case qbtypes.FilterOperatorLessThanOrEq:
|
||||||
|
return sb.LE(fieldExpression, value), nil
|
||||||
|
case qbtypes.FilterOperatorLike:
|
||||||
|
return sb.Like(fieldExpression, value), nil
|
||||||
|
case qbtypes.FilterOperatorNotLike:
|
||||||
|
return sb.NotLike(fieldExpression, value), nil
|
||||||
|
case qbtypes.FilterOperatorILike:
|
||||||
|
return sb.ILike(fieldExpression, value), nil
|
||||||
|
case qbtypes.FilterOperatorNotILike:
|
||||||
|
return sb.NotILike(fieldExpression, value), nil
|
||||||
|
case qbtypes.FilterOperatorContains:
|
||||||
|
return sb.ILike(fieldExpression, fmt.Sprintf("%%%s%%", value)), nil
|
||||||
|
case qbtypes.FilterOperatorNotContains:
|
||||||
|
return sb.NotILike(fieldExpression, fmt.Sprintf("%%%s%%", value)), nil
|
||||||
|
case qbtypes.FilterOperatorRegexp:
|
||||||
|
return fmt.Sprintf(`match(%s, %s)`, sqlbuilder.Escape(fieldExpression), sb.Var(value)), nil
|
||||||
|
case qbtypes.FilterOperatorNotRegexp:
|
||||||
|
return fmt.Sprintf(`NOT match(%s, %s)`, sqlbuilder.Escape(fieldExpression), sb.Var(value)), nil
|
||||||
|
case qbtypes.FilterOperatorBetween:
|
||||||
|
values, ok := value.([]any)
|
||||||
|
if !ok {
|
||||||
|
return "", qbtypes.ErrBetweenValues
|
||||||
|
}
|
||||||
|
if len(values) != 2 {
|
||||||
|
return "", qbtypes.ErrBetweenValues
|
||||||
|
}
|
||||||
|
return sb.Between(fieldExpression, values[0], values[1]), nil
|
||||||
|
case qbtypes.FilterOperatorNotBetween:
|
||||||
|
values, ok := value.([]any)
|
||||||
|
if !ok {
|
||||||
|
return "", qbtypes.ErrBetweenValues
|
||||||
|
}
|
||||||
|
if len(values) != 2 {
|
||||||
|
return "", qbtypes.ErrBetweenValues
|
||||||
|
}
|
||||||
|
return sb.NotBetween(fieldExpression, values[0], values[1]), nil
|
||||||
|
case qbtypes.FilterOperatorIn:
|
||||||
|
values, ok := value.([]any)
|
||||||
|
if !ok {
|
||||||
|
return "", qbtypes.ErrInValues
|
||||||
|
}
|
||||||
|
conditions := []string{}
|
||||||
|
for _, value := range values {
|
||||||
|
conditions = append(conditions, sb.E(fieldExpression, value))
|
||||||
|
}
|
||||||
|
return sb.Or(conditions...), nil
|
||||||
|
case qbtypes.FilterOperatorNotIn:
|
||||||
|
values, ok := value.([]any)
|
||||||
|
if !ok {
|
||||||
|
return "", qbtypes.ErrInValues
|
||||||
|
}
|
||||||
|
conditions := []string{}
|
||||||
|
for _, value := range values {
|
||||||
|
conditions = append(conditions, sb.NE(fieldExpression, value))
|
||||||
|
}
|
||||||
|
return sb.And(conditions...), nil
|
||||||
|
case qbtypes.FilterOperatorExists, qbtypes.FilterOperatorNotExists:
|
||||||
|
var value any
|
||||||
|
column := columns[0]
|
||||||
|
|
||||||
|
switch column.Type.GetType() {
|
||||||
|
case schema.ColumnTypeEnumJSON:
|
||||||
|
if operator == qbtypes.FilterOperatorExists {
|
||||||
|
return sb.IsNotNull(fieldExpression), nil
|
||||||
|
}
|
||||||
|
return sb.IsNull(fieldExpression), nil
|
||||||
|
case schema.ColumnTypeEnumLowCardinality:
|
||||||
|
switch elementType := column.Type.(schema.LowCardinalityColumnType).ElementType; elementType.GetType() {
|
||||||
|
case schema.ColumnTypeEnumString:
|
||||||
|
value = ""
|
||||||
|
if operator == qbtypes.FilterOperatorExists {
|
||||||
|
return sb.NE(fieldExpression, value), nil
|
||||||
|
}
|
||||||
|
return sb.E(fieldExpression, value), nil
|
||||||
|
default:
|
||||||
|
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "exists operator is not supported for low cardinality column type %s", elementType)
|
||||||
|
}
|
||||||
|
case schema.ColumnTypeEnumString:
|
||||||
|
value = ""
|
||||||
|
if operator == qbtypes.FilterOperatorExists {
|
||||||
|
return sb.NE(fieldExpression, value), nil
|
||||||
|
}
|
||||||
|
return sb.E(fieldExpression, value), nil
|
||||||
|
case schema.ColumnTypeEnumUInt64, schema.ColumnTypeEnumUInt32, schema.ColumnTypeEnumUInt8:
|
||||||
|
value = 0
|
||||||
|
if operator == qbtypes.FilterOperatorExists {
|
||||||
|
return sb.NE(fieldExpression, value), nil
|
||||||
|
}
|
||||||
|
return sb.E(fieldExpression, value), nil
|
||||||
|
case schema.ColumnTypeEnumMap:
|
||||||
|
keyType := column.Type.(schema.MapColumnType).KeyType
|
||||||
|
if _, ok := keyType.(schema.LowCardinalityColumnType); !ok {
|
||||||
|
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "key type %s is not supported for map column type %s", keyType, column.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch valueType := column.Type.(schema.MapColumnType).ValueType; valueType.GetType() {
|
||||||
|
case schema.ColumnTypeEnumString, schema.ColumnTypeEnumBool, schema.ColumnTypeEnumFloat64:
|
||||||
|
leftOperand := fmt.Sprintf("mapContains(%s, '%s')", column.Name, key.Name)
|
||||||
|
if key.Materialized {
|
||||||
|
leftOperand = telemetrytypes.FieldKeyToMaterializedColumnNameForExists(key)
|
||||||
|
}
|
||||||
|
if operator == qbtypes.FilterOperatorExists {
|
||||||
|
return sb.E(leftOperand, true), nil
|
||||||
|
}
|
||||||
|
return sb.NE(leftOperand, true), nil
|
||||||
|
default:
|
||||||
|
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "exists operator is not supported for map column type %s", valueType)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "exists operator is not supported for column type %s", column.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported operator: %v", operator)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *conditionBuilder) ConditionFor(
|
||||||
|
ctx context.Context,
|
||||||
|
startNs uint64,
|
||||||
|
endNs uint64,
|
||||||
|
key *telemetrytypes.TelemetryFieldKey,
|
||||||
|
operator qbtypes.FilterOperator,
|
||||||
|
value any,
|
||||||
|
sb *sqlbuilder.SelectBuilder,
|
||||||
|
) (string, error) {
|
||||||
|
condition, err := c.conditionFor(ctx, startNs, endNs, key, operator, value, sb)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if key.FieldContext == telemetrytypes.FieldContextLog || key.FieldContext == telemetrytypes.FieldContextScope {
|
||||||
|
return condition, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if operator.AddDefaultExistsFilter() {
|
||||||
|
existsCondition, err := c.conditionFor(ctx, startNs, endNs, key, qbtypes.FilterOperatorExists, nil, sb)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return sb.And(condition, existsCondition), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return condition, nil
|
||||||
|
}
|
||||||
129
pkg/telemetryaudit/const.go
Normal file
129
pkg/telemetryaudit/const.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
package telemetryaudit
|
||||||
|
|
||||||
|
import (
|
||||||
|
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
|
||||||
|
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||||
|
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Internal Columns.
|
||||||
|
IDColumn = "id"
|
||||||
|
TimestampBucketStartColumn = "ts_bucket_start"
|
||||||
|
ResourceFingerPrintColumn = "resource_fingerprint"
|
||||||
|
|
||||||
|
// Intrinsic Columns.
|
||||||
|
TimestampColumn = "timestamp"
|
||||||
|
ObservedTimestampColumn = "observed_timestamp"
|
||||||
|
BodyColumn = "body"
|
||||||
|
EventNameColumn = "event_name"
|
||||||
|
TraceIDColumn = "trace_id"
|
||||||
|
SpanIDColumn = "span_id"
|
||||||
|
TraceFlagsColumn = "trace_flags"
|
||||||
|
SeverityTextColumn = "severity_text"
|
||||||
|
SeverityNumberColumn = "severity_number"
|
||||||
|
ScopeNameColumn = "scope_name"
|
||||||
|
ScopeVersionColumn = "scope_version"
|
||||||
|
|
||||||
|
// Contextual Columns.
|
||||||
|
AttributesStringColumn = "attributes_string"
|
||||||
|
AttributesNumberColumn = "attributes_number"
|
||||||
|
AttributesBoolColumn = "attributes_bool"
|
||||||
|
ResourceColumn = "resource"
|
||||||
|
ScopeStringColumn = "scope_string"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
DefaultFullTextColumn = &telemetrytypes.TelemetryFieldKey{
|
||||||
|
Name: "body",
|
||||||
|
Signal: telemetrytypes.SignalLogs,
|
||||||
|
FieldContext: telemetrytypes.FieldContextLog,
|
||||||
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||||
|
}
|
||||||
|
|
||||||
|
IntrinsicFields = map[string]telemetrytypes.TelemetryFieldKey{
|
||||||
|
"body": {
|
||||||
|
Name: "body",
|
||||||
|
Signal: telemetrytypes.SignalLogs,
|
||||||
|
FieldContext: telemetrytypes.FieldContextLog,
|
||||||
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||||
|
},
|
||||||
|
"trace_id": {
|
||||||
|
Name: "trace_id",
|
||||||
|
Signal: telemetrytypes.SignalLogs,
|
||||||
|
FieldContext: telemetrytypes.FieldContextLog,
|
||||||
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||||
|
},
|
||||||
|
"span_id": {
|
||||||
|
Name: "span_id",
|
||||||
|
Signal: telemetrytypes.SignalLogs,
|
||||||
|
FieldContext: telemetrytypes.FieldContextLog,
|
||||||
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||||
|
},
|
||||||
|
"trace_flags": {
|
||||||
|
Name: "trace_flags",
|
||||||
|
Signal: telemetrytypes.SignalLogs,
|
||||||
|
FieldContext: telemetrytypes.FieldContextLog,
|
||||||
|
FieldDataType: telemetrytypes.FieldDataTypeNumber,
|
||||||
|
},
|
||||||
|
"severity_text": {
|
||||||
|
Name: "severity_text",
|
||||||
|
Signal: telemetrytypes.SignalLogs,
|
||||||
|
FieldContext: telemetrytypes.FieldContextLog,
|
||||||
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||||
|
},
|
||||||
|
"severity_number": {
|
||||||
|
Name: "severity_number",
|
||||||
|
Signal: telemetrytypes.SignalLogs,
|
||||||
|
FieldContext: telemetrytypes.FieldContextLog,
|
||||||
|
FieldDataType: telemetrytypes.FieldDataTypeNumber,
|
||||||
|
},
|
||||||
|
"event_name": {
|
||||||
|
Name: "event_name",
|
||||||
|
Signal: telemetrytypes.SignalLogs,
|
||||||
|
FieldContext: telemetrytypes.FieldContextLog,
|
||||||
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
DefaultSortingOrder = []qbtypes.OrderBy{
|
||||||
|
{
|
||||||
|
Key: qbtypes.OrderByKey{
|
||||||
|
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||||
|
Name: TimestampColumn,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Direction: qbtypes.OrderDirectionDesc,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: qbtypes.OrderByKey{
|
||||||
|
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||||
|
Name: IDColumn,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Direction: qbtypes.OrderDirectionDesc,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
var auditLogColumns = map[string]*schema.Column{
|
||||||
|
"ts_bucket_start": {Name: "ts_bucket_start", Type: schema.ColumnTypeUInt64},
|
||||||
|
"resource_fingerprint": {Name: "resource_fingerprint", Type: schema.ColumnTypeString},
|
||||||
|
"timestamp": {Name: "timestamp", Type: schema.ColumnTypeUInt64},
|
||||||
|
"observed_timestamp": {Name: "observed_timestamp", Type: schema.ColumnTypeUInt64},
|
||||||
|
"id": {Name: "id", Type: schema.ColumnTypeString},
|
||||||
|
"trace_id": {Name: "trace_id", Type: schema.ColumnTypeString},
|
||||||
|
"span_id": {Name: "span_id", Type: schema.ColumnTypeString},
|
||||||
|
"trace_flags": {Name: "trace_flags", Type: schema.ColumnTypeUInt32},
|
||||||
|
"severity_text": {Name: "severity_text", Type: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString}},
|
||||||
|
"severity_number": {Name: "severity_number", Type: schema.ColumnTypeUInt8},
|
||||||
|
"body": {Name: "body", Type: schema.ColumnTypeString},
|
||||||
|
"attributes_string": {Name: "attributes_string", Type: schema.MapColumnType{KeyType: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString}, ValueType: schema.ColumnTypeString}},
|
||||||
|
"attributes_number": {Name: "attributes_number", Type: schema.MapColumnType{KeyType: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString}, ValueType: schema.ColumnTypeFloat64}},
|
||||||
|
"attributes_bool": {Name: "attributes_bool", Type: schema.MapColumnType{KeyType: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString}, ValueType: schema.ColumnTypeBool}},
|
||||||
|
"resource": {Name: "resource", Type: schema.JSONColumnType{}},
|
||||||
|
"event_name": {Name: "event_name", Type: schema.ColumnTypeString},
|
||||||
|
"scope_name": {Name: "scope_name", Type: schema.ColumnTypeString},
|
||||||
|
"scope_version": {Name: "scope_version", Type: schema.ColumnTypeString},
|
||||||
|
"scope_string": {Name: "scope_string", Type: schema.MapColumnType{KeyType: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString}, ValueType: schema.ColumnTypeString}},
|
||||||
|
}
|
||||||
124
pkg/telemetryaudit/field_mapper.go
Normal file
124
pkg/telemetryaudit/field_mapper.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package telemetryaudit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
|
||||||
|
"github.com/SigNoz/signoz/pkg/errors"
|
||||||
|
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||||
|
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||||
|
"github.com/huandu/go-sqlbuilder"
|
||||||
|
|
||||||
|
"golang.org/x/exp/maps"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fieldMapper struct{}
|
||||||
|
|
||||||
|
func NewFieldMapper() qbtypes.FieldMapper {
|
||||||
|
return &fieldMapper{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *fieldMapper) getColumn(_ context.Context, key *telemetrytypes.TelemetryFieldKey) ([]*schema.Column, error) {
|
||||||
|
switch key.FieldContext {
|
||||||
|
case telemetrytypes.FieldContextResource:
|
||||||
|
return []*schema.Column{auditLogColumns["resource"]}, nil
|
||||||
|
case telemetrytypes.FieldContextScope:
|
||||||
|
switch key.Name {
|
||||||
|
case "name", "scope.name", "scope_name":
|
||||||
|
return []*schema.Column{auditLogColumns["scope_name"]}, nil
|
||||||
|
case "version", "scope.version", "scope_version":
|
||||||
|
return []*schema.Column{auditLogColumns["scope_version"]}, nil
|
||||||
|
}
|
||||||
|
return []*schema.Column{auditLogColumns["scope_string"]}, nil
|
||||||
|
case telemetrytypes.FieldContextAttribute:
|
||||||
|
switch key.FieldDataType {
|
||||||
|
case telemetrytypes.FieldDataTypeString:
|
||||||
|
return []*schema.Column{auditLogColumns["attributes_string"]}, nil
|
||||||
|
case telemetrytypes.FieldDataTypeInt64, telemetrytypes.FieldDataTypeFloat64, telemetrytypes.FieldDataTypeNumber:
|
||||||
|
return []*schema.Column{auditLogColumns["attributes_number"]}, nil
|
||||||
|
case telemetrytypes.FieldDataTypeBool:
|
||||||
|
return []*schema.Column{auditLogColumns["attributes_bool"]}, nil
|
||||||
|
}
|
||||||
|
case telemetrytypes.FieldContextLog, telemetrytypes.FieldContextUnspecified:
|
||||||
|
col, ok := auditLogColumns[key.Name]
|
||||||
|
if !ok {
|
||||||
|
return nil, qbtypes.ErrColumnNotFound
|
||||||
|
}
|
||||||
|
return []*schema.Column{col}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, qbtypes.ErrColumnNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *fieldMapper) FieldFor(ctx context.Context, _, _ uint64, key *telemetrytypes.TelemetryFieldKey) (string, error) {
|
||||||
|
columns, err := m.getColumn(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(columns) != 1 {
|
||||||
|
return "", errors.Newf(errors.TypeInternal, errors.CodeInternal, "expected exactly 1 column, got %d", len(columns))
|
||||||
|
}
|
||||||
|
column := columns[0]
|
||||||
|
|
||||||
|
switch column.Type.GetType() {
|
||||||
|
case schema.ColumnTypeEnumJSON:
|
||||||
|
if key.FieldContext != telemetrytypes.FieldContextResource {
|
||||||
|
return "", errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "only resource context fields are supported for json columns in audit, got %s", key.FieldContext.String)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s.`%s`::String", column.Name, key.Name), nil
|
||||||
|
case schema.ColumnTypeEnumLowCardinality:
|
||||||
|
return column.Name, nil
|
||||||
|
case schema.ColumnTypeEnumString, schema.ColumnTypeEnumUInt64, schema.ColumnTypeEnumUInt32, schema.ColumnTypeEnumUInt8:
|
||||||
|
return column.Name, nil
|
||||||
|
case schema.ColumnTypeEnumMap:
|
||||||
|
keyType := column.Type.(schema.MapColumnType).KeyType
|
||||||
|
if _, ok := keyType.(schema.LowCardinalityColumnType); !ok {
|
||||||
|
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "key type %s is not supported for map column type %s", keyType, column.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch valueType := column.Type.(schema.MapColumnType).ValueType; valueType.GetType() {
|
||||||
|
case schema.ColumnTypeEnumString, schema.ColumnTypeEnumBool, schema.ColumnTypeEnumFloat64:
|
||||||
|
if key.Materialized {
|
||||||
|
return telemetrytypes.FieldKeyToMaterializedColumnName(key), nil
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s['%s']", column.Name, key.Name), nil
|
||||||
|
default:
|
||||||
|
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported map value type %s", valueType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return column.Name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *fieldMapper) ColumnFor(ctx context.Context, _, _ uint64, key *telemetrytypes.TelemetryFieldKey) ([]*schema.Column, error) {
|
||||||
|
return m.getColumn(ctx, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *fieldMapper) ColumnExpressionFor(
|
||||||
|
ctx context.Context,
|
||||||
|
tsStart, tsEnd uint64,
|
||||||
|
field *telemetrytypes.TelemetryFieldKey,
|
||||||
|
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||||
|
) (string, error) {
|
||||||
|
fieldExpression, err := m.FieldFor(ctx, tsStart, tsEnd, field)
|
||||||
|
if errors.Is(err, qbtypes.ErrColumnNotFound) {
|
||||||
|
keysForField := keys[field.Name]
|
||||||
|
if len(keysForField) == 0 {
|
||||||
|
if _, ok := auditLogColumns[field.Name]; ok {
|
||||||
|
field.FieldContext = telemetrytypes.FieldContextLog
|
||||||
|
fieldExpression, _ = m.FieldFor(ctx, tsStart, tsEnd, field)
|
||||||
|
} else {
|
||||||
|
correction, found := telemetrytypes.SuggestCorrection(field.Name, maps.Keys(keys))
|
||||||
|
if found {
|
||||||
|
return "", errors.Wrap(err, errors.TypeInvalidInput, errors.CodeInvalidInput, correction)
|
||||||
|
}
|
||||||
|
return "", errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "field `%s` not found", field.Name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fieldExpression, _ = m.FieldFor(ctx, tsStart, tsEnd, keysForField[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s AS `%s`", sqlbuilder.Escape(fieldExpression), field.Name), nil
|
||||||
|
}
|
||||||
612
pkg/telemetryaudit/statement_builder.go
Normal file
612
pkg/telemetryaudit/statement_builder.go
Normal file
@@ -0,0 +1,612 @@
|
|||||||
|
package telemetryaudit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/SigNoz/signoz/pkg/errors"
|
||||||
|
"github.com/SigNoz/signoz/pkg/factory"
|
||||||
|
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||||
|
"github.com/SigNoz/signoz/pkg/telemetryresourcefilter"
|
||||||
|
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||||
|
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||||
|
"github.com/huandu/go-sqlbuilder"
|
||||||
|
)
|
||||||
|
|
||||||
|
type auditQueryStatementBuilder struct {
|
||||||
|
logger *slog.Logger
|
||||||
|
metadataStore telemetrytypes.MetadataStore
|
||||||
|
fm qbtypes.FieldMapper
|
||||||
|
cb qbtypes.ConditionBuilder
|
||||||
|
resourceFilterStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
|
||||||
|
aggExprRewriter qbtypes.AggExprRewriter
|
||||||
|
fullTextColumn *telemetrytypes.TelemetryFieldKey
|
||||||
|
jsonKeyToKey qbtypes.JsonKeyToFieldFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ qbtypes.StatementBuilder[qbtypes.LogAggregation] = (*auditQueryStatementBuilder)(nil)
|
||||||
|
|
||||||
|
func NewAuditQueryStatementBuilder(
|
||||||
|
settings factory.ProviderSettings,
|
||||||
|
metadataStore telemetrytypes.MetadataStore,
|
||||||
|
fieldMapper qbtypes.FieldMapper,
|
||||||
|
conditionBuilder qbtypes.ConditionBuilder,
|
||||||
|
aggExprRewriter qbtypes.AggExprRewriter,
|
||||||
|
fullTextColumn *telemetrytypes.TelemetryFieldKey,
|
||||||
|
jsonKeyToKey qbtypes.JsonKeyToFieldFunc,
|
||||||
|
) *auditQueryStatementBuilder {
|
||||||
|
auditSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/telemetryaudit")
|
||||||
|
|
||||||
|
resourceFilterStmtBuilder := telemetryresourcefilter.New[qbtypes.LogAggregation](
|
||||||
|
settings,
|
||||||
|
DBName,
|
||||||
|
LogsResourceTableName,
|
||||||
|
telemetrytypes.SignalLogs,
|
||||||
|
telemetrytypes.SourceAudit,
|
||||||
|
metadataStore,
|
||||||
|
fullTextColumn,
|
||||||
|
jsonKeyToKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
return &auditQueryStatementBuilder{
|
||||||
|
logger: auditSettings.Logger(),
|
||||||
|
metadataStore: metadataStore,
|
||||||
|
fm: fieldMapper,
|
||||||
|
cb: conditionBuilder,
|
||||||
|
resourceFilterStmtBuilder: resourceFilterStmtBuilder,
|
||||||
|
aggExprRewriter: aggExprRewriter,
|
||||||
|
fullTextColumn: fullTextColumn,
|
||||||
|
jsonKeyToKey: jsonKeyToKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *auditQueryStatementBuilder) Build(
|
||||||
|
ctx context.Context,
|
||||||
|
start uint64,
|
||||||
|
end uint64,
|
||||||
|
requestType qbtypes.RequestType,
|
||||||
|
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
|
||||||
|
variables map[string]qbtypes.VariableItem,
|
||||||
|
) (*qbtypes.Statement, error) {
|
||||||
|
start = querybuilder.ToNanoSecs(start)
|
||||||
|
end = querybuilder.ToNanoSecs(end)
|
||||||
|
|
||||||
|
keySelectors := getKeySelectors(query)
|
||||||
|
keys, _, err := b.metadataStore.GetKeysMulti(ctx, keySelectors)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
query = b.adjustKeys(ctx, keys, query, requestType)
|
||||||
|
|
||||||
|
q := sqlbuilder.NewSelectBuilder()
|
||||||
|
|
||||||
|
var stmt *qbtypes.Statement
|
||||||
|
switch requestType {
|
||||||
|
case qbtypes.RequestTypeRaw, qbtypes.RequestTypeRawStream:
|
||||||
|
stmt, err = b.buildListQuery(ctx, q, query, start, end, keys, variables)
|
||||||
|
case qbtypes.RequestTypeTimeSeries:
|
||||||
|
stmt, err = b.buildTimeSeriesQuery(ctx, q, query, start, end, keys, variables)
|
||||||
|
case qbtypes.RequestTypeScalar:
|
||||||
|
stmt, err = b.buildScalarQuery(ctx, q, query, start, end, keys, false, variables)
|
||||||
|
default:
|
||||||
|
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported request type: %s", requestType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return stmt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getKeySelectors(query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]) []*telemetrytypes.FieldKeySelector {
|
||||||
|
var keySelectors []*telemetrytypes.FieldKeySelector
|
||||||
|
|
||||||
|
for idx := range query.Aggregations {
|
||||||
|
aggExpr := query.Aggregations[idx]
|
||||||
|
selectors := querybuilder.QueryStringToKeysSelectors(aggExpr.Expression)
|
||||||
|
keySelectors = append(keySelectors, selectors...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if query.Filter != nil && query.Filter.Expression != "" {
|
||||||
|
whereClauseSelectors := querybuilder.QueryStringToKeysSelectors(query.Filter.Expression)
|
||||||
|
keySelectors = append(keySelectors, whereClauseSelectors...)
|
||||||
|
}
|
||||||
|
|
||||||
|
for idx := range query.GroupBy {
|
||||||
|
groupBy := query.GroupBy[idx]
|
||||||
|
keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{
|
||||||
|
Name: groupBy.Name,
|
||||||
|
Signal: telemetrytypes.SignalLogs,
|
||||||
|
FieldContext: groupBy.FieldContext,
|
||||||
|
FieldDataType: groupBy.FieldDataType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for idx := range query.SelectFields {
|
||||||
|
selectField := query.SelectFields[idx]
|
||||||
|
keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{
|
||||||
|
Name: selectField.Name,
|
||||||
|
Signal: telemetrytypes.SignalLogs,
|
||||||
|
FieldContext: selectField.FieldContext,
|
||||||
|
FieldDataType: selectField.FieldDataType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for idx := range query.Order {
|
||||||
|
keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{
|
||||||
|
Name: query.Order[idx].Key.Name,
|
||||||
|
Signal: telemetrytypes.SignalLogs,
|
||||||
|
FieldContext: query.Order[idx].Key.FieldContext,
|
||||||
|
FieldDataType: query.Order[idx].Key.FieldDataType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for idx := range keySelectors {
|
||||||
|
keySelectors[idx].Signal = telemetrytypes.SignalLogs
|
||||||
|
keySelectors[idx].Source = telemetrytypes.SourceAudit
|
||||||
|
keySelectors[idx].SelectorMatchType = telemetrytypes.FieldSelectorMatchTypeExact
|
||||||
|
}
|
||||||
|
|
||||||
|
return keySelectors
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *auditQueryStatementBuilder) adjustKeys(ctx context.Context, keys map[string][]*telemetrytypes.TelemetryFieldKey, query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation], requestType qbtypes.RequestType) qbtypes.QueryBuilderQuery[qbtypes.LogAggregation] {
|
||||||
|
keys["id"] = append([]*telemetrytypes.TelemetryFieldKey{{
|
||||||
|
Name: "id",
|
||||||
|
Signal: telemetrytypes.SignalLogs,
|
||||||
|
FieldContext: telemetrytypes.FieldContextLog,
|
||||||
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||||
|
}}, keys["id"]...)
|
||||||
|
|
||||||
|
keys["timestamp"] = append([]*telemetrytypes.TelemetryFieldKey{{
|
||||||
|
Name: "timestamp",
|
||||||
|
Signal: telemetrytypes.SignalLogs,
|
||||||
|
FieldContext: telemetrytypes.FieldContextLog,
|
||||||
|
FieldDataType: telemetrytypes.FieldDataTypeNumber,
|
||||||
|
}}, keys["timestamp"]...)
|
||||||
|
|
||||||
|
actions := querybuilder.AdjustKeysForAliasExpressions(&query, requestType)
|
||||||
|
actions = append(actions, querybuilder.AdjustDuplicateKeys(&query)...)
|
||||||
|
|
||||||
|
for idx := range query.SelectFields {
|
||||||
|
actions = append(actions, b.adjustKey(&query.SelectFields[idx], keys)...)
|
||||||
|
}
|
||||||
|
for idx := range query.GroupBy {
|
||||||
|
actions = append(actions, b.adjustKey(&query.GroupBy[idx].TelemetryFieldKey, keys)...)
|
||||||
|
}
|
||||||
|
for idx := range query.Order {
|
||||||
|
actions = append(actions, b.adjustKey(&query.Order[idx].Key.TelemetryFieldKey, keys)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, action := range actions {
|
||||||
|
b.logger.InfoContext(ctx, "key adjustment action", slog.String("action", action))
|
||||||
|
}
|
||||||
|
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *auditQueryStatementBuilder) adjustKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) []string {
|
||||||
|
if _, ok := IntrinsicFields[key.Name]; ok {
|
||||||
|
intrinsicField := IntrinsicFields[key.Name]
|
||||||
|
return querybuilder.AdjustKey(key, keys, &intrinsicField)
|
||||||
|
}
|
||||||
|
return querybuilder.AdjustKey(key, keys, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *auditQueryStatementBuilder) buildListQuery(
|
||||||
|
ctx context.Context,
|
||||||
|
sb *sqlbuilder.SelectBuilder,
|
||||||
|
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
|
||||||
|
start, end uint64,
|
||||||
|
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||||
|
variables map[string]qbtypes.VariableItem,
|
||||||
|
) (*qbtypes.Statement, error) {
|
||||||
|
var (
|
||||||
|
cteFragments []string
|
||||||
|
cteArgs [][]any
|
||||||
|
)
|
||||||
|
|
||||||
|
if frag, args, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if frag != "" {
|
||||||
|
cteFragments = append(cteFragments, frag)
|
||||||
|
cteArgs = append(cteArgs, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Select(TimestampColumn)
|
||||||
|
sb.SelectMore(IDColumn)
|
||||||
|
if len(query.SelectFields) == 0 {
|
||||||
|
sb.SelectMore(TraceIDColumn)
|
||||||
|
sb.SelectMore(SpanIDColumn)
|
||||||
|
sb.SelectMore(TraceFlagsColumn)
|
||||||
|
sb.SelectMore(SeverityTextColumn)
|
||||||
|
sb.SelectMore(SeverityNumberColumn)
|
||||||
|
sb.SelectMore(ScopeNameColumn)
|
||||||
|
sb.SelectMore(ScopeVersionColumn)
|
||||||
|
sb.SelectMore(BodyColumn)
|
||||||
|
sb.SelectMore(EventNameColumn)
|
||||||
|
sb.SelectMore(AttributesStringColumn)
|
||||||
|
sb.SelectMore(AttributesNumberColumn)
|
||||||
|
sb.SelectMore(AttributesBoolColumn)
|
||||||
|
sb.SelectMore(ResourceColumn)
|
||||||
|
sb.SelectMore(ScopeStringColumn)
|
||||||
|
} else {
|
||||||
|
for index := range query.SelectFields {
|
||||||
|
if query.SelectFields[index].Name == TimestampColumn || query.SelectFields[index].Name == IDColumn {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
colExpr, err := b.fm.ColumnExpressionFor(ctx, start, end, &query.SelectFields[index], keys)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sb.SelectMore(colExpr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.From(fmt.Sprintf("%s.%s", DBName, AuditLogsTableName))
|
||||||
|
|
||||||
|
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, orderBy := range query.Order {
|
||||||
|
colExpr, err := b.fm.ColumnExpressionFor(ctx, start, end, &orderBy.Key.TelemetryFieldKey, keys)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sb.OrderBy(fmt.Sprintf("%s %s", colExpr, orderBy.Direction.StringValue()))
|
||||||
|
}
|
||||||
|
|
||||||
|
if query.Limit > 0 {
|
||||||
|
sb.Limit(query.Limit)
|
||||||
|
} else {
|
||||||
|
sb.Limit(100)
|
||||||
|
}
|
||||||
|
|
||||||
|
if query.Offset > 0 {
|
||||||
|
sb.Offset(query.Offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||||
|
|
||||||
|
finalSQL := querybuilder.CombineCTEs(cteFragments) + mainSQL
|
||||||
|
finalArgs := querybuilder.PrependArgs(cteArgs, mainArgs)
|
||||||
|
|
||||||
|
stmt := &qbtypes.Statement{
|
||||||
|
Query: finalSQL,
|
||||||
|
Args: finalArgs,
|
||||||
|
}
|
||||||
|
if preparedWhereClause != nil {
|
||||||
|
stmt.Warnings = preparedWhereClause.Warnings
|
||||||
|
stmt.WarningsDocURL = preparedWhereClause.WarningsDocURL
|
||||||
|
}
|
||||||
|
|
||||||
|
return stmt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *auditQueryStatementBuilder) buildTimeSeriesQuery(
|
||||||
|
ctx context.Context,
|
||||||
|
sb *sqlbuilder.SelectBuilder,
|
||||||
|
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
|
||||||
|
start, end uint64,
|
||||||
|
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||||
|
variables map[string]qbtypes.VariableItem,
|
||||||
|
) (*qbtypes.Statement, error) {
|
||||||
|
var (
|
||||||
|
cteFragments []string
|
||||||
|
cteArgs [][]any
|
||||||
|
)
|
||||||
|
|
||||||
|
if frag, args, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if frag != "" {
|
||||||
|
cteFragments = append(cteFragments, frag)
|
||||||
|
cteArgs = append(cteArgs, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.SelectMore(fmt.Sprintf(
|
||||||
|
"toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL %d SECOND) AS ts",
|
||||||
|
int64(query.StepInterval.Seconds()),
|
||||||
|
))
|
||||||
|
|
||||||
|
var allGroupByArgs []any
|
||||||
|
|
||||||
|
fieldNames := make([]string, 0, len(query.GroupBy))
|
||||||
|
for _, gb := range query.GroupBy {
|
||||||
|
expr, args, err := querybuilder.CollisionHandledFinalExpr(ctx, start, end, &gb.TelemetryFieldKey, b.fm, b.cb, keys, telemetrytypes.FieldDataTypeString, b.jsonKeyToKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
colExpr := fmt.Sprintf("toString(%s) AS `%s`", expr, gb.Name)
|
||||||
|
allGroupByArgs = append(allGroupByArgs, args...)
|
||||||
|
sb.SelectMore(colExpr)
|
||||||
|
fieldNames = append(fieldNames, fmt.Sprintf("`%s`", gb.Name))
|
||||||
|
}
|
||||||
|
|
||||||
|
allAggChArgs := make([]any, 0)
|
||||||
|
for i, agg := range query.Aggregations {
|
||||||
|
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(ctx, start, end, agg.Expression, uint64(query.StepInterval.Seconds()), keys)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
allAggChArgs = append(allAggChArgs, chArgs...)
|
||||||
|
sb.SelectMore(fmt.Sprintf("%s AS __result_%d", rewritten, i))
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.From(fmt.Sprintf("%s.%s", DBName, AuditLogsTableName))
|
||||||
|
|
||||||
|
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var finalSQL string
|
||||||
|
var finalArgs []any
|
||||||
|
|
||||||
|
if query.Limit > 0 && len(query.GroupBy) > 0 {
|
||||||
|
cteSB := sqlbuilder.NewSelectBuilder()
|
||||||
|
cteStmt, err := b.buildScalarQuery(ctx, cteSB, query, start, end, keys, true, variables)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cteFragments = append(cteFragments, fmt.Sprintf("__limit_cte AS (%s)", cteStmt.Query))
|
||||||
|
cteArgs = append(cteArgs, cteStmt.Args)
|
||||||
|
|
||||||
|
tuple := fmt.Sprintf("(%s)", strings.Join(fieldNames, ", "))
|
||||||
|
sb.Where(fmt.Sprintf("%s GLOBAL IN (SELECT %s FROM __limit_cte)", tuple, strings.Join(fieldNames, ", ")))
|
||||||
|
|
||||||
|
sb.GroupBy("ts")
|
||||||
|
sb.GroupBy(querybuilder.GroupByKeys(query.GroupBy)...)
|
||||||
|
if query.Having != nil && query.Having.Expression != "" {
|
||||||
|
rewriter := querybuilder.NewHavingExpressionRewriter()
|
||||||
|
rewrittenExpr, err := rewriter.RewriteForLogs(query.Having.Expression, query.Aggregations)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sb.Having(rewrittenExpr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(query.Order) != 0 {
|
||||||
|
for _, orderBy := range query.Order {
|
||||||
|
_, ok := aggOrderBy(orderBy, query)
|
||||||
|
if !ok {
|
||||||
|
sb.OrderBy(fmt.Sprintf("`%s` %s", orderBy.Key.Name, orderBy.Direction.StringValue()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.OrderBy("ts desc")
|
||||||
|
}
|
||||||
|
|
||||||
|
combinedArgs := append(allGroupByArgs, allAggChArgs...)
|
||||||
|
mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse, combinedArgs...)
|
||||||
|
|
||||||
|
finalSQL = querybuilder.CombineCTEs(cteFragments) + mainSQL
|
||||||
|
finalArgs = querybuilder.PrependArgs(cteArgs, mainArgs)
|
||||||
|
} else {
|
||||||
|
sb.GroupBy("ts")
|
||||||
|
sb.GroupBy(querybuilder.GroupByKeys(query.GroupBy)...)
|
||||||
|
if query.Having != nil && query.Having.Expression != "" {
|
||||||
|
rewriter := querybuilder.NewHavingExpressionRewriter()
|
||||||
|
rewrittenExpr, err := rewriter.RewriteForLogs(query.Having.Expression, query.Aggregations)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sb.Having(rewrittenExpr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(query.Order) != 0 {
|
||||||
|
for _, orderBy := range query.Order {
|
||||||
|
_, ok := aggOrderBy(orderBy, query)
|
||||||
|
if !ok {
|
||||||
|
sb.OrderBy(fmt.Sprintf("`%s` %s", orderBy.Key.Name, orderBy.Direction.StringValue()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.OrderBy("ts desc")
|
||||||
|
}
|
||||||
|
|
||||||
|
combinedArgs := append(allGroupByArgs, allAggChArgs...)
|
||||||
|
mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse, combinedArgs...)
|
||||||
|
|
||||||
|
finalSQL = querybuilder.CombineCTEs(cteFragments) + mainSQL
|
||||||
|
finalArgs = querybuilder.PrependArgs(cteArgs, mainArgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
stmt := &qbtypes.Statement{
|
||||||
|
Query: finalSQL,
|
||||||
|
Args: finalArgs,
|
||||||
|
}
|
||||||
|
if preparedWhereClause != nil {
|
||||||
|
stmt.Warnings = preparedWhereClause.Warnings
|
||||||
|
stmt.WarningsDocURL = preparedWhereClause.WarningsDocURL
|
||||||
|
}
|
||||||
|
|
||||||
|
return stmt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *auditQueryStatementBuilder) buildScalarQuery(
|
||||||
|
ctx context.Context,
|
||||||
|
sb *sqlbuilder.SelectBuilder,
|
||||||
|
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
|
||||||
|
start, end uint64,
|
||||||
|
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||||
|
skipResourceCTE bool,
|
||||||
|
variables map[string]qbtypes.VariableItem,
|
||||||
|
) (*qbtypes.Statement, error) {
|
||||||
|
var (
|
||||||
|
cteFragments []string
|
||||||
|
cteArgs [][]any
|
||||||
|
)
|
||||||
|
|
||||||
|
if frag, args, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if frag != "" && !skipResourceCTE {
|
||||||
|
cteFragments = append(cteFragments, frag)
|
||||||
|
cteArgs = append(cteArgs, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
allAggChArgs := []any{}
|
||||||
|
|
||||||
|
var allGroupByArgs []any
|
||||||
|
|
||||||
|
for _, gb := range query.GroupBy {
|
||||||
|
expr, args, err := querybuilder.CollisionHandledFinalExpr(ctx, start, end, &gb.TelemetryFieldKey, b.fm, b.cb, keys, telemetrytypes.FieldDataTypeString, b.jsonKeyToKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
colExpr := fmt.Sprintf("toString(%s) AS `%s`", expr, gb.Name)
|
||||||
|
allGroupByArgs = append(allGroupByArgs, args...)
|
||||||
|
sb.SelectMore(colExpr)
|
||||||
|
}
|
||||||
|
|
||||||
|
rateInterval := (end - start) / querybuilder.NsToSeconds
|
||||||
|
|
||||||
|
if len(query.Aggregations) > 0 {
|
||||||
|
for idx := range query.Aggregations {
|
||||||
|
aggExpr := query.Aggregations[idx]
|
||||||
|
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(ctx, start, end, aggExpr.Expression, rateInterval, keys)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
allAggChArgs = append(allAggChArgs, chArgs...)
|
||||||
|
sb.SelectMore(fmt.Sprintf("%s AS __result_%d", rewritten, idx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.From(fmt.Sprintf("%s.%s", DBName, AuditLogsTableName))
|
||||||
|
|
||||||
|
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.GroupBy(querybuilder.GroupByKeys(query.GroupBy)...)
|
||||||
|
|
||||||
|
if query.Having != nil && query.Having.Expression != "" {
|
||||||
|
rewriter := querybuilder.NewHavingExpressionRewriter()
|
||||||
|
rewrittenExpr, err := rewriter.RewriteForLogs(query.Having.Expression, query.Aggregations)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sb.Having(rewrittenExpr)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, orderBy := range query.Order {
|
||||||
|
idx, ok := aggOrderBy(orderBy, query)
|
||||||
|
if ok {
|
||||||
|
sb.OrderBy(fmt.Sprintf("__result_%d %s", idx, orderBy.Direction.StringValue()))
|
||||||
|
} else {
|
||||||
|
sb.OrderBy(fmt.Sprintf("`%s` %s", orderBy.Key.Name, orderBy.Direction.StringValue()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(query.Order) == 0 {
|
||||||
|
sb.OrderBy("__result_0 DESC")
|
||||||
|
}
|
||||||
|
|
||||||
|
if query.Limit > 0 {
|
||||||
|
sb.Limit(query.Limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
combinedArgs := append(allGroupByArgs, allAggChArgs...)
|
||||||
|
|
||||||
|
mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse, combinedArgs...)
|
||||||
|
|
||||||
|
finalSQL := querybuilder.CombineCTEs(cteFragments) + mainSQL
|
||||||
|
finalArgs := querybuilder.PrependArgs(cteArgs, mainArgs)
|
||||||
|
|
||||||
|
stmt := &qbtypes.Statement{
|
||||||
|
Query: finalSQL,
|
||||||
|
Args: finalArgs,
|
||||||
|
}
|
||||||
|
if preparedWhereClause != nil {
|
||||||
|
stmt.Warnings = preparedWhereClause.Warnings
|
||||||
|
stmt.WarningsDocURL = preparedWhereClause.WarningsDocURL
|
||||||
|
}
|
||||||
|
|
||||||
|
return stmt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *auditQueryStatementBuilder) addFilterCondition(
|
||||||
|
ctx context.Context,
|
||||||
|
sb *sqlbuilder.SelectBuilder,
|
||||||
|
start, end uint64,
|
||||||
|
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
|
||||||
|
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||||
|
variables map[string]qbtypes.VariableItem,
|
||||||
|
) (*querybuilder.PreparedWhereClause, error) {
|
||||||
|
var preparedWhereClause *querybuilder.PreparedWhereClause
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if query.Filter != nil && query.Filter.Expression != "" {
|
||||||
|
preparedWhereClause, err = querybuilder.PrepareWhereClause(query.Filter.Expression, querybuilder.FilterExprVisitorOpts{
|
||||||
|
Context: ctx,
|
||||||
|
Logger: b.logger,
|
||||||
|
FieldMapper: b.fm,
|
||||||
|
ConditionBuilder: b.cb,
|
||||||
|
FieldKeys: keys,
|
||||||
|
SkipResourceFilter: true,
|
||||||
|
FullTextColumn: b.fullTextColumn,
|
||||||
|
JsonKeyToKey: b.jsonKeyToKey,
|
||||||
|
Variables: variables,
|
||||||
|
StartNs: start,
|
||||||
|
EndNs: end,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if preparedWhereClause != nil {
|
||||||
|
sb.AddWhereClause(preparedWhereClause.WhereClause)
|
||||||
|
}
|
||||||
|
|
||||||
|
startBucket := start/querybuilder.NsToSeconds - querybuilder.BucketAdjustment
|
||||||
|
var endBucket uint64
|
||||||
|
if end != 0 {
|
||||||
|
endBucket = end / querybuilder.NsToSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
if start != 0 {
|
||||||
|
sb.Where(sb.GE("timestamp", fmt.Sprintf("%d", start)), sb.GE("ts_bucket_start", startBucket))
|
||||||
|
}
|
||||||
|
if end != 0 {
|
||||||
|
sb.Where(sb.L("timestamp", fmt.Sprintf("%d", end)), sb.LE("ts_bucket_start", endBucket))
|
||||||
|
}
|
||||||
|
|
||||||
|
return preparedWhereClause, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func aggOrderBy(k qbtypes.OrderBy, q qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]) (int, bool) {
|
||||||
|
for i, agg := range q.Aggregations {
|
||||||
|
if k.Key.Name == agg.Alias || k.Key.Name == agg.Expression || k.Key.Name == fmt.Sprintf("%d", i) {
|
||||||
|
return i, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *auditQueryStatementBuilder) maybeAttachResourceFilter(
|
||||||
|
ctx context.Context,
|
||||||
|
sb *sqlbuilder.SelectBuilder,
|
||||||
|
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
|
||||||
|
start, end uint64,
|
||||||
|
variables map[string]qbtypes.VariableItem,
|
||||||
|
) (cteSQL string, cteArgs []any, err error) {
|
||||||
|
stmt, err := b.resourceFilterStmtBuilder.Build(ctx, start, end, qbtypes.RequestTypeRaw, query, variables)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Where("resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)")
|
||||||
|
|
||||||
|
return fmt.Sprintf("__resource_filter AS (%s)", stmt.Query), stmt.Args, nil
|
||||||
|
}
|
||||||
223
pkg/telemetryaudit/statement_builder_test.go
Normal file
223
pkg/telemetryaudit/statement_builder_test.go
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
package telemetryaudit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||||
|
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||||
|
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||||
|
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||||
|
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func auditFieldKeyMap() map[string][]*telemetrytypes.TelemetryFieldKey {
|
||||||
|
key := func(name string, ctx telemetrytypes.FieldContext, dt telemetrytypes.FieldDataType, materialized bool) *telemetrytypes.TelemetryFieldKey {
|
||||||
|
return &telemetrytypes.TelemetryFieldKey{
|
||||||
|
Name: name,
|
||||||
|
Signal: telemetrytypes.SignalLogs,
|
||||||
|
FieldContext: ctx,
|
||||||
|
FieldDataType: dt,
|
||||||
|
Materialized: materialized,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
attr := telemetrytypes.FieldContextAttribute
|
||||||
|
res := telemetrytypes.FieldContextResource
|
||||||
|
str := telemetrytypes.FieldDataTypeString
|
||||||
|
i64 := telemetrytypes.FieldDataTypeInt64
|
||||||
|
|
||||||
|
return map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||||
|
"service.name": {key("service.name", res, str, false)},
|
||||||
|
"signoz.audit.action": {key("signoz.audit.action", attr, str, true)},
|
||||||
|
"signoz.audit.outcome": {key("signoz.audit.outcome", attr, str, true)},
|
||||||
|
"signoz.audit.principal.email": {key("signoz.audit.principal.email", attr, str, true)},
|
||||||
|
"signoz.audit.principal.id": {key("signoz.audit.principal.id", attr, str, true)},
|
||||||
|
"signoz.audit.principal.type": {key("signoz.audit.principal.type", attr, str, true)},
|
||||||
|
"signoz.audit.resource.kind": {key("signoz.audit.resource.kind", res, str, false)},
|
||||||
|
"signoz.audit.resource.id": {key("signoz.audit.resource.id", res, str, false)},
|
||||||
|
"signoz.audit.action_category": {key("signoz.audit.action_category", attr, str, false)},
|
||||||
|
"signoz.audit.error.type": {key("signoz.audit.error.type", attr, str, false)},
|
||||||
|
"signoz.audit.error.code": {key("signoz.audit.error.code", attr, str, false)},
|
||||||
|
"http.request.method": {key("http.request.method", attr, str, false)},
|
||||||
|
"http.response.status_code": {key("http.response.status_code", attr, i64, false)},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestAuditStatementBuilder() *auditQueryStatementBuilder {
|
||||||
|
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||||
|
mockMetadataStore.KeysMap = auditFieldKeyMap()
|
||||||
|
|
||||||
|
fm := NewFieldMapper()
|
||||||
|
cb := NewConditionBuilder(fm)
|
||||||
|
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil)
|
||||||
|
|
||||||
|
return NewAuditQueryStatementBuilder(
|
||||||
|
instrumentationtest.New().ToProviderSettings(),
|
||||||
|
mockMetadataStore,
|
||||||
|
fm,
|
||||||
|
cb,
|
||||||
|
aggExprRewriter,
|
||||||
|
DefaultFullTextColumn,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatementBuilder(t *testing.T) {
|
||||||
|
statementBuilder := newTestAuditStatementBuilder()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
requestType qbtypes.RequestType
|
||||||
|
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
|
||||||
|
expected qbtypes.Statement
|
||||||
|
expectedErr error
|
||||||
|
}{
|
||||||
|
// List: all actions by a specific user (materialized principal.id filter)
|
||||||
|
{
|
||||||
|
name: "ListByPrincipalID",
|
||||||
|
requestType: qbtypes.RequestTypeRaw,
|
||||||
|
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||||
|
Signal: telemetrytypes.SignalLogs,
|
||||||
|
Source: telemetrytypes.SourceAudit,
|
||||||
|
Filter: &qbtypes.Filter{
|
||||||
|
Expression: "signoz.audit.principal.id = '019a-1234-abcd-5678'",
|
||||||
|
},
|
||||||
|
Limit: 100,
|
||||||
|
},
|
||||||
|
expected: qbtypes.Statement{
|
||||||
|
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND (`attribute_string_signoz$$audit$$principal$$id` = ? AND `attribute_string_signoz$$audit$$principal$$id_exists` = ?) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
|
||||||
|
Args: []any{uint64(1747945619), uint64(1747983448), "019a-1234-abcd-5678", true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 100},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// List: all failed actions (materialized outcome filter)
|
||||||
|
{
|
||||||
|
name: "ListByOutcomeFailure",
|
||||||
|
requestType: qbtypes.RequestTypeRaw,
|
||||||
|
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||||
|
Signal: telemetrytypes.SignalLogs,
|
||||||
|
Source: telemetrytypes.SourceAudit,
|
||||||
|
Filter: &qbtypes.Filter{
|
||||||
|
Expression: "signoz.audit.outcome = 'failure'",
|
||||||
|
},
|
||||||
|
Limit: 100,
|
||||||
|
},
|
||||||
|
expected: qbtypes.Statement{
|
||||||
|
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND (`attribute_string_signoz$$audit$$outcome` = ? AND `attribute_string_signoz$$audit$$outcome_exists` = ?) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
|
||||||
|
Args: []any{uint64(1747945619), uint64(1747983448), "failure", true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 100},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// List: change history of a specific dashboard (two materialized column AND)
|
||||||
|
{
|
||||||
|
name: "ListByResourceKindAndID",
|
||||||
|
requestType: qbtypes.RequestTypeRaw,
|
||||||
|
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||||
|
Signal: telemetrytypes.SignalLogs,
|
||||||
|
Source: telemetrytypes.SourceAudit,
|
||||||
|
Filter: &qbtypes.Filter{
|
||||||
|
Expression: "signoz.audit.resource.kind = 'dashboard' AND signoz.audit.resource.id = '019b-5678-efgh-9012'",
|
||||||
|
},
|
||||||
|
Limit: 100,
|
||||||
|
},
|
||||||
|
expected: qbtypes.Statement{
|
||||||
|
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE ((simpleJSONExtractString(labels, 'signoz.audit.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?) AND (simpleJSONExtractString(labels, 'signoz.audit.resource.id') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
|
||||||
|
Args: []any{"dashboard", "%signoz.audit.resource.kind%", "%signoz.audit.resource.kind\":\"dashboard%", "019b-5678-efgh-9012", "%signoz.audit.resource.id%", "%signoz.audit.resource.id\":\"019b-5678-efgh-9012%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 100},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// List: all dashboard deletions (compliance — resource.kind + action AND)
|
||||||
|
{
|
||||||
|
name: "ListByResourceKindAndAction",
|
||||||
|
requestType: qbtypes.RequestTypeRaw,
|
||||||
|
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||||
|
Signal: telemetrytypes.SignalLogs,
|
||||||
|
Source: telemetrytypes.SourceAudit,
|
||||||
|
Filter: &qbtypes.Filter{
|
||||||
|
Expression: "signoz.audit.resource.kind = 'dashboard' AND signoz.audit.action = 'delete'",
|
||||||
|
},
|
||||||
|
Limit: 100,
|
||||||
|
},
|
||||||
|
expected: qbtypes.Statement{
|
||||||
|
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE (simpleJSONExtractString(labels, 'signoz.audit.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND (`attribute_string_signoz$$audit$$action` = ? AND `attribute_string_signoz$$audit$$action_exists` = ?) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
|
||||||
|
Args: []any{"dashboard", "%signoz.audit.resource.kind%", "%signoz.audit.resource.kind\":\"dashboard%", uint64(1747945619), uint64(1747983448), "delete", true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 100},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// List: all actions by service accounts (materialized principal.type)
|
||||||
|
{
|
||||||
|
name: "ListByPrincipalType",
|
||||||
|
requestType: qbtypes.RequestTypeRaw,
|
||||||
|
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||||
|
Signal: telemetrytypes.SignalLogs,
|
||||||
|
Source: telemetrytypes.SourceAudit,
|
||||||
|
Filter: &qbtypes.Filter{
|
||||||
|
Expression: "signoz.audit.principal.type = 'service_account'",
|
||||||
|
},
|
||||||
|
Limit: 100,
|
||||||
|
},
|
||||||
|
expected: qbtypes.Statement{
|
||||||
|
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND (`attribute_string_signoz$$audit$$principal$$type` = ? AND `attribute_string_signoz$$audit$$principal$$type_exists` = ?) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
|
||||||
|
Args: []any{uint64(1747945619), uint64(1747983448), "service_account", true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 100},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Scalar: alert — count forbidden errors (outcome + action AND)
|
||||||
|
{
|
||||||
|
name: "ScalarCountByOutcomeAndAction",
|
||||||
|
requestType: qbtypes.RequestTypeScalar,
|
||||||
|
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||||
|
Signal: telemetrytypes.SignalLogs,
|
||||||
|
Source: telemetrytypes.SourceAudit,
|
||||||
|
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
||||||
|
Filter: &qbtypes.Filter{
|
||||||
|
Expression: "signoz.audit.outcome = 'failure' AND signoz.audit.action = 'update'",
|
||||||
|
},
|
||||||
|
Aggregations: []qbtypes.LogAggregation{
|
||||||
|
{Expression: "count()"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: qbtypes.Statement{
|
||||||
|
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT count() AS __result_0 FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((`attribute_string_signoz$$audit$$outcome` = ? AND `attribute_string_signoz$$audit$$outcome_exists` = ?) AND (`attribute_string_signoz$$audit$$action` = ? AND `attribute_string_signoz$$audit$$action_exists` = ?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY __result_0 DESC",
|
||||||
|
Args: []any{uint64(1747945619), uint64(1747983448), "failure", true, "update", true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// TimeSeries: failures grouped by principal email with top-N limit
|
||||||
|
{
|
||||||
|
name: "TimeSeriesFailuresGroupedByPrincipal",
|
||||||
|
requestType: qbtypes.RequestTypeTimeSeries,
|
||||||
|
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||||
|
Signal: telemetrytypes.SignalLogs,
|
||||||
|
Source: telemetrytypes.SourceAudit,
|
||||||
|
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
||||||
|
Aggregations: []qbtypes.LogAggregation{
|
||||||
|
{Expression: "count()"},
|
||||||
|
},
|
||||||
|
Filter: &qbtypes.Filter{
|
||||||
|
Expression: "signoz.audit.outcome = 'failure'",
|
||||||
|
},
|
||||||
|
GroupBy: []qbtypes.GroupByKey{
|
||||||
|
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "signoz.audit.principal.email"}},
|
||||||
|
},
|
||||||
|
Limit: 5,
|
||||||
|
},
|
||||||
|
expected: qbtypes.Statement{
|
||||||
|
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(`attribute_string_signoz$$audit$$principal$$email_exists` = ?, `attribute_string_signoz$$audit$$principal$$email`, NULL)) AS `signoz.audit.principal.email`, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND (`attribute_string_signoz$$audit$$outcome` = ? AND `attribute_string_signoz$$audit$$outcome_exists` = ?) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `signoz.audit.principal.email` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 60 SECOND) AS ts, toString(multiIf(`attribute_string_signoz$$audit$$principal$$email_exists` = ?, `attribute_string_signoz$$audit$$principal$$email`, NULL)) AS `signoz.audit.principal.email`, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND (`attribute_string_signoz$$audit$$outcome` = ? AND `attribute_string_signoz$$audit$$outcome_exists` = ?) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`signoz.audit.principal.email`) GLOBAL IN (SELECT `signoz.audit.principal.email` FROM __limit_cte) GROUP BY ts, `signoz.audit.principal.email`",
|
||||||
|
Args: []any{uint64(1747945619), uint64(1747983448), true, "failure", true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 5, true, "failure", true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, testCase := range testCases {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
q, err := statementBuilder.Build(ctx, 1747947419000, 1747983448000, testCase.requestType, testCase.query, nil)
|
||||||
|
if testCase.expectedErr != nil {
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), testCase.expectedErr.Error())
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, testCase.expected.Query, q.Query)
|
||||||
|
require.Equal(t, testCase.expected.Args, q.Args)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
12
pkg/telemetryaudit/tables.go
Normal file
12
pkg/telemetryaudit/tables.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package telemetryaudit
|
||||||
|
|
||||||
|
const (
|
||||||
|
DBName = "signoz_audit"
|
||||||
|
AuditLogsTableName = "distributed_logs"
|
||||||
|
AuditLogsLocalTableName = "logs"
|
||||||
|
TagAttributesTableName = "distributed_tag_attributes"
|
||||||
|
TagAttributesLocalTableName = "tag_attributes"
|
||||||
|
LogAttributeKeysTblName = "distributed_logs_attribute_keys"
|
||||||
|
LogResourceKeysTblName = "distributed_logs_resource_keys"
|
||||||
|
LogsResourceTableName = "distributed_logs_resource"
|
||||||
|
)
|
||||||
@@ -631,7 +631,7 @@ func TestJSONStmtBuilder_ArrayPaths(t *testing.T) {
|
|||||||
filter: "hasAll(body.user.permissions, ['read', 'write'])",
|
filter: "hasAll(body.user.permissions, ['read', 'write'])",
|
||||||
expected: TestExpected{
|
expected: TestExpected{
|
||||||
WhereClause: "hasAll(dynamicElement(body_v2.`user.permissions`, 'Array(Nullable(String))'), ?)",
|
WhereClause: "hasAll(dynamicElement(body_v2.`user.permissions`, 'Array(Nullable(String))'), ?)",
|
||||||
Args: []any{uint64(1747945619), uint64(1747983448), []any{[]any{"read", "write"}}, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
Args: []any{uint64(1747945619), uint64(1747983448), []any{"read", "write"}, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -757,7 +757,7 @@ func TestJSONStmtBuilder_ArrayPaths(t *testing.T) {
|
|||||||
filter: "hasAny(education[].awards[].participated[].members, ['Piyush', 'Tushar'])",
|
filter: "hasAny(education[].awards[].participated[].members, ['Piyush', 'Tushar'])",
|
||||||
expected: TestExpected{
|
expected: TestExpected{
|
||||||
WhereClause: "hasAny(arrayFlatten(arrayConcat(arrayMap(`body_v2.education`->arrayConcat(arrayMap(`body_v2.education[].awards`->arrayConcat(arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')), arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(Dynamic)'))))), dynamicElement(`body_v2.education`.`awards`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), arrayMap(`body_v2.education[].awards`->arrayConcat(arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))')), arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(Dynamic)'))))), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education`.`awards`, 'Array(Dynamic)'))))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))), ?)",
|
WhereClause: "hasAny(arrayFlatten(arrayConcat(arrayMap(`body_v2.education`->arrayConcat(arrayMap(`body_v2.education[].awards`->arrayConcat(arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')), arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(Dynamic)'))))), dynamicElement(`body_v2.education`.`awards`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), arrayMap(`body_v2.education[].awards`->arrayConcat(arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))')), arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(Dynamic)'))))), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education`.`awards`, 'Array(Dynamic)'))))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))), ?)",
|
||||||
Args: []any{uint64(1747945619), uint64(1747983448), []any{[]any{"Piyush", "Tushar"}}, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
Args: []any{uint64(1747945619), uint64(1747983448), []any{"Piyush", "Tushar"}, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ func NewLogQueryStatementBuilder(
|
|||||||
DBName,
|
DBName,
|
||||||
LogsResourceV2TableName,
|
LogsResourceV2TableName,
|
||||||
telemetrytypes.SignalLogs,
|
telemetrytypes.SignalLogs,
|
||||||
|
telemetrytypes.SourceUnspecified,
|
||||||
metadataStore,
|
metadataStore,
|
||||||
fullTextColumn,
|
fullTextColumn,
|
||||||
jsonKeyToKey,
|
jsonKeyToKey,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/SigNoz/signoz/pkg/errors"
|
"github.com/SigNoz/signoz/pkg/errors"
|
||||||
"github.com/SigNoz/signoz/pkg/factory"
|
"github.com/SigNoz/signoz/pkg/factory"
|
||||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||||
|
"github.com/SigNoz/signoz/pkg/telemetryaudit"
|
||||||
"github.com/SigNoz/signoz/pkg/telemetrylogs"
|
"github.com/SigNoz/signoz/pkg/telemetrylogs"
|
||||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||||
@@ -27,6 +28,7 @@ import (
|
|||||||
var (
|
var (
|
||||||
ErrFailedToGetTracesKeys = errors.Newf(errors.TypeInternal, errors.CodeInternal, "failed to get traces keys")
|
ErrFailedToGetTracesKeys = errors.Newf(errors.TypeInternal, errors.CodeInternal, "failed to get traces keys")
|
||||||
ErrFailedToGetLogsKeys = errors.Newf(errors.TypeInternal, errors.CodeInternal, "failed to get logs keys")
|
ErrFailedToGetLogsKeys = errors.Newf(errors.TypeInternal, errors.CodeInternal, "failed to get logs keys")
|
||||||
|
ErrFailedToGetAuditKeys = errors.Newf(errors.TypeInternal, errors.CodeInternal, "failed to get audit keys")
|
||||||
ErrFailedToGetTblStatement = errors.Newf(errors.TypeInternal, errors.CodeInternal, "failed to get tbl statement")
|
ErrFailedToGetTblStatement = errors.Newf(errors.TypeInternal, errors.CodeInternal, "failed to get tbl statement")
|
||||||
ErrFailedToGetMetricsKeys = errors.Newf(errors.TypeInternal, errors.CodeInternal, "failed to get metrics keys")
|
ErrFailedToGetMetricsKeys = errors.Newf(errors.TypeInternal, errors.CodeInternal, "failed to get metrics keys")
|
||||||
ErrFailedToGetMeterKeys = errors.Newf(errors.TypeInternal, errors.CodeInternal, "failed to get meter keys")
|
ErrFailedToGetMeterKeys = errors.Newf(errors.TypeInternal, errors.CodeInternal, "failed to get meter keys")
|
||||||
@@ -50,6 +52,11 @@ type telemetryMetaStore struct {
|
|||||||
logAttributeKeysTblName string
|
logAttributeKeysTblName string
|
||||||
logResourceKeysTblName string
|
logResourceKeysTblName string
|
||||||
logsV2TblName string
|
logsV2TblName string
|
||||||
|
auditDBName string
|
||||||
|
auditLogsTblName string
|
||||||
|
auditFieldsTblName string
|
||||||
|
auditAttributeKeysTblName string
|
||||||
|
auditResourceKeysTblName string
|
||||||
relatedMetadataDBName string
|
relatedMetadataDBName string
|
||||||
relatedMetadataTblName string
|
relatedMetadataTblName string
|
||||||
columnEvolutionMetadataTblName string
|
columnEvolutionMetadataTblName string
|
||||||
@@ -79,6 +86,11 @@ func NewTelemetryMetaStore(
|
|||||||
logsFieldsTblName string,
|
logsFieldsTblName string,
|
||||||
logAttributeKeysTblName string,
|
logAttributeKeysTblName string,
|
||||||
logResourceKeysTblName string,
|
logResourceKeysTblName string,
|
||||||
|
auditDBName string,
|
||||||
|
auditLogsTblName string,
|
||||||
|
auditFieldsTblName string,
|
||||||
|
auditAttributeKeysTblName string,
|
||||||
|
auditResourceKeysTblName string,
|
||||||
relatedMetadataDBName string,
|
relatedMetadataDBName string,
|
||||||
relatedMetadataTblName string,
|
relatedMetadataTblName string,
|
||||||
columnEvolutionMetadataTblName string,
|
columnEvolutionMetadataTblName string,
|
||||||
@@ -101,6 +113,11 @@ func NewTelemetryMetaStore(
|
|||||||
logsFieldsTblName: logsFieldsTblName,
|
logsFieldsTblName: logsFieldsTblName,
|
||||||
logAttributeKeysTblName: logAttributeKeysTblName,
|
logAttributeKeysTblName: logAttributeKeysTblName,
|
||||||
logResourceKeysTblName: logResourceKeysTblName,
|
logResourceKeysTblName: logResourceKeysTblName,
|
||||||
|
auditDBName: auditDBName,
|
||||||
|
auditLogsTblName: auditLogsTblName,
|
||||||
|
auditFieldsTblName: auditFieldsTblName,
|
||||||
|
auditAttributeKeysTblName: auditAttributeKeysTblName,
|
||||||
|
auditResourceKeysTblName: auditResourceKeysTblName,
|
||||||
relatedMetadataDBName: relatedMetadataDBName,
|
relatedMetadataDBName: relatedMetadataDBName,
|
||||||
relatedMetadataTblName: relatedMetadataTblName,
|
relatedMetadataTblName: relatedMetadataTblName,
|
||||||
columnEvolutionMetadataTblName: columnEvolutionMetadataTblName,
|
columnEvolutionMetadataTblName: columnEvolutionMetadataTblName,
|
||||||
@@ -592,6 +609,227 @@ func (t *telemetryMetaStore) getLogsKeys(ctx context.Context, fieldKeySelectors
|
|||||||
return keys, complete, nil
|
return keys, complete, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *telemetryMetaStore) auditTblStatementToFieldKeys(ctx context.Context) ([]*telemetrytypes.TelemetryFieldKey, error) {
|
||||||
|
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||||
|
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalLogs.StringValue(),
|
||||||
|
instrumentationtypes.CodeNamespace: "metadata",
|
||||||
|
instrumentationtypes.CodeFunctionName: "auditTblStatementToFieldKeys",
|
||||||
|
})
|
||||||
|
|
||||||
|
query := fmt.Sprintf("SHOW CREATE TABLE %s.%s", t.auditDBName, t.auditLogsTblName)
|
||||||
|
statements := []telemetrytypes.ShowCreateTableStatement{}
|
||||||
|
err := t.telemetrystore.ClickhouseDB().Select(ctx, &statements, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, ErrFailedToGetTblStatement.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
materialisedKeys, err := ExtractFieldKeysFromTblStatement(statements[0].Statement)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, ErrFailedToGetAuditKeys.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
for idx := range materialisedKeys {
|
||||||
|
materialisedKeys[idx].Signal = telemetrytypes.SignalLogs
|
||||||
|
}
|
||||||
|
|
||||||
|
return materialisedKeys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *telemetryMetaStore) getAuditKeys(ctx context.Context, fieldKeySelectors []*telemetrytypes.FieldKeySelector) ([]*telemetrytypes.TelemetryFieldKey, bool, error) {
|
||||||
|
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||||
|
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalLogs.StringValue(),
|
||||||
|
instrumentationtypes.CodeNamespace: "metadata",
|
||||||
|
instrumentationtypes.CodeFunctionName: "getAuditKeys",
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(fieldKeySelectors) == 0 {
|
||||||
|
return nil, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
matKeys, err := t.auditTblStatementToFieldKeys(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
mapOfKeys := make(map[string]*telemetrytypes.TelemetryFieldKey)
|
||||||
|
for _, key := range matKeys {
|
||||||
|
mapOfKeys[key.Name+";"+key.FieldContext.StringValue()+";"+key.FieldDataType.StringValue()] = key
|
||||||
|
}
|
||||||
|
|
||||||
|
var queries []string
|
||||||
|
var allArgs []any
|
||||||
|
|
||||||
|
queryAttributeTable := false
|
||||||
|
queryResourceTable := false
|
||||||
|
|
||||||
|
for _, selector := range fieldKeySelectors {
|
||||||
|
if selector.FieldContext == telemetrytypes.FieldContextUnspecified {
|
||||||
|
queryAttributeTable = true
|
||||||
|
queryResourceTable = true
|
||||||
|
break
|
||||||
|
} else if selector.FieldContext == telemetrytypes.FieldContextAttribute {
|
||||||
|
queryAttributeTable = true
|
||||||
|
} else if selector.FieldContext == telemetrytypes.FieldContextResource {
|
||||||
|
queryResourceTable = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tablesToQuery := []struct {
|
||||||
|
fieldContext telemetrytypes.FieldContext
|
||||||
|
shouldQuery bool
|
||||||
|
tblName string
|
||||||
|
}{
|
||||||
|
{telemetrytypes.FieldContextAttribute, queryAttributeTable, t.auditDBName + "." + t.auditAttributeKeysTblName},
|
||||||
|
{telemetrytypes.FieldContextResource, queryResourceTable, t.auditDBName + "." + t.auditResourceKeysTblName},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, table := range tablesToQuery {
|
||||||
|
if !table.shouldQuery {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldContext := table.fieldContext
|
||||||
|
tblName := table.tblName
|
||||||
|
|
||||||
|
sb := sqlbuilder.Select(
|
||||||
|
"name AS tag_key",
|
||||||
|
fmt.Sprintf("'%s' AS tag_type", fieldContext.TagType()),
|
||||||
|
"lower(datatype) AS tag_data_type",
|
||||||
|
fmt.Sprintf("%d AS priority", getPriorityForContext(fieldContext)),
|
||||||
|
).From(tblName)
|
||||||
|
|
||||||
|
var limit int
|
||||||
|
conds := []string{}
|
||||||
|
|
||||||
|
for _, fieldKeySelector := range fieldKeySelectors {
|
||||||
|
if fieldKeySelector.FieldContext != telemetrytypes.FieldContextUnspecified && fieldKeySelector.FieldContext != fieldContext {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldKeyConds := []string{}
|
||||||
|
if fieldKeySelector.SelectorMatchType == telemetrytypes.FieldSelectorMatchTypeExact {
|
||||||
|
fieldKeyConds = append(fieldKeyConds, sb.E("name", fieldKeySelector.Name))
|
||||||
|
} else {
|
||||||
|
fieldKeyConds = append(fieldKeyConds, sb.ILike("name", "%"+escapeForLike(fieldKeySelector.Name)+"%"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if fieldKeySelector.FieldDataType != telemetrytypes.FieldDataTypeUnspecified {
|
||||||
|
fieldKeyConds = append(fieldKeyConds, sb.E("datatype", fieldKeySelector.FieldDataType.TagDataType()))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(fieldKeyConds) > 0 {
|
||||||
|
conds = append(conds, sb.And(fieldKeyConds...))
|
||||||
|
}
|
||||||
|
limit += fieldKeySelector.Limit
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(conds) > 0 {
|
||||||
|
sb.Where(sb.Or(conds...))
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.GroupBy("name", "datatype")
|
||||||
|
if limit == 0 {
|
||||||
|
limit = 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||||
|
queries = append(queries, query)
|
||||||
|
allArgs = append(allArgs, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(queries) == 0 {
|
||||||
|
return []*telemetrytypes.TelemetryFieldKey{}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var limit int
|
||||||
|
for _, fieldKeySelector := range fieldKeySelectors {
|
||||||
|
limit += fieldKeySelector.Limit
|
||||||
|
}
|
||||||
|
if limit == 0 {
|
||||||
|
limit = 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
mainQuery := fmt.Sprintf(`
|
||||||
|
SELECT tag_key, tag_type, tag_data_type, max(priority) as priority
|
||||||
|
FROM (
|
||||||
|
%s
|
||||||
|
) AS combined_results
|
||||||
|
GROUP BY tag_key, tag_type, tag_data_type
|
||||||
|
ORDER BY priority
|
||||||
|
LIMIT %d
|
||||||
|
`, strings.Join(queries, " UNION ALL "), limit+1)
|
||||||
|
|
||||||
|
rows, err := t.telemetrystore.ClickhouseDB().Query(ctx, mainQuery, allArgs...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, ErrFailedToGetAuditKeys.Error())
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
keys := []*telemetrytypes.TelemetryFieldKey{}
|
||||||
|
rowCount := 0
|
||||||
|
searchTexts := []string{}
|
||||||
|
|
||||||
|
for _, fieldKeySelector := range fieldKeySelectors {
|
||||||
|
searchTexts = append(searchTexts, fieldKeySelector.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
rowCount++
|
||||||
|
if rowCount > limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
var name string
|
||||||
|
var fieldContext telemetrytypes.FieldContext
|
||||||
|
var fieldDataType telemetrytypes.FieldDataType
|
||||||
|
var priority uint8
|
||||||
|
err = rows.Scan(&name, &fieldContext, &fieldDataType, &priority)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, ErrFailedToGetAuditKeys.Error())
|
||||||
|
}
|
||||||
|
key, ok := mapOfKeys[name+";"+fieldContext.StringValue()+";"+fieldDataType.StringValue()]
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
key = &telemetrytypes.TelemetryFieldKey{
|
||||||
|
Name: name,
|
||||||
|
Signal: telemetrytypes.SignalLogs,
|
||||||
|
FieldContext: fieldContext,
|
||||||
|
FieldDataType: fieldDataType,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys = append(keys, key)
|
||||||
|
mapOfKeys[name+";"+fieldContext.StringValue()+";"+fieldDataType.StringValue()] = key
|
||||||
|
}
|
||||||
|
|
||||||
|
if rows.Err() != nil {
|
||||||
|
return nil, false, errors.Wrap(rows.Err(), errors.TypeInternal, errors.CodeInternal, ErrFailedToGetAuditKeys.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
complete := rowCount <= limit
|
||||||
|
|
||||||
|
// Add intrinsic audit fields (same as logs intrinsics: body, severity_text, etc.)
|
||||||
|
staticKeys := maps.Keys(telemetryaudit.IntrinsicFields)
|
||||||
|
for _, key := range staticKeys {
|
||||||
|
found := false
|
||||||
|
for _, v := range searchTexts {
|
||||||
|
if v == "" || strings.Contains(key, v) {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if found {
|
||||||
|
if field, exists := telemetryaudit.IntrinsicFields[key]; exists {
|
||||||
|
if _, added := mapOfKeys[field.Name+";"+field.FieldContext.StringValue()+";"+field.FieldDataType.StringValue()]; !added {
|
||||||
|
keys = append(keys, &field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys, complete, nil
|
||||||
|
}
|
||||||
|
|
||||||
func getPriorityForContext(ctx telemetrytypes.FieldContext) int {
|
func getPriorityForContext(ctx telemetrytypes.FieldContext) int {
|
||||||
switch ctx {
|
switch ctx {
|
||||||
case telemetrytypes.FieldContextLog:
|
case telemetrytypes.FieldContextLog:
|
||||||
@@ -889,7 +1127,11 @@ func (t *telemetryMetaStore) GetKeys(ctx context.Context, fieldKeySelector *tele
|
|||||||
case telemetrytypes.SignalTraces:
|
case telemetrytypes.SignalTraces:
|
||||||
keys, complete, err = t.getTracesKeys(ctx, selectors)
|
keys, complete, err = t.getTracesKeys(ctx, selectors)
|
||||||
case telemetrytypes.SignalLogs:
|
case telemetrytypes.SignalLogs:
|
||||||
keys, complete, err = t.getLogsKeys(ctx, selectors)
|
if fieldKeySelector.Source == telemetrytypes.SourceAudit {
|
||||||
|
keys, complete, err = t.getAuditKeys(ctx, selectors)
|
||||||
|
} else {
|
||||||
|
keys, complete, err = t.getLogsKeys(ctx, selectors)
|
||||||
|
}
|
||||||
case telemetrytypes.SignalMetrics:
|
case telemetrytypes.SignalMetrics:
|
||||||
if fieldKeySelector.Source == telemetrytypes.SourceMeter {
|
if fieldKeySelector.Source == telemetrytypes.SourceMeter {
|
||||||
keys, complete, err = t.getMeterSourceMetricKeys(ctx, selectors)
|
keys, complete, err = t.getMeterSourceMetricKeys(ctx, selectors)
|
||||||
@@ -938,6 +1180,7 @@ func (t *telemetryMetaStore) GetKeys(ctx context.Context, fieldKeySelector *tele
|
|||||||
func (t *telemetryMetaStore) GetKeysMulti(ctx context.Context, fieldKeySelectors []*telemetrytypes.FieldKeySelector) (map[string][]*telemetrytypes.TelemetryFieldKey, bool, error) {
|
func (t *telemetryMetaStore) GetKeysMulti(ctx context.Context, fieldKeySelectors []*telemetrytypes.FieldKeySelector) (map[string][]*telemetrytypes.TelemetryFieldKey, bool, error) {
|
||||||
|
|
||||||
logsSelectors := []*telemetrytypes.FieldKeySelector{}
|
logsSelectors := []*telemetrytypes.FieldKeySelector{}
|
||||||
|
auditSelectors := []*telemetrytypes.FieldKeySelector{}
|
||||||
tracesSelectors := []*telemetrytypes.FieldKeySelector{}
|
tracesSelectors := []*telemetrytypes.FieldKeySelector{}
|
||||||
metricsSelectors := []*telemetrytypes.FieldKeySelector{}
|
metricsSelectors := []*telemetrytypes.FieldKeySelector{}
|
||||||
meterSourceMetricsSelectors := []*telemetrytypes.FieldKeySelector{}
|
meterSourceMetricsSelectors := []*telemetrytypes.FieldKeySelector{}
|
||||||
@@ -945,7 +1188,11 @@ func (t *telemetryMetaStore) GetKeysMulti(ctx context.Context, fieldKeySelectors
|
|||||||
for _, fieldKeySelector := range fieldKeySelectors {
|
for _, fieldKeySelector := range fieldKeySelectors {
|
||||||
switch fieldKeySelector.Signal {
|
switch fieldKeySelector.Signal {
|
||||||
case telemetrytypes.SignalLogs:
|
case telemetrytypes.SignalLogs:
|
||||||
logsSelectors = append(logsSelectors, fieldKeySelector)
|
if fieldKeySelector.Source == telemetrytypes.SourceAudit {
|
||||||
|
auditSelectors = append(auditSelectors, fieldKeySelector)
|
||||||
|
} else {
|
||||||
|
logsSelectors = append(logsSelectors, fieldKeySelector)
|
||||||
|
}
|
||||||
case telemetrytypes.SignalTraces:
|
case telemetrytypes.SignalTraces:
|
||||||
tracesSelectors = append(tracesSelectors, fieldKeySelector)
|
tracesSelectors = append(tracesSelectors, fieldKeySelector)
|
||||||
case telemetrytypes.SignalMetrics:
|
case telemetrytypes.SignalMetrics:
|
||||||
@@ -965,6 +1212,10 @@ func (t *telemetryMetaStore) GetKeysMulti(ctx context.Context, fieldKeySelectors
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
|
auditKeys, auditComplete, err := t.getAuditKeys(ctx, auditSelectors)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
tracesKeys, tracesComplete, err := t.getTracesKeys(ctx, tracesSelectors)
|
tracesKeys, tracesComplete, err := t.getTracesKeys(ctx, tracesSelectors)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
@@ -979,12 +1230,15 @@ func (t *telemetryMetaStore) GetKeysMulti(ctx context.Context, fieldKeySelectors
|
|||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
// Complete only if all queries are complete
|
// Complete only if all queries are complete
|
||||||
complete := logsComplete && tracesComplete && metricsComplete
|
complete := logsComplete && auditComplete && tracesComplete && metricsComplete
|
||||||
|
|
||||||
mapOfKeys := make(map[string][]*telemetrytypes.TelemetryFieldKey)
|
mapOfKeys := make(map[string][]*telemetrytypes.TelemetryFieldKey)
|
||||||
for _, key := range logsKeys {
|
for _, key := range logsKeys {
|
||||||
mapOfKeys[key.Name] = append(mapOfKeys[key.Name], key)
|
mapOfKeys[key.Name] = append(mapOfKeys[key.Name], key)
|
||||||
}
|
}
|
||||||
|
for _, key := range auditKeys {
|
||||||
|
mapOfKeys[key.Name] = append(mapOfKeys[key.Name], key)
|
||||||
|
}
|
||||||
for _, key := range tracesKeys {
|
for _, key := range tracesKeys {
|
||||||
mapOfKeys[key.Name] = append(mapOfKeys[key.Name], key)
|
mapOfKeys[key.Name] = append(mapOfKeys[key.Name], key)
|
||||||
}
|
}
|
||||||
@@ -1338,6 +1592,97 @@ func (t *telemetryMetaStore) getLogFieldValues(ctx context.Context, fieldValueSe
|
|||||||
return values, complete, nil
|
return values, complete, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *telemetryMetaStore) getAuditFieldValues(ctx context.Context, fieldValueSelector *telemetrytypes.FieldValueSelector) (*telemetrytypes.TelemetryFieldValues, bool, error) {
|
||||||
|
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||||
|
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalLogs.StringValue(),
|
||||||
|
instrumentationtypes.CodeNamespace: "metadata",
|
||||||
|
instrumentationtypes.CodeFunctionName: "getAuditFieldValues",
|
||||||
|
})
|
||||||
|
|
||||||
|
limit := fieldValueSelector.Limit
|
||||||
|
if limit == 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
sb := sqlbuilder.Select("DISTINCT string_value, number_value").From(t.auditDBName + "." + t.auditFieldsTblName)
|
||||||
|
|
||||||
|
if fieldValueSelector.Name != "" {
|
||||||
|
sb.Where(sb.E("tag_key", fieldValueSelector.Name))
|
||||||
|
}
|
||||||
|
|
||||||
|
if fieldValueSelector.FieldContext != telemetrytypes.FieldContextUnspecified {
|
||||||
|
sb.Where(sb.E("tag_type", fieldValueSelector.FieldContext.TagType()))
|
||||||
|
}
|
||||||
|
|
||||||
|
if fieldValueSelector.FieldDataType != telemetrytypes.FieldDataTypeUnspecified {
|
||||||
|
sb.Where(sb.E("tag_data_type", fieldValueSelector.FieldDataType.TagDataType()))
|
||||||
|
}
|
||||||
|
|
||||||
|
if fieldValueSelector.Value != "" {
|
||||||
|
switch fieldValueSelector.FieldDataType {
|
||||||
|
case telemetrytypes.FieldDataTypeString:
|
||||||
|
sb.Where(sb.ILike("string_value", "%"+escapeForLike(fieldValueSelector.Value)+"%"))
|
||||||
|
case telemetrytypes.FieldDataTypeNumber:
|
||||||
|
sb.Where(sb.IsNotNull("number_value"))
|
||||||
|
sb.Where(sb.ILike("toString(number_value)", "%"+escapeForLike(fieldValueSelector.Value)+"%"))
|
||||||
|
case telemetrytypes.FieldDataTypeUnspecified:
|
||||||
|
sb.Where(sb.Or(
|
||||||
|
sb.ILike("string_value", "%"+escapeForLike(fieldValueSelector.Value)+"%"),
|
||||||
|
sb.ILike("toString(number_value)", "%"+escapeForLike(fieldValueSelector.Value)+"%"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch one extra row to detect whether the result set is complete
|
||||||
|
sb.Limit(limit + 1)
|
||||||
|
|
||||||
|
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||||
|
|
||||||
|
rows, err := t.telemetrystore.ClickhouseDB().Query(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, ErrFailedToGetAuditKeys.Error())
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
values := &telemetrytypes.TelemetryFieldValues{}
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
rowCount := 0
|
||||||
|
totalCount := 0
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
rowCount++
|
||||||
|
|
||||||
|
var stringValue string
|
||||||
|
var numberValue float64
|
||||||
|
err = rows.Scan(&stringValue, &numberValue)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, ErrFailedToGetAuditKeys.Error())
|
||||||
|
}
|
||||||
|
if stringValue != "" && !seen[stringValue] {
|
||||||
|
if totalCount >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
values.StringValues = append(values.StringValues, stringValue)
|
||||||
|
seen[stringValue] = true
|
||||||
|
totalCount++
|
||||||
|
}
|
||||||
|
if numberValue != 0 {
|
||||||
|
if totalCount >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !seen[fmt.Sprintf("%f", numberValue)] {
|
||||||
|
values.NumberValues = append(values.NumberValues, numberValue)
|
||||||
|
seen[fmt.Sprintf("%f", numberValue)] = true
|
||||||
|
totalCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
complete := rowCount <= limit
|
||||||
|
|
||||||
|
return values, complete, nil
|
||||||
|
}
|
||||||
|
|
||||||
// getMetricFieldValues returns field values and whether the result is complete.
|
// getMetricFieldValues returns field values and whether the result is complete.
|
||||||
func (t *telemetryMetaStore) getMetricFieldValues(ctx context.Context, fieldValueSelector *telemetrytypes.FieldValueSelector) (*telemetrytypes.TelemetryFieldValues, bool, error) {
|
func (t *telemetryMetaStore) getMetricFieldValues(ctx context.Context, fieldValueSelector *telemetrytypes.FieldValueSelector) (*telemetrytypes.TelemetryFieldValues, bool, error) {
|
||||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||||
@@ -1628,7 +1973,11 @@ func (t *telemetryMetaStore) GetAllValues(ctx context.Context, fieldValueSelecto
|
|||||||
case telemetrytypes.SignalTraces:
|
case telemetrytypes.SignalTraces:
|
||||||
values, complete, err = t.getSpanFieldValues(ctx, fieldValueSelector)
|
values, complete, err = t.getSpanFieldValues(ctx, fieldValueSelector)
|
||||||
case telemetrytypes.SignalLogs:
|
case telemetrytypes.SignalLogs:
|
||||||
values, complete, err = t.getLogFieldValues(ctx, fieldValueSelector)
|
if fieldValueSelector.Source == telemetrytypes.SourceAudit {
|
||||||
|
values, complete, err = t.getAuditFieldValues(ctx, fieldValueSelector)
|
||||||
|
} else {
|
||||||
|
values, complete, err = t.getLogFieldValues(ctx, fieldValueSelector)
|
||||||
|
}
|
||||||
case telemetrytypes.SignalMetrics:
|
case telemetrytypes.SignalMetrics:
|
||||||
if fieldValueSelector.Source == telemetrytypes.SourceMeter {
|
if fieldValueSelector.Source == telemetrytypes.SourceMeter {
|
||||||
values, complete, err = t.getMeterSourceMetricFieldValues(ctx, fieldValueSelector)
|
values, complete, err = t.getMeterSourceMetricFieldValues(ctx, fieldValueSelector)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||||
|
"github.com/SigNoz/signoz/pkg/telemetryaudit"
|
||||||
"github.com/SigNoz/signoz/pkg/telemetrylogs"
|
"github.com/SigNoz/signoz/pkg/telemetrylogs"
|
||||||
"github.com/SigNoz/signoz/pkg/telemetrymeter"
|
"github.com/SigNoz/signoz/pkg/telemetrymeter"
|
||||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||||
@@ -37,6 +38,11 @@ func TestGetFirstSeenFromMetricMetadata(t *testing.T) {
|
|||||||
telemetrylogs.TagAttributesV2TableName,
|
telemetrylogs.TagAttributesV2TableName,
|
||||||
telemetrylogs.LogAttributeKeysTblName,
|
telemetrylogs.LogAttributeKeysTblName,
|
||||||
telemetrylogs.LogResourceKeysTblName,
|
telemetrylogs.LogResourceKeysTblName,
|
||||||
|
telemetryaudit.DBName,
|
||||||
|
telemetryaudit.AuditLogsTableName,
|
||||||
|
telemetryaudit.TagAttributesTableName,
|
||||||
|
telemetryaudit.LogAttributeKeysTblName,
|
||||||
|
telemetryaudit.LogResourceKeysTblName,
|
||||||
DBName,
|
DBName,
|
||||||
AttributesMetadataLocalTableName,
|
AttributesMetadataLocalTableName,
|
||||||
ColumnEvolutionMetadataTableName,
|
ColumnEvolutionMetadataTableName,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/SigNoz/signoz/pkg/errors"
|
"github.com/SigNoz/signoz/pkg/errors"
|
||||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||||
|
"github.com/SigNoz/signoz/pkg/telemetryaudit"
|
||||||
"github.com/SigNoz/signoz/pkg/telemetrylogs"
|
"github.com/SigNoz/signoz/pkg/telemetrylogs"
|
||||||
"github.com/SigNoz/signoz/pkg/telemetrymeter"
|
"github.com/SigNoz/signoz/pkg/telemetrymeter"
|
||||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||||
@@ -36,6 +37,11 @@ func newTestTelemetryMetaStoreTestHelper(store telemetrystore.TelemetryStore) te
|
|||||||
telemetrylogs.TagAttributesV2TableName,
|
telemetrylogs.TagAttributesV2TableName,
|
||||||
telemetrylogs.LogAttributeKeysTblName,
|
telemetrylogs.LogAttributeKeysTblName,
|
||||||
telemetrylogs.LogResourceKeysTblName,
|
telemetrylogs.LogResourceKeysTblName,
|
||||||
|
telemetryaudit.DBName,
|
||||||
|
telemetryaudit.AuditLogsTableName,
|
||||||
|
telemetryaudit.TagAttributesTableName,
|
||||||
|
telemetryaudit.LogAttributeKeysTblName,
|
||||||
|
telemetryaudit.LogResourceKeysTblName,
|
||||||
DBName,
|
DBName,
|
||||||
AttributesMetadataLocalTableName,
|
AttributesMetadataLocalTableName,
|
||||||
ColumnEvolutionMetadataTableName,
|
ColumnEvolutionMetadataTableName,
|
||||||
|
|||||||
@@ -9,6 +9,6 @@ const (
|
|||||||
ColumnEvolutionMetadataTableName = "distributed_column_evolution_metadata"
|
ColumnEvolutionMetadataTableName = "distributed_column_evolution_metadata"
|
||||||
PathTypesTableName = otelcollectorconst.DistributedPathTypesTable
|
PathTypesTableName = otelcollectorconst.DistributedPathTypesTable
|
||||||
// Column Evolution table stores promoted paths as (signal, column_name, field_context, field_name); see signoz-otel-collector metadata_migrations.
|
// Column Evolution table stores promoted paths as (signal, column_name, field_context, field_name); see signoz-otel-collector metadata_migrations.
|
||||||
PromotedPathsTableName = "distributed_column_evolution_metadata"
|
PromotedPathsTableName = "distributed_column_evolution_metadata"
|
||||||
SkipIndexTableName = "system.data_skipping_indices"
|
SkipIndexTableName = "system.data_skipping_indices"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type resourceFilterStatementBuilder[T any] struct {
|
|||||||
conditionBuilder qbtypes.ConditionBuilder
|
conditionBuilder qbtypes.ConditionBuilder
|
||||||
metadataStore telemetrytypes.MetadataStore
|
metadataStore telemetrytypes.MetadataStore
|
||||||
signal telemetrytypes.Signal
|
signal telemetrytypes.Signal
|
||||||
|
source telemetrytypes.Source
|
||||||
|
|
||||||
fullTextColumn *telemetrytypes.TelemetryFieldKey
|
fullTextColumn *telemetrytypes.TelemetryFieldKey
|
||||||
jsonKeyToKey qbtypes.JsonKeyToFieldFunc
|
jsonKeyToKey qbtypes.JsonKeyToFieldFunc
|
||||||
@@ -37,6 +38,7 @@ func New[T any](
|
|||||||
dbName string,
|
dbName string,
|
||||||
tableName string,
|
tableName string,
|
||||||
signal telemetrytypes.Signal,
|
signal telemetrytypes.Signal,
|
||||||
|
source telemetrytypes.Source,
|
||||||
metadataStore telemetrytypes.MetadataStore,
|
metadataStore telemetrytypes.MetadataStore,
|
||||||
fullTextColumn *telemetrytypes.TelemetryFieldKey,
|
fullTextColumn *telemetrytypes.TelemetryFieldKey,
|
||||||
jsonKeyToKey qbtypes.JsonKeyToFieldFunc,
|
jsonKeyToKey qbtypes.JsonKeyToFieldFunc,
|
||||||
@@ -52,6 +54,7 @@ func New[T any](
|
|||||||
conditionBuilder: cb,
|
conditionBuilder: cb,
|
||||||
metadataStore: metadataStore,
|
metadataStore: metadataStore,
|
||||||
signal: signal,
|
signal: signal,
|
||||||
|
source: source,
|
||||||
fullTextColumn: fullTextColumn,
|
fullTextColumn: fullTextColumn,
|
||||||
jsonKeyToKey: jsonKeyToKey,
|
jsonKeyToKey: jsonKeyToKey,
|
||||||
}
|
}
|
||||||
@@ -72,6 +75,7 @@ func (b *resourceFilterStatementBuilder[T]) getKeySelectors(query qbtypes.QueryB
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
keySelectors[idx].Signal = b.signal
|
keySelectors[idx].Signal = b.signal
|
||||||
|
keySelectors[idx].Source = b.source
|
||||||
keySelectors[idx].SelectorMatchType = telemetrytypes.FieldSelectorMatchTypeExact
|
keySelectors[idx].SelectorMatchType = telemetrytypes.FieldSelectorMatchTypeExact
|
||||||
filteredKeySelectors = append(filteredKeySelectors, keySelectors[idx])
|
filteredKeySelectors = append(filteredKeySelectors, keySelectors[idx])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -375,6 +375,7 @@ func TestResourceFilterStatementBuilder_Traces(t *testing.T) {
|
|||||||
"signoz_traces",
|
"signoz_traces",
|
||||||
"distributed_traces_v3_resource",
|
"distributed_traces_v3_resource",
|
||||||
telemetrytypes.SignalTraces,
|
telemetrytypes.SignalTraces,
|
||||||
|
telemetrytypes.SourceUnspecified,
|
||||||
mockMetadataStore,
|
mockMetadataStore,
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
@@ -592,6 +593,7 @@ func TestResourceFilterStatementBuilder_Logs(t *testing.T) {
|
|||||||
"signoz_logs",
|
"signoz_logs",
|
||||||
"distributed_logs_v2_resource",
|
"distributed_logs_v2_resource",
|
||||||
telemetrytypes.SignalLogs,
|
telemetrytypes.SignalLogs,
|
||||||
|
telemetrytypes.SourceUnspecified,
|
||||||
mockMetadataStore,
|
mockMetadataStore,
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
@@ -653,6 +655,7 @@ func TestResourceFilterStatementBuilder_Variables(t *testing.T) {
|
|||||||
"signoz_traces",
|
"signoz_traces",
|
||||||
"distributed_traces_v3_resource",
|
"distributed_traces_v3_resource",
|
||||||
telemetrytypes.SignalTraces,
|
telemetrytypes.SignalTraces,
|
||||||
|
telemetrytypes.SourceUnspecified,
|
||||||
mockMetadataStore,
|
mockMetadataStore,
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ func NewTraceQueryStatementBuilder(
|
|||||||
DBName,
|
DBName,
|
||||||
TracesResourceV3TableName,
|
TracesResourceV3TableName,
|
||||||
telemetrytypes.SignalTraces,
|
telemetrytypes.SignalTraces,
|
||||||
|
telemetrytypes.SourceUnspecified,
|
||||||
metadataStore,
|
metadataStore,
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user