Compare commits

..

7 Commits

Author SHA1 Message Date
Vinícius Lourenço
7d8c8e3d7d fix(row): missing active styles 2026-04-13 19:37:13 -03:00
Vinícius Lourenço
5eae936ab4 fix(tests): use find by text 2026-04-13 19:17:34 -03:00
Vinícius Lourenço
091d61045a fix(styles): minor fixes on styles 2026-04-13 19:15:43 -03:00
Vinícius Lourenço
ed1217e5d0 chore(tests): cleanup tests and components 2026-04-13 18:57:30 -03:00
Vinícius Lourenço
0389b46836 refactor(table): optimize 2026-04-13 18:36:45 -03:00
Vinícius Lourenço
284d6f72d4 refactor(table): extract table to own component 2026-04-13 15:49:33 -03:00
Vinícius Lourenço
66abfa3be4 refactor(tanstack): move table to components & convert to css modules 2026-04-08 15:27:59 -03:00
238 changed files with 5622 additions and 23689 deletions

View File

@@ -8,7 +8,6 @@ import (
"github.com/SigNoz/signoz/cmd"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/authz/openfgaauthz"
@@ -94,9 +93,6 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(_ licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return noopgateway.NewProviderFactory()
},
func(_ licensing.Licensing) factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]] {
return signoz.NewAuditorProviderFactories()
},
func(ps factory.ProviderSettings, q querier.Querier, a analytics.Analytics) querier.Handler {
return querier.NewHandler(ps, q, a)
},

View File

@@ -8,7 +8,6 @@ import (
"github.com/spf13/cobra"
"github.com/SigNoz/signoz/cmd"
"github.com/SigNoz/signoz/ee/auditor/otlphttpauditor"
"github.com/SigNoz/signoz/ee/authn/callbackauthn/oidccallbackauthn"
"github.com/SigNoz/signoz/ee/authn/callbackauthn/samlcallbackauthn"
"github.com/SigNoz/signoz/ee/authz/openfgaauthz"
@@ -25,7 +24,6 @@ import (
enterprisezeus "github.com/SigNoz/signoz/ee/zeus"
"github.com/SigNoz/signoz/ee/zeus/httpzeus"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/errors"
@@ -135,13 +133,6 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(licensing licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return httpgateway.NewProviderFactory(licensing)
},
func(licensing licensing.Licensing) factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]] {
factories := signoz.NewAuditorProviderFactories()
if err := factories.Add(otlphttpauditor.NewFactory(licensing, version.Info)); err != nil {
panic(err)
}
return factories
},
func(ps factory.ProviderSettings, q querier.Querier, a analytics.Analytics) querier.Handler {
communityHandler := querier.NewHandler(ps, q, a)
return eequerier.NewHandler(ps, q, communityHandler)

View File

@@ -364,34 +364,3 @@ serviceaccount:
analytics:
# toggle service account analytics
enabled: true
##################### Auditor #####################
auditor:
# Specifies the auditor provider to use.
# noop: discards all audit events (community default).
# otlphttp: exports audit events via OTLP HTTP (enterprise).
provider: noop
# The async channel capacity for audit events. Events are dropped when full (fail-open).
buffer_size: 1000
# The maximum number of events per export batch.
batch_size: 100
# The maximum time between export flushes.
flush_interval: 1s
otlphttp:
# The target scheme://host:port/path of the OTLP HTTP endpoint.
endpoint: http://localhost:4318/v1/logs
# Whether to use HTTP instead of HTTPS.
insecure: false
# The maximum duration for an export attempt.
timeout: 10s
# Additional HTTP headers sent with every export request.
headers: {}
retry:
# Whether to retry on transient failures.
enabled: true
# The initial wait time before the first retry.
initial_interval: 5s
# The upper bound on backoff interval.
max_interval: 30s
# The total maximum time spent retrying.
max_elapsed_time: 60s

View File

@@ -403,65 +403,27 @@ components:
required:
- regions
type: object
CloudintegrationtypesAWSCloudWatchLogsSubscription:
CloudintegrationtypesAWSCollectionStrategy:
properties:
filterPattern:
type: string
logGroupNamePrefix:
type: string
required:
- logGroupNamePrefix
- filterPattern
type: object
CloudintegrationtypesAWSCloudWatchMetricStreamFilter:
properties:
metricNames:
items:
type: string
type: array
namespace:
type: string
required:
- namespace
aws_logs:
$ref: '#/components/schemas/CloudintegrationtypesAWSLogsStrategy'
aws_metrics:
$ref: '#/components/schemas/CloudintegrationtypesAWSMetricsStrategy'
s3_buckets:
additionalProperties:
items:
type: string
type: array
type: object
type: object
CloudintegrationtypesAWSConnectionArtifact:
properties:
connectionUrl:
connectionURL:
type: string
required:
- connectionUrl
- connectionURL
type: object
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:
CloudintegrationtypesAWSConnectionArtifactRequest:
properties:
deploymentRegion:
type: string
@@ -473,6 +435,46 @@ components:
- deploymentRegion
- regions
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:
properties:
logs:
@@ -484,7 +486,7 @@ components:
properties:
enabled:
type: boolean
s3Buckets:
s3_buckets:
additionalProperties:
items:
type: string
@@ -496,19 +498,6 @@ components:
enabled:
type: boolean
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:
properties:
agentReport:
@@ -572,26 +561,6 @@ components:
nullable: true
type: array
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:
properties:
name:
@@ -612,6 +581,13 @@ components:
unit:
type: string
type: object
CloudintegrationtypesCollectionStrategy:
properties:
aws:
$ref: '#/components/schemas/CloudintegrationtypesAWSCollectionStrategy'
required:
- aws
type: object
CloudintegrationtypesConnectionArtifact:
properties:
aws:
@@ -619,21 +595,12 @@ components:
required:
- aws
type: object
CloudintegrationtypesCredentials:
CloudintegrationtypesConnectionArtifactRequest:
properties:
ingestionKey:
type: string
ingestionUrl:
type: string
sigNozApiKey:
type: string
sigNozApiUrl:
type: string
aws:
$ref: '#/components/schemas/CloudintegrationtypesAWSConnectionArtifactRequest'
required:
- sigNozApiUrl
- sigNozApiKey
- ingestionUrl
- ingestionKey
- aws
type: object
CloudintegrationtypesDashboard:
properties:
@@ -659,7 +626,7 @@ components:
nullable: true
type: array
type: object
CloudintegrationtypesGettableAccountWithConnectionArtifact:
CloudintegrationtypesGettableAccountWithArtifact:
properties:
connectionArtifact:
$ref: '#/components/schemas/CloudintegrationtypesConnectionArtifact'
@@ -678,7 +645,7 @@ components:
required:
- accounts
type: object
CloudintegrationtypesGettableAgentCheckIn:
CloudintegrationtypesGettableAgentCheckInResponse:
properties:
account_id:
type: string
@@ -727,72 +694,12 @@ components:
type: string
type: array
telemetry:
$ref: '#/components/schemas/CloudintegrationtypesOldAWSCollectionStrategy'
$ref: '#/components/schemas/CloudintegrationtypesAWSCollectionStrategy'
required:
- enabled_regions
- telemetry
type: object
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:
CloudintegrationtypesPostableAgentCheckInRequest:
properties:
account_id:
type: string
@@ -820,8 +727,6 @@ components:
properties:
assets:
$ref: '#/components/schemas/CloudintegrationtypesAssets'
cloudIntegrationService:
$ref: '#/components/schemas/CloudintegrationtypesCloudIntegrationService'
dataCollected:
$ref: '#/components/schemas/CloudintegrationtypesDataCollected'
icon:
@@ -830,10 +735,12 @@ components:
type: string
overview:
type: string
supportedSignals:
serviceConfig:
$ref: '#/components/schemas/CloudintegrationtypesServiceConfig'
supported_signals:
$ref: '#/components/schemas/CloudintegrationtypesSupportedSignals'
telemetryCollectionStrategy:
$ref: '#/components/schemas/CloudintegrationtypesTelemetryCollectionStrategy'
$ref: '#/components/schemas/CloudintegrationtypesCollectionStrategy'
title:
type: string
required:
@@ -842,10 +749,9 @@ components:
- icon
- overview
- assets
- supportedSignals
- supported_signals
- dataCollected
- telemetryCollectionStrategy
- cloudIntegrationService
type: object
CloudintegrationtypesServiceConfig:
properties:
@@ -854,22 +760,6 @@ components:
required:
- aws
type: object
CloudintegrationtypesServiceID:
enum:
- alb
- api-gateway
- dynamodb
- ec2
- ecs
- eks
- elasticache
- lambda
- msk
- rds
- s3sync
- sns
- sqs
type: string
CloudintegrationtypesServiceMetadata:
properties:
enabled:
@@ -893,13 +783,6 @@ components:
metrics:
type: boolean
type: object
CloudintegrationtypesTelemetryCollectionStrategy:
properties:
aws:
$ref: '#/components/schemas/CloudintegrationtypesAWSTelemetryCollectionStrategy'
required:
- aws
type: object
CloudintegrationtypesUpdatableAccount:
properties:
config:
@@ -2787,147 +2670,6 @@ components:
type: string
type: array
type: object
TracedetailtypesEvent:
properties:
attributeMap:
additionalProperties: {}
type: object
isError:
type: boolean
name:
type: string
timeUnixNano:
minimum: 0
type: integer
type: object
TracedetailtypesWaterfallRequest:
properties:
limit:
minimum: 0
type: integer
selectedSpanId:
type: string
uncollapsedSpans:
items:
type: string
nullable: true
type: array
type: object
TracedetailtypesWaterfallResponse:
properties:
endTimestampMillis:
minimum: 0
type: integer
hasMissingSpans:
type: boolean
hasMore:
type: boolean
rootServiceEntryPoint:
type: string
rootServiceName:
type: string
serviceNameToTotalDurationMap:
additionalProperties:
minimum: 0
type: integer
nullable: true
type: object
spans:
items:
$ref: '#/components/schemas/TracedetailtypesWaterfallSpan'
nullable: true
type: array
startTimestampMillis:
minimum: 0
type: integer
totalErrorSpansCount:
minimum: 0
type: integer
totalSpansCount:
minimum: 0
type: integer
uncollapsedSpans:
items:
type: string
nullable: true
type: array
type: object
TracedetailtypesWaterfallSpan:
properties:
attributes:
additionalProperties: {}
nullable: true
type: object
db_name:
type: string
db_operation:
type: string
duration_nano:
minimum: 0
type: integer
events:
items:
$ref: '#/components/schemas/TracedetailtypesEvent'
nullable: true
type: array
external_http_method:
type: string
external_http_url:
type: string
flags:
minimum: 0
type: integer
has_children:
type: boolean
has_error:
type: boolean
http_host:
type: string
http_method:
type: string
http_url:
type: string
is_remote:
type: string
kind:
format: int32
type: integer
kind_string:
type: string
level:
minimum: 0
type: integer
name:
type: string
parent_span_id:
type: string
resource:
additionalProperties:
type: string
nullable: true
type: object
response_status_code:
type: string
span_id:
type: string
status_code:
format: int32
type: integer
status_code_string:
type: string
status_message:
type: string
sub_tree_node_count:
minimum: 0
type: integer
timestamp:
minimum: 0
type: integer
trace_id:
type: string
trace_state:
type: string
type: object
TypesChangePasswordRequest:
properties:
newPassword:
@@ -3339,7 +3081,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/CloudintegrationtypesPostableAgentCheckIn'
$ref: '#/components/schemas/CloudintegrationtypesPostableAgentCheckInRequest'
responses:
"200":
content:
@@ -3347,7 +3089,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/CloudintegrationtypesGettableAgentCheckIn'
$ref: '#/components/schemas/CloudintegrationtypesGettableAgentCheckInResponse'
status:
type: string
required:
@@ -3448,22 +3190,22 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/CloudintegrationtypesPostableAccount'
$ref: '#/components/schemas/CloudintegrationtypesConnectionArtifactRequest'
responses:
"201":
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/CloudintegrationtypesGettableAccountWithConnectionArtifact'
$ref: '#/components/schemas/CloudintegrationtypesGettableAccountWithArtifact'
status:
type: string
required:
- status
- data
type: object
description: Created
description: OK
"401":
content:
application/json:
@@ -3652,61 +3394,6 @@ paths:
summary: Update account
tags:
- 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:
post:
deprecated: false
@@ -3722,7 +3409,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/CloudintegrationtypesPostableAgentCheckIn'
$ref: '#/components/schemas/CloudintegrationtypesPostableAgentCheckInRequest'
responses:
"200":
content:
@@ -3730,7 +3417,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/CloudintegrationtypesGettableAgentCheckIn'
$ref: '#/components/schemas/CloudintegrationtypesGettableAgentCheckInResponse'
status:
type: string
required:
@@ -3764,59 +3451,6 @@ paths:
summary: Agent check-in
tags:
- 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:
get:
deprecated: false
@@ -3824,11 +3458,6 @@ paths:
provider
operationId: ListServicesMetadata
parameters:
- in: query
name: cloud_integration_id
required: false
schema:
type: string
- in: path
name: cloud_provider
required: true
@@ -3881,11 +3510,6 @@ paths:
description: This endpoint gets a service for the specified cloud provider
operationId: GetService
parameters:
- in: query
name: cloud_integration_id
required: false
schema:
type: string
- in: path
name: cloud_provider
required: true
@@ -3937,6 +3561,55 @@ paths:
summary: Get service
tags:
- 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:
get:
deprecated: false
@@ -9655,76 +9328,6 @@ paths:
summary: Put profile in Zeus for a deployment.
tags:
- zeus
/api/v3/traces/{traceID}/waterfall:
post:
deprecated: false
description: Returns the waterfall view of spans for a given trace ID with tree
structure, metadata, and windowed pagination
operationId: GetWaterfall
parameters:
- in: path
name: traceID
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/TracedetailtypesWaterfallRequest'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TracedetailtypesWaterfallResponse'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get waterfall view for a trace
tags:
- tracedetail
/api/v5/query_range:
post:
deprecated: false

View File

@@ -227,7 +227,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
s.config.APIServer.Timeout.Default,
s.config.APIServer.Timeout.Max,
).Wrap)
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, s.signoz.Auditor).Wrap)
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, nil).Wrap)
r.Use(middleware.NewComment().Wrap)
apiHandler.RegisterRoutes(r, am)

View File

@@ -67,7 +67,7 @@
"@signozhq/table": "0.3.7",
"@signozhq/toggle-group": "0.0.1",
"@signozhq/tooltip": "0.0.2",
"@signozhq/ui": "0.0.6",
"@signozhq/ui": "0.0.5",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.22",
"@uiw/codemirror-theme-copilot": "4.23.11",
@@ -139,12 +139,10 @@
"react-helmet-async": "1.3.0",
"react-hook-form": "7.71.2",
"react-i18next": "^11.16.1",
"react-json-tree": "^0.20.0",
"react-lottie": "1.2.10",
"react-markdown": "8.0.7",
"react-query": "3.39.3",
"react-redux": "^7.2.2",
"react-rnd": "^10.5.3",
"react-router-dom": "^5.2.0",
"react-router-dom-v5-compat": "6.27.0",
"react-syntax-highlighter": "15.5.0",

View File

@@ -24,24 +24,20 @@ import type {
AgentCheckInDeprecated200,
AgentCheckInDeprecatedPathParameters,
AgentCheckInPathParameters,
CloudintegrationtypesPostableAccountDTO,
CloudintegrationtypesPostableAgentCheckInDTO,
CloudintegrationtypesConnectionArtifactRequestDTO,
CloudintegrationtypesPostableAgentCheckInRequestDTO,
CloudintegrationtypesUpdatableAccountDTO,
CloudintegrationtypesUpdatableServiceDTO,
CreateAccount201,
CreateAccount200,
CreateAccountPathParameters,
DisconnectAccountPathParameters,
GetAccount200,
GetAccountPathParameters,
GetConnectionCredentials200,
GetConnectionCredentialsPathParameters,
GetService200,
GetServiceParams,
GetServicePathParameters,
ListAccounts200,
ListAccountsPathParameters,
ListServicesMetadata200,
ListServicesMetadataParams,
ListServicesMetadataPathParameters,
RenderErrorResponseDTO,
UpdateAccountPathParameters,
@@ -55,14 +51,14 @@ import type {
*/
export const agentCheckInDeprecated = (
{ cloudProvider }: AgentCheckInDeprecatedPathParameters,
cloudintegrationtypesPostableAgentCheckInDTO: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>,
cloudintegrationtypesPostableAgentCheckInRequestDTO: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<AgentCheckInDeprecated200>({
url: `/api/v1/cloud-integrations/${cloudProvider}/agent-check-in`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: cloudintegrationtypesPostableAgentCheckInDTO,
data: cloudintegrationtypesPostableAgentCheckInRequestDTO,
signal,
});
};
@@ -76,7 +72,7 @@ export const getAgentCheckInDeprecatedMutationOptions = <
TError,
{
pathParams: AgentCheckInDeprecatedPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
},
TContext
>;
@@ -85,7 +81,7 @@ export const getAgentCheckInDeprecatedMutationOptions = <
TError,
{
pathParams: AgentCheckInDeprecatedPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
},
TContext
> => {
@@ -102,7 +98,7 @@ export const getAgentCheckInDeprecatedMutationOptions = <
Awaited<ReturnType<typeof agentCheckInDeprecated>>,
{
pathParams: AgentCheckInDeprecatedPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
@@ -116,7 +112,7 @@ export const getAgentCheckInDeprecatedMutationOptions = <
export type AgentCheckInDeprecatedMutationResult = NonNullable<
Awaited<ReturnType<typeof agentCheckInDeprecated>>
>;
export type AgentCheckInDeprecatedMutationBody = BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
export type AgentCheckInDeprecatedMutationBody = BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
export type AgentCheckInDeprecatedMutationError = ErrorType<RenderErrorResponseDTO>;
/**
@@ -132,7 +128,7 @@ export const useAgentCheckInDeprecated = <
TError,
{
pathParams: AgentCheckInDeprecatedPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
},
TContext
>;
@@ -141,7 +137,7 @@ export const useAgentCheckInDeprecated = <
TError,
{
pathParams: AgentCheckInDeprecatedPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
},
TContext
> => {
@@ -259,14 +255,14 @@ export const invalidateListAccounts = async (
*/
export const createAccount = (
{ cloudProvider }: CreateAccountPathParameters,
cloudintegrationtypesPostableAccountDTO: BodyType<CloudintegrationtypesPostableAccountDTO>,
cloudintegrationtypesConnectionArtifactRequestDTO: BodyType<CloudintegrationtypesConnectionArtifactRequestDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateAccount201>({
return GeneratedAPIInstance<CreateAccount200>({
url: `/api/v1/cloud_integrations/${cloudProvider}/accounts`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: cloudintegrationtypesPostableAccountDTO,
data: cloudintegrationtypesConnectionArtifactRequestDTO,
signal,
});
};
@@ -280,7 +276,7 @@ export const getCreateAccountMutationOptions = <
TError,
{
pathParams: CreateAccountPathParameters;
data: BodyType<CloudintegrationtypesPostableAccountDTO>;
data: BodyType<CloudintegrationtypesConnectionArtifactRequestDTO>;
},
TContext
>;
@@ -289,7 +285,7 @@ export const getCreateAccountMutationOptions = <
TError,
{
pathParams: CreateAccountPathParameters;
data: BodyType<CloudintegrationtypesPostableAccountDTO>;
data: BodyType<CloudintegrationtypesConnectionArtifactRequestDTO>;
},
TContext
> => {
@@ -306,7 +302,7 @@ export const getCreateAccountMutationOptions = <
Awaited<ReturnType<typeof createAccount>>,
{
pathParams: CreateAccountPathParameters;
data: BodyType<CloudintegrationtypesPostableAccountDTO>;
data: BodyType<CloudintegrationtypesConnectionArtifactRequestDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
@@ -320,7 +316,7 @@ export const getCreateAccountMutationOptions = <
export type CreateAccountMutationResult = NonNullable<
Awaited<ReturnType<typeof createAccount>>
>;
export type CreateAccountMutationBody = BodyType<CloudintegrationtypesPostableAccountDTO>;
export type CreateAccountMutationBody = BodyType<CloudintegrationtypesConnectionArtifactRequestDTO>;
export type CreateAccountMutationError = ErrorType<RenderErrorResponseDTO>;
/**
@@ -335,7 +331,7 @@ export const useCreateAccount = <
TError,
{
pathParams: CreateAccountPathParameters;
data: BodyType<CloudintegrationtypesPostableAccountDTO>;
data: BodyType<CloudintegrationtypesConnectionArtifactRequestDTO>;
},
TContext
>;
@@ -344,7 +340,7 @@ export const useCreateAccount = <
TError,
{
pathParams: CreateAccountPathParameters;
data: BodyType<CloudintegrationtypesPostableAccountDTO>;
data: BodyType<CloudintegrationtypesConnectionArtifactRequestDTO>;
},
TContext
> => {
@@ -632,16 +628,330 @@ export const useUpdateAccount = <
return useMutation(mutationOptions);
};
/**
* This endpoint is called by the deployed agent to check in
* @summary Agent check-in
*/
export const agentCheckIn = (
{ cloudProvider }: AgentCheckInPathParameters,
cloudintegrationtypesPostableAgentCheckInRequestDTO: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<AgentCheckIn200>({
url: `/api/v1/cloud_integrations/${cloudProvider}/accounts/check_in`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: cloudintegrationtypesPostableAgentCheckInRequestDTO,
signal,
});
};
export const getAgentCheckInMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof agentCheckIn>>,
TError,
{
pathParams: AgentCheckInPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof agentCheckIn>>,
TError,
{
pathParams: AgentCheckInPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
},
TContext
> => {
const mutationKey = ['agentCheckIn'];
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 agentCheckIn>>,
{
pathParams: AgentCheckInPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return agentCheckIn(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type AgentCheckInMutationResult = NonNullable<
Awaited<ReturnType<typeof agentCheckIn>>
>;
export type AgentCheckInMutationBody = BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
export type AgentCheckInMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Agent check-in
*/
export const useAgentCheckIn = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof agentCheckIn>>,
TError,
{
pathParams: AgentCheckInPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof agentCheckIn>>,
TError,
{
pathParams: AgentCheckInPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
},
TContext
> => {
const mutationOptions = getAgentCheckInMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* This endpoint lists the services metadata for the specified cloud provider
* @summary List services metadata
*/
export const listServicesMetadata = (
{ cloudProvider }: ListServicesMetadataPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListServicesMetadata200>({
url: `/api/v1/cloud_integrations/${cloudProvider}/services`,
method: 'GET',
signal,
});
};
export const getListServicesMetadataQueryKey = ({
cloudProvider,
}: ListServicesMetadataPathParameters) => {
return [`/api/v1/cloud_integrations/${cloudProvider}/services`] as const;
};
export const getListServicesMetadataQueryOptions = <
TData = Awaited<ReturnType<typeof listServicesMetadata>>,
TError = ErrorType<RenderErrorResponseDTO>
>(
{ cloudProvider }: ListServicesMetadataPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listServicesMetadata>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getListServicesMetadataQueryKey({ cloudProvider });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listServicesMetadata>>
> = ({ signal }) => listServicesMetadata({ cloudProvider }, signal);
return {
queryKey,
queryFn,
enabled: !!cloudProvider,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof listServicesMetadata>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListServicesMetadataQueryResult = NonNullable<
Awaited<ReturnType<typeof listServicesMetadata>>
>;
export type ListServicesMetadataQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List services metadata
*/
export function useListServicesMetadata<
TData = Awaited<ReturnType<typeof listServicesMetadata>>,
TError = ErrorType<RenderErrorResponseDTO>
>(
{ cloudProvider }: ListServicesMetadataPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listServicesMetadata>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListServicesMetadataQueryOptions(
{ cloudProvider },
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary List services metadata
*/
export const invalidateListServicesMetadata = async (
queryClient: QueryClient,
{ cloudProvider }: ListServicesMetadataPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListServicesMetadataQueryKey({ cloudProvider }) },
options,
);
return queryClient;
};
/**
* This endpoint gets a service for the specified cloud provider
* @summary Get service
*/
export const getService = (
{ cloudProvider, serviceId }: GetServicePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetService200>({
url: `/api/v1/cloud_integrations/${cloudProvider}/services/${serviceId}`,
method: 'GET',
signal,
});
};
export const getGetServiceQueryKey = ({
cloudProvider,
serviceId,
}: GetServicePathParameters) => {
return [
`/api/v1/cloud_integrations/${cloudProvider}/services/${serviceId}`,
] as const;
};
export const getGetServiceQueryOptions = <
TData = Awaited<ReturnType<typeof getService>>,
TError = ErrorType<RenderErrorResponseDTO>
>(
{ cloudProvider, serviceId }: GetServicePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getService>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetServiceQueryKey({ cloudProvider, serviceId });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getService>>> = ({
signal,
}) => getService({ cloudProvider, serviceId }, signal);
return {
queryKey,
queryFn,
enabled: !!(cloudProvider && serviceId),
...queryOptions,
} as UseQueryOptions<Awaited<ReturnType<typeof getService>>, TError, TData> & {
queryKey: QueryKey;
};
};
export type GetServiceQueryResult = NonNullable<
Awaited<ReturnType<typeof getService>>
>;
export type GetServiceQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get service
*/
export function useGetService<
TData = Awaited<ReturnType<typeof getService>>,
TError = ErrorType<RenderErrorResponseDTO>
>(
{ cloudProvider, serviceId }: GetServicePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getService>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetServiceQueryOptions(
{ cloudProvider, serviceId },
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get service
*/
export const invalidateGetService = async (
queryClient: QueryClient,
{ cloudProvider, serviceId }: GetServicePathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetServiceQueryKey({ cloudProvider, serviceId }) },
options,
);
return queryClient;
};
/**
* This endpoint updates a service for the specified cloud provider
* @summary Update service
*/
export const updateService = (
{ cloudProvider, id, serviceId }: UpdateServicePathParameters,
{ cloudProvider, serviceId }: UpdateServicePathParameters,
cloudintegrationtypesUpdatableServiceDTO: BodyType<CloudintegrationtypesUpdatableServiceDTO>,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/cloud_integrations/${cloudProvider}/accounts/${id}/services/${serviceId}`,
url: `/api/v1/cloud_integrations/${cloudProvider}/services/${serviceId}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: cloudintegrationtypesUpdatableServiceDTO,
@@ -729,443 +1039,3 @@ export const useUpdateService = <
return useMutation(mutationOptions);
};
/**
* This endpoint is called by the deployed agent to check in
* @summary Agent check-in
*/
export const agentCheckIn = (
{ cloudProvider }: AgentCheckInPathParameters,
cloudintegrationtypesPostableAgentCheckInDTO: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<AgentCheckIn200>({
url: `/api/v1/cloud_integrations/${cloudProvider}/accounts/check_in`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: cloudintegrationtypesPostableAgentCheckInDTO,
signal,
});
};
export const getAgentCheckInMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof agentCheckIn>>,
TError,
{
pathParams: AgentCheckInPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof agentCheckIn>>,
TError,
{
pathParams: AgentCheckInPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
},
TContext
> => {
const mutationKey = ['agentCheckIn'];
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 agentCheckIn>>,
{
pathParams: AgentCheckInPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return agentCheckIn(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type AgentCheckInMutationResult = NonNullable<
Awaited<ReturnType<typeof agentCheckIn>>
>;
export type AgentCheckInMutationBody = BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
export type AgentCheckInMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Agent check-in
*/
export const useAgentCheckIn = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof agentCheckIn>>,
TError,
{
pathParams: AgentCheckInPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof agentCheckIn>>,
TError,
{
pathParams: AgentCheckInPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
},
TContext
> => {
const mutationOptions = getAgentCheckInMutationOptions(options);
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
* @summary List services metadata
*/
export const listServicesMetadata = (
{ cloudProvider }: ListServicesMetadataPathParameters,
params?: ListServicesMetadataParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListServicesMetadata200>({
url: `/api/v1/cloud_integrations/${cloudProvider}/services`,
method: 'GET',
params,
signal,
});
};
export const getListServicesMetadataQueryKey = (
{ cloudProvider }: ListServicesMetadataPathParameters,
params?: ListServicesMetadataParams,
) => {
return [
`/api/v1/cloud_integrations/${cloudProvider}/services`,
...(params ? [params] : []),
] as const;
};
export const getListServicesMetadataQueryOptions = <
TData = Awaited<ReturnType<typeof listServicesMetadata>>,
TError = ErrorType<RenderErrorResponseDTO>
>(
{ cloudProvider }: ListServicesMetadataPathParameters,
params?: ListServicesMetadataParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listServicesMetadata>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getListServicesMetadataQueryKey({ cloudProvider }, params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listServicesMetadata>>
> = ({ signal }) => listServicesMetadata({ cloudProvider }, params, signal);
return {
queryKey,
queryFn,
enabled: !!cloudProvider,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof listServicesMetadata>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListServicesMetadataQueryResult = NonNullable<
Awaited<ReturnType<typeof listServicesMetadata>>
>;
export type ListServicesMetadataQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List services metadata
*/
export function useListServicesMetadata<
TData = Awaited<ReturnType<typeof listServicesMetadata>>,
TError = ErrorType<RenderErrorResponseDTO>
>(
{ cloudProvider }: ListServicesMetadataPathParameters,
params?: ListServicesMetadataParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listServicesMetadata>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListServicesMetadataQueryOptions(
{ cloudProvider },
params,
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary List services metadata
*/
export const invalidateListServicesMetadata = async (
queryClient: QueryClient,
{ cloudProvider }: ListServicesMetadataPathParameters,
params?: ListServicesMetadataParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListServicesMetadataQueryKey({ cloudProvider }, params) },
options,
);
return queryClient;
};
/**
* This endpoint gets a service for the specified cloud provider
* @summary Get service
*/
export const getService = (
{ cloudProvider, serviceId }: GetServicePathParameters,
params?: GetServiceParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetService200>({
url: `/api/v1/cloud_integrations/${cloudProvider}/services/${serviceId}`,
method: 'GET',
params,
signal,
});
};
export const getGetServiceQueryKey = (
{ cloudProvider, serviceId }: GetServicePathParameters,
params?: GetServiceParams,
) => {
return [
`/api/v1/cloud_integrations/${cloudProvider}/services/${serviceId}`,
...(params ? [params] : []),
] as const;
};
export const getGetServiceQueryOptions = <
TData = Awaited<ReturnType<typeof getService>>,
TError = ErrorType<RenderErrorResponseDTO>
>(
{ cloudProvider, serviceId }: GetServicePathParameters,
params?: GetServiceParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getService>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getGetServiceQueryKey({ cloudProvider, serviceId }, params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getService>>> = ({
signal,
}) => getService({ cloudProvider, serviceId }, params, signal);
return {
queryKey,
queryFn,
enabled: !!(cloudProvider && serviceId),
...queryOptions,
} as UseQueryOptions<Awaited<ReturnType<typeof getService>>, TError, TData> & {
queryKey: QueryKey;
};
};
export type GetServiceQueryResult = NonNullable<
Awaited<ReturnType<typeof getService>>
>;
export type GetServiceQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get service
*/
export function useGetService<
TData = Awaited<ReturnType<typeof getService>>,
TError = ErrorType<RenderErrorResponseDTO>
>(
{ cloudProvider, serviceId }: GetServicePathParameters,
params?: GetServiceParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getService>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetServiceQueryOptions(
{ cloudProvider, serviceId },
params,
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get service
*/
export const invalidateGetService = async (
queryClient: QueryClient,
{ cloudProvider, serviceId }: GetServicePathParameters,
params?: GetServiceParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetServiceQueryKey({ cloudProvider, serviceId }, params) },
options,
);
return queryClient;
};

View File

@@ -512,58 +512,27 @@ export interface CloudintegrationtypesAWSAccountConfigDTO {
regions: string[];
}
export interface CloudintegrationtypesAWSCloudWatchLogsSubscriptionDTO {
/**
* @type string
*/
filterPattern: string;
/**
* @type string
*/
logGroupNamePrefix: string;
}
export type CloudintegrationtypesAWSCollectionStrategyDTOS3Buckets = {
[key: string]: string[];
};
export interface CloudintegrationtypesAWSCloudWatchMetricStreamFilterDTO {
export interface CloudintegrationtypesAWSCollectionStrategyDTO {
aws_logs?: CloudintegrationtypesAWSLogsStrategyDTO;
aws_metrics?: CloudintegrationtypesAWSMetricsStrategyDTO;
/**
* @type array
* @type object
*/
metricNames?: string[];
/**
* @type string
*/
namespace: string;
s3_buckets?: CloudintegrationtypesAWSCollectionStrategyDTOS3Buckets;
}
export interface CloudintegrationtypesAWSConnectionArtifactDTO {
/**
* @type string
*/
connectionUrl: string;
connectionURL: string;
}
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 {
export interface CloudintegrationtypesAWSConnectionArtifactRequestDTO {
/**
* @type string
*/
@@ -574,6 +543,56 @@ export interface CloudintegrationtypesAWSPostableAccountConfigDTO {
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 {
logs?: CloudintegrationtypesAWSServiceLogsConfigDTO;
metrics?: CloudintegrationtypesAWSServiceMetricsConfigDTO;
@@ -591,7 +610,7 @@ export interface CloudintegrationtypesAWSServiceLogsConfigDTO {
/**
* @type object
*/
s3Buckets?: CloudintegrationtypesAWSServiceLogsConfigDTOS3Buckets;
s3_buckets?: CloudintegrationtypesAWSServiceLogsConfigDTOS3Buckets;
}
export interface CloudintegrationtypesAWSServiceMetricsConfigDTO {
@@ -601,19 +620,6 @@ export interface CloudintegrationtypesAWSServiceMetricsConfigDTO {
enabled?: boolean;
}
export type CloudintegrationtypesAWSTelemetryCollectionStrategyDTOS3Buckets = {
[key: string]: string[];
};
export interface CloudintegrationtypesAWSTelemetryCollectionStrategyDTO {
logs?: CloudintegrationtypesAWSLogsCollectionStrategyDTO;
metrics?: CloudintegrationtypesAWSMetricsCollectionStrategyDTO;
/**
* @type object
*/
s3Buckets?: CloudintegrationtypesAWSTelemetryCollectionStrategyDTOS3Buckets;
}
export interface CloudintegrationtypesAccountDTO {
agentReport: CloudintegrationtypesAgentReportDTO;
config: CloudintegrationtypesAccountConfigDTO;
@@ -687,32 +693,6 @@ export interface CloudintegrationtypesAssetsDTO {
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 {
/**
* @type string
@@ -747,27 +727,16 @@ export interface CloudintegrationtypesCollectedMetricDTO {
unit?: string;
}
export interface CloudintegrationtypesCollectionStrategyDTO {
aws: CloudintegrationtypesAWSCollectionStrategyDTO;
}
export interface CloudintegrationtypesConnectionArtifactDTO {
aws: CloudintegrationtypesAWSConnectionArtifactDTO;
}
export interface CloudintegrationtypesCredentialsDTO {
/**
* @type string
*/
ingestionKey: string;
/**
* @type string
*/
ingestionUrl: string;
/**
* @type string
*/
sigNozApiKey: string;
/**
* @type string
*/
sigNozApiUrl: string;
export interface CloudintegrationtypesConnectionArtifactRequestDTO {
aws: CloudintegrationtypesAWSConnectionArtifactRequestDTO;
}
export interface CloudintegrationtypesDashboardDTO {
@@ -799,7 +768,7 @@ export interface CloudintegrationtypesDataCollectedDTO {
metrics?: CloudintegrationtypesCollectedMetricDTO[] | null;
}
export interface CloudintegrationtypesGettableAccountWithConnectionArtifactDTO {
export interface CloudintegrationtypesGettableAccountWithArtifactDTO {
connectionArtifact: CloudintegrationtypesConnectionArtifactDTO;
/**
* @type string
@@ -814,7 +783,7 @@ export interface CloudintegrationtypesGettableAccountsDTO {
accounts: CloudintegrationtypesAccountDTO[];
}
export interface CloudintegrationtypesGettableAgentCheckInDTO {
export interface CloudintegrationtypesGettableAgentCheckInResponseDTO {
/**
* @type string
*/
@@ -862,85 +831,17 @@ export type CloudintegrationtypesIntegrationConfigDTO = {
* @type array
*/
enabled_regions: string[];
telemetry: CloudintegrationtypesOldAWSCollectionStrategyDTO;
telemetry: CloudintegrationtypesAWSCollectionStrategyDTO;
} | 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
*/
export type CloudintegrationtypesPostableAgentCheckInDTOData = {
export type CloudintegrationtypesPostableAgentCheckInRequestDTOData = {
[key: string]: unknown;
} | null;
export interface CloudintegrationtypesPostableAgentCheckInDTO {
export interface CloudintegrationtypesPostableAgentCheckInRequestDTO {
/**
* @type string
*/
@@ -957,7 +858,7 @@ export interface CloudintegrationtypesPostableAgentCheckInDTO {
* @type object
* @nullable true
*/
data: CloudintegrationtypesPostableAgentCheckInDTOData;
data: CloudintegrationtypesPostableAgentCheckInRequestDTOData;
/**
* @type string
*/
@@ -970,7 +871,6 @@ export interface CloudintegrationtypesProviderIntegrationConfigDTO {
export interface CloudintegrationtypesServiceDTO {
assets: CloudintegrationtypesAssetsDTO;
cloudIntegrationService: CloudintegrationtypesCloudIntegrationServiceDTO;
dataCollected: CloudintegrationtypesDataCollectedDTO;
/**
* @type string
@@ -984,8 +884,9 @@ export interface CloudintegrationtypesServiceDTO {
* @type string
*/
overview: string;
supportedSignals: CloudintegrationtypesSupportedSignalsDTO;
telemetryCollectionStrategy: CloudintegrationtypesTelemetryCollectionStrategyDTO;
serviceConfig?: CloudintegrationtypesServiceConfigDTO;
supported_signals: CloudintegrationtypesSupportedSignalsDTO;
telemetryCollectionStrategy: CloudintegrationtypesCollectionStrategyDTO;
/**
* @type string
*/
@@ -996,21 +897,6 @@ export interface CloudintegrationtypesServiceConfigDTO {
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 {
/**
* @type boolean
@@ -1041,10 +927,6 @@ export interface CloudintegrationtypesSupportedSignalsDTO {
metrics?: boolean;
}
export interface CloudintegrationtypesTelemetryCollectionStrategyDTO {
aws: CloudintegrationtypesAWSTelemetryCollectionStrategyDTO;
}
export interface CloudintegrationtypesUpdatableAccountDTO {
config: CloudintegrationtypesAccountConfigDTO;
}
@@ -3196,249 +3078,6 @@ export interface TelemetrytypesTelemetryFieldValuesDTO {
stringValues?: string[];
}
export type TracedetailtypesEventDTOAttributeMap = { [key: string]: unknown };
export interface TracedetailtypesEventDTO {
/**
* @type object
*/
attributeMap?: TracedetailtypesEventDTOAttributeMap;
/**
* @type boolean
*/
isError?: boolean;
/**
* @type string
*/
name?: string;
/**
* @type integer
* @minimum 0
*/
timeUnixNano?: number;
}
export interface TracedetailtypesWaterfallRequestDTO {
/**
* @type integer
* @minimum 0
*/
limit?: number;
/**
* @type string
*/
selectedSpanId?: string;
/**
* @type array
* @nullable true
*/
uncollapsedSpans?: string[] | null;
}
/**
* @nullable
*/
export type TracedetailtypesWaterfallResponseDTOServiceNameToTotalDurationMap = {
[key: string]: number;
} | null;
export interface TracedetailtypesWaterfallResponseDTO {
/**
* @type integer
* @minimum 0
*/
endTimestampMillis?: number;
/**
* @type boolean
*/
hasMissingSpans?: boolean;
/**
* @type boolean
*/
hasMore?: boolean;
/**
* @type string
*/
rootServiceEntryPoint?: string;
/**
* @type string
*/
rootServiceName?: string;
/**
* @type object
* @nullable true
*/
serviceNameToTotalDurationMap?: TracedetailtypesWaterfallResponseDTOServiceNameToTotalDurationMap;
/**
* @type array
* @nullable true
*/
spans?: TracedetailtypesWaterfallSpanDTO[] | null;
/**
* @type integer
* @minimum 0
*/
startTimestampMillis?: number;
/**
* @type integer
* @minimum 0
*/
totalErrorSpansCount?: number;
/**
* @type integer
* @minimum 0
*/
totalSpansCount?: number;
/**
* @type array
* @nullable true
*/
uncollapsedSpans?: string[] | null;
}
/**
* @nullable
*/
export type TracedetailtypesWaterfallSpanDTOAttributes = {
[key: string]: unknown;
} | null;
/**
* @nullable
*/
export type TracedetailtypesWaterfallSpanDTOResource = {
[key: string]: string;
} | null;
export interface TracedetailtypesWaterfallSpanDTO {
/**
* @type object
* @nullable true
*/
attributes?: TracedetailtypesWaterfallSpanDTOAttributes;
/**
* @type string
*/
db_name?: string;
/**
* @type string
*/
db_operation?: string;
/**
* @type integer
* @minimum 0
*/
duration_nano?: number;
/**
* @type array
* @nullable true
*/
events?: TracedetailtypesEventDTO[] | null;
/**
* @type string
*/
external_http_method?: string;
/**
* @type string
*/
external_http_url?: string;
/**
* @type integer
* @minimum 0
*/
flags?: number;
/**
* @type boolean
*/
has_children?: boolean;
/**
* @type boolean
*/
has_error?: boolean;
/**
* @type string
*/
http_host?: string;
/**
* @type string
*/
http_method?: string;
/**
* @type string
*/
http_url?: string;
/**
* @type string
*/
is_remote?: string;
/**
* @type integer
* @format int32
*/
kind?: number;
/**
* @type string
*/
kind_string?: string;
/**
* @type integer
* @minimum 0
*/
level?: number;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
parent_span_id?: string;
/**
* @type object
* @nullable true
*/
resource?: TracedetailtypesWaterfallSpanDTOResource;
/**
* @type string
*/
response_status_code?: string;
/**
* @type string
*/
span_id?: string;
/**
* @type integer
* @format int32
*/
status_code?: number;
/**
* @type string
*/
status_code_string?: string;
/**
* @type string
*/
status_message?: string;
/**
* @type integer
* @minimum 0
*/
sub_tree_node_count?: number;
/**
* @type integer
* @minimum 0
*/
timestamp?: number;
/**
* @type string
*/
trace_id?: string;
/**
* @type string
*/
trace_state?: string;
}
export interface TypesChangePasswordRequestDTO {
/**
* @type string
@@ -3811,7 +3450,7 @@ export type AgentCheckInDeprecatedPathParameters = {
cloudProvider: string;
};
export type AgentCheckInDeprecated200 = {
data: CloudintegrationtypesGettableAgentCheckInDTO;
data: CloudintegrationtypesGettableAgentCheckInResponseDTO;
/**
* @type string
*/
@@ -3832,8 +3471,8 @@ export type ListAccounts200 = {
export type CreateAccountPathParameters = {
cloudProvider: string;
};
export type CreateAccount201 = {
data: CloudintegrationtypesGettableAccountWithConnectionArtifactDTO;
export type CreateAccount200 = {
data: CloudintegrationtypesGettableAccountWithArtifactDTO;
/**
* @type string
*/
@@ -3860,27 +3499,11 @@ export type UpdateAccountPathParameters = {
cloudProvider: string;
id: string;
};
export type UpdateServicePathParameters = {
cloudProvider: string;
id: string;
serviceId: string;
};
export type AgentCheckInPathParameters = {
cloudProvider: string;
};
export type AgentCheckIn200 = {
data: CloudintegrationtypesGettableAgentCheckInDTO;
/**
* @type string
*/
status: string;
};
export type GetConnectionCredentialsPathParameters = {
cloudProvider: string;
};
export type GetConnectionCredentials200 = {
data: CloudintegrationtypesCredentialsDTO;
data: CloudintegrationtypesGettableAgentCheckInResponseDTO;
/**
* @type string
*/
@@ -3890,14 +3513,6 @@ export type GetConnectionCredentials200 = {
export type ListServicesMetadataPathParameters = {
cloudProvider: string;
};
export type ListServicesMetadataParams = {
/**
* @type string
* @description undefined
*/
cloud_integration_id?: string;
};
export type ListServicesMetadata200 = {
data: CloudintegrationtypesGettableServicesMetadataDTO;
/**
@@ -3910,14 +3525,6 @@ export type GetServicePathParameters = {
cloudProvider: string;
serviceId: string;
};
export type GetServiceParams = {
/**
* @type string
* @description undefined
*/
cloud_integration_id?: string;
};
export type GetService200 = {
data: CloudintegrationtypesServiceDTO;
/**
@@ -3926,6 +3533,10 @@ export type GetService200 = {
status: string;
};
export type UpdateServicePathParameters = {
cloudProvider: string;
serviceId: string;
};
export type CreateSessionByGoogleCallback303 = {
data: AuthtypesGettableTokenDTO;
/**
@@ -5116,17 +4727,6 @@ export type GetHosts200 = {
status: string;
};
export type GetWaterfallPathParameters = {
traceID: string;
};
export type GetWaterfall200 = {
data: TracedetailtypesWaterfallResponseDTO;
/**
* @type string
*/
status: string;
};
export type QueryRangeV5200 = {
data: Querybuildertypesv5QueryRangeResponseDTO;
/**

View File

@@ -1,121 +0,0 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import type {
MutationFunction,
UseMutationOptions,
UseMutationResult,
} from 'react-query';
import { useMutation } from 'react-query';
import type { BodyType, ErrorType } from '../../../generatedAPIInstance';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type {
GetWaterfall200,
GetWaterfallPathParameters,
RenderErrorResponseDTO,
TracedetailtypesWaterfallRequestDTO,
} from '../sigNoz.schemas';
/**
* Returns the waterfall view of spans for a given trace ID with tree structure, metadata, and windowed pagination
* @summary Get waterfall view for a trace
*/
export const getWaterfall = (
{ traceID }: GetWaterfallPathParameters,
tracedetailtypesWaterfallRequestDTO: BodyType<TracedetailtypesWaterfallRequestDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetWaterfall200>({
url: `/api/v3/traces/${traceID}/waterfall`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: tracedetailtypesWaterfallRequestDTO,
signal,
});
};
export const getGetWaterfallMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof getWaterfall>>,
TError,
{
pathParams: GetWaterfallPathParameters;
data: BodyType<TracedetailtypesWaterfallRequestDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof getWaterfall>>,
TError,
{
pathParams: GetWaterfallPathParameters;
data: BodyType<TracedetailtypesWaterfallRequestDTO>;
},
TContext
> => {
const mutationKey = ['getWaterfall'];
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 getWaterfall>>,
{
pathParams: GetWaterfallPathParameters;
data: BodyType<TracedetailtypesWaterfallRequestDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return getWaterfall(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type GetWaterfallMutationResult = NonNullable<
Awaited<ReturnType<typeof getWaterfall>>
>;
export type GetWaterfallMutationBody = BodyType<TracedetailtypesWaterfallRequestDTO>;
export type GetWaterfallMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get waterfall view for a trace
*/
export const useGetWaterfall = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof getWaterfall>>,
TError,
{
pathParams: GetWaterfallPathParameters;
data: BodyType<TracedetailtypesWaterfallRequestDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof getWaterfall>>,
TError,
{
pathParams: GetWaterfallPathParameters;
data: BodyType<TracedetailtypesWaterfallRequestDTO>;
},
TContext
> => {
const mutationOptions = getGetWaterfallMutationOptions(options);
return useMutation(mutationOptions);
};

View File

@@ -1,44 +0,0 @@
import { ApiV3Instance as axios } from 'api';
import { omit } from 'lodash-es';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
GetTraceV3PayloadProps,
GetTraceV3SuccessResponse,
} from 'types/api/trace/getTraceV3';
const getTraceV3 = async (
props: GetTraceV3PayloadProps,
): Promise<SuccessResponse<GetTraceV3SuccessResponse> | ErrorResponse> => {
let uncollapsedSpans = [...props.uncollapsedSpans];
if (!props.isSelectedSpanIDUnCollapsed) {
uncollapsedSpans = uncollapsedSpans.filter(
(node) => node !== props.selectedSpanId,
);
}
const postData: GetTraceV3PayloadProps = {
...props,
uncollapsedSpans,
};
const response = await axios.post<GetTraceV3SuccessResponse>(
`/traces/${props.traceId}/waterfall`,
omit(postData, 'traceId'),
);
// V3 API wraps response in { status, data }
const rawPayload = (response.data as any).data || response.data;
// Derive serviceName from resources for backward compatibility
const spans = (rawPayload.spans || []).map((span: any) => ({
...span,
serviceName: span.serviceName || span.resources?.['service.name'] || '',
}));
return {
statusCode: 200,
error: null,
message: 'Success',
payload: { ...rawPayload, spans },
};
};
export default getTraceV3;

View File

@@ -1,40 +0,0 @@
.details-header {
// ghost + secondary missing hover bg token in @signozhq/button
--button-ghost-hover-background: var(--l3-background);
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
border-bottom: 1px solid var(--l1-border);
height: 36px;
background: var(--l2-background);
&__icon-btn {
flex-shrink: 0;
}
&__title {
font-size: 13px;
font-weight: 500;
color: var(--l1-foreground);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
&__nav {
display: flex;
align-items: center;
gap: 2px;
}
}

View File

@@ -1,59 +0,0 @@
import { ReactNode } from 'react';
import { Button } from '@signozhq/button';
import { X } from '@signozhq/icons';
import './DetailsHeader.styles.scss';
export interface HeaderAction {
key: string;
component: ReactNode; // check later if we can use direct btn itself or not.
}
export interface DetailsHeaderProps {
title: string;
onClose: () => void;
actions?: HeaderAction[];
closePosition?: 'left' | 'right';
className?: string;
}
function DetailsHeader({
title,
onClose,
actions,
closePosition = 'right',
className,
}: DetailsHeaderProps): JSX.Element {
const closeButton = (
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={onClose}
aria-label="Close"
className="details-header__icon-btn"
>
<X size={14} />
</Button>
);
return (
<div className={`details-header ${className || ''}`}>
{closePosition === 'left' && closeButton}
<span className="details-header__title">{title}</span>
{actions && (
<div className="details-header__actions">
{actions.map((action) => (
<div key={action.key}>{action.component}</div>
))}
</div>
)}
{closePosition === 'right' && closeButton}
</div>
);
}
export default DetailsHeader;

View File

@@ -1,7 +0,0 @@
.details-panel-drawer {
&__body {
display: flex;
flex-direction: column;
height: 100%;
}
}

View File

@@ -1,36 +0,0 @@
import { DrawerWrapper } from '@signozhq/drawer';
import './DetailsPanelDrawer.styles.scss';
interface DetailsPanelDrawerProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
className?: string;
}
function DetailsPanelDrawer({
isOpen,
onClose,
children,
className,
}: DetailsPanelDrawerProps): JSX.Element {
return (
<DrawerWrapper
open={isOpen}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
direction="right"
type="panel"
showOverlay={false}
allowOutsideClick
className={`details-panel-drawer ${className || ''}`}
content={<div className="details-panel-drawer__body">{children}</div>}
/>
);
}
export default DetailsPanelDrawer;

View File

@@ -1,8 +0,0 @@
export type {
DetailsHeaderProps,
HeaderAction,
} from './DetailsHeader/DetailsHeader';
export { default as DetailsHeader } from './DetailsHeader/DetailsHeader';
export { default as DetailsPanelDrawer } from './DetailsPanelDrawer';
export type { DetailsPanelState, UseDetailsPanelOptions } from './types';
export { default as useDetailsPanel } from './useDetailsPanel';

View File

@@ -1,10 +0,0 @@
export interface DetailsPanelState {
isOpen: boolean;
open: () => void;
close: () => void;
}
export interface UseDetailsPanelOptions {
entityId: string | undefined;
onClose?: () => void;
}

View File

@@ -1,29 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { DetailsPanelState, UseDetailsPanelOptions } from './types';
function useDetailsPanel({
entityId,
onClose,
}: UseDetailsPanelOptions): DetailsPanelState {
const [isOpen, setIsOpen] = useState<boolean>(false);
const prevEntityIdRef = useRef<string>('');
useEffect(() => {
const currentId = entityId || '';
if (currentId && currentId !== prevEntityIdRef.current) {
setIsOpen(true);
}
prevEntityIdRef.current = currentId;
}, [entityId]);
const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => {
setIsOpen(false);
onClose?.();
}, [onClose]);
return { isOpen, open, close };
}
export default useDetailsPanel;

View File

@@ -15,6 +15,7 @@ import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import ContextView from 'container/LogDetailedView/ContextView/ContextView';
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
import JSONView from 'container/LogDetailedView/JsonView';
import Overview from 'container/LogDetailedView/Overview';
import {
aggregateAttributesResourcesToString,
@@ -44,7 +45,6 @@ import {
TextSelect,
X,
} from 'lucide-react';
import { JsonView } from 'periscope/components/JsonView';
import { AppState } from 'store/reducers';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
@@ -527,9 +527,7 @@ function LogDetailInner({
handleChangeSelectedView={handleChangeSelectedView}
/>
)}
{selectedView === VIEW_TYPES.JSON && (
<JsonView data={LogJsonData} height="68vh" />
)}
{selectedView === VIEW_TYPES.JSON && <JSONView logData={log} />}
{selectedView === VIEW_TYPES.CONTEXT && (
<ContextView

View File

@@ -28,17 +28,6 @@
}
}
// 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 {
.log-line-action-buttons {
border: 1px solid var(--bg-vanilla-400);

View File

@@ -1,6 +1,4 @@
.log-state-indicator {
padding-left: 8px;
.line {
margin: 0 8px;
min-height: 24px;

View File

@@ -0,0 +1,43 @@
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;
}
}

View File

@@ -0,0 +1,12 @@
.logBodyCell {
font-style: normal;
font-weight: 400;
letter-spacing: -0.07px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--tanstack-plain-cell-font-size, 14px);
line-height: var(--tanstack-plain-cell-line-height, 18px);
line-clamp: var(--tanstack-plain-body-line-clamp, 1);
color: var(--l2-foreground);
}

View File

@@ -0,0 +1,117 @@
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';
import styles from './useLogsTableColumns.module.scss';
type UseLogsTableColumnsProps = {
fields: IField[];
linesPerRow: number;
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,
width: { fixed: 32 },
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: { min: 200 },
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,
width: { min: 640 },
cell: ({ value, isActive }): ReactElement => (
<div
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: getSanitizedLogBody(value as string, {
shouldEscapeHtml: true,
}),
}}
data-active={isActive}
className={styles.logBodyCell}
/>
),
}
: null;
return [
stateIndicatorCol,
...(timestampCol ? [timestampCol] : []),
...(appendTo === 'center' ? fieldColumns : []),
...(bodyCol ? [bodyCol] : []),
...(appendTo === 'end' ? fieldColumns : []),
];
}, [fields, appendTo, fontSize, formatTimezoneAdjustedTimestamp]);
}

View File

@@ -0,0 +1,101 @@
/* eslint-disable no-restricted-imports */
import {
createContext,
ReactNode,
useCallback,
useContext,
useRef,
} from 'react';
/* eslint-enable no-restricted-imports */
import { createStore, StoreApi, useStore } from 'zustand';
const CLEAR_HOVER_DELAY_MS = 100;
type RowHoverState = {
hoveredRowId: string | null;
clearTimeoutId: ReturnType<typeof setTimeout> | null;
setHoveredRowId: (id: string | null) => void;
scheduleClearHover: (rowId: string) => void;
};
const createRowHoverStore = (): StoreApi<RowHoverState> =>
createStore<RowHoverState>((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 });
},
}));
type RowHoverStore = StoreApi<RowHoverState>;
const RowHoverContext = createContext<RowHoverStore | null>(null);
export function RowHoverProvider({
children,
}: {
children: ReactNode;
}): JSX.Element {
const storeRef = useRef<RowHoverStore | null>(null);
if (!storeRef.current) {
storeRef.current = createRowHoverStore();
}
return (
<RowHoverContext.Provider value={storeRef.current}>
{children}
</RowHoverContext.Provider>
);
}
const defaultStore = createRowHoverStore();
export const useIsRowHovered = (rowId: string): boolean => {
const store = useContext(RowHoverContext);
// Selector returns true only if this specific row is hovered
const isHovered = useStore(
store ?? defaultStore,
(s) => s.hoveredRowId === rowId,
);
return store ? isHovered : false;
};
export const useSetRowHovered = (rowId: string): (() => void) => {
const store = useContext(RowHoverContext);
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(RowHoverContext);
return useCallback(() => {
if (store) {
store.getState().scheduleClearHover(rowId);
}
}, [store, rowId]);
};
export default RowHoverContext;

View File

@@ -0,0 +1,111 @@
import { ComponentProps, memo } from 'react';
import { TableComponents } from 'react-virtuoso';
import cx from 'classnames';
import { useClearRowHovered, useSetRowHovered } from './RowHoverContext';
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>({
children,
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}>
{children}
</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}
>
{children}
</tr>
);
}
// Custom comparison - only re-render when row identity or computed values change
function areTableRowPropsEqual<TData>(
prev: Readonly<VirtuosoTableRowProps<TData>>,
next: Readonly<VirtuosoTableRowProps<TData>>,
): boolean {
// Different row = must re-render
if (prev.item.row.id !== next.item.row.id) {
return false;
}
// Different kind (row vs expansion) = must re-render
if (prev.item.kind !== next.item.kind) {
return false;
}
// Same row, same kind - check if computed values would differ
// We compare the context callbacks and row data to determine this
const prevData = prev.item.row.original;
const nextData = next.item.row.original;
// Row data reference changed = potential re-render needed
if (prevData !== nextData) {
return false;
}
// Context callbacks changed = computed values may differ
if (prev.context !== next.context) {
// If context changed, check if the actual computed values differ
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;

View File

@@ -1,4 +1,4 @@
.tanstack-header-cell {
.tanstackHeaderCell {
position: sticky;
top: 0;
z-index: 2;
@@ -10,16 +10,21 @@
);
transition: var(--tanstack-header-transition, none);
&.is-dragging {
&.isDragging {
opacity: 0.85;
}
&.is-resizing {
&.isResizing {
background: var(--l2-background-hover);
}
&:last-child .cursorColResize {
display: none;
border-right: 1px solid var(--l2-border);
}
}
.tanstack-header-content {
.tanstackHeaderContent {
display: flex;
align-items: center;
height: 100%;
@@ -28,20 +33,20 @@
cursor: default;
max-width: 100%;
&.has-resize-control {
&.hasResizeControl {
max-width: calc(100% - 5px);
}
&.has-action-control {
&.hasActionControl {
max-width: calc(100% - 5px);
}
&.has-resize-control.has-action-control {
&.hasResizeControl.hasActionControl {
max-width: calc(100% - 10px);
}
}
.tanstack-grip-slot {
.tanstackGripSlot {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -51,7 +56,7 @@
flex-shrink: 0;
}
.tanstack-grip-activator {
.tanstackGripActivator {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -63,7 +68,7 @@
touch-action: none;
}
.tanstack-header-action-trigger {
.tanstackHeaderActionTrigger {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -72,9 +77,11 @@
cursor: pointer;
flex-shrink: 0;
color: var(--l2-foreground);
margin-left: auto;
}
.tanstack-column-actions-content {
.tanstackColumnActionsContent {
width: 140px;
padding: 0;
background: var(--l2-background);
@@ -83,7 +90,7 @@
box-shadow: none;
}
.tanstack-remove-column-action {
.tanstackRemoveColumnAction {
display: flex;
align-items: center;
gap: 8px;
@@ -105,19 +112,19 @@
background: var(--l2-background-hover);
color: var(--l2-foreground);
.tanstack-remove-column-action-icon {
.tanstackRemoveColumnActionIcon {
color: var(--l2-foreground);
}
}
}
.tanstack-remove-column-action-icon {
.tanstackRemoveColumnActionIcon {
font-size: 11px;
color: var(--l2-foreground);
opacity: 0.95;
}
.tanstack-header-cell .cursor-col-resize {
.tanstackHeaderCell .cursorColResize {
position: absolute;
top: 0;
right: 0;
@@ -129,11 +136,11 @@
background: transparent;
}
.tanstack-header-cell.is-resizing .cursor-col-resize {
.tanstackHeaderCell.isResizing .cursorColResize {
background: var(--bg-robin-300);
}
.tanstack-resize-handle-line {
.tanstackResizeHandleLine {
position: absolute;
top: 0;
bottom: 0;
@@ -146,7 +153,7 @@
transition: background 120ms ease, width 120ms ease;
}
.tanstack-header-cell.is-resizing .tanstack-resize-handle-line {
.tanstackHeaderCell.isResizing .tanstackResizeHandleLine {
width: 2px;
background: var(--bg-robin-500);
transition: none;

View File

@@ -8,51 +8,46 @@ 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 { GripVertical } from 'lucide-react';
import { TableHeaderCellStyled } from '../InfinityTableView/styles';
import { InfinityTableProps } from '../InfinityTableView/types';
import { OrderedColumn, TanStackTableRowData } from './types';
import { getColumnId } from './utils';
import { TableColumnDef } from './types';
import './styles/TanStackHeaderRow.styles.scss';
import headerStyles from './TanStackHeaderRow.module.scss';
import tableStyles from './TanStackTable.module.scss';
type TanStackHeaderRowProps = {
column: OrderedColumn;
header?: TanStackHeader<TanStackTableRowData, unknown>;
type TanStackHeaderRowProps<TData = unknown> = {
column: TableColumnDef<TData>;
header?: TanStackHeader<TData, unknown>;
isDarkMode: boolean;
fontSize: InfinityTableProps['tableViewProps']['fontSize'];
hasSingleColumn: boolean;
canRemoveColumn?: boolean;
onRemoveColumn?: (columnKey: string) => void;
onRemoveColumn?: (columnId: string) => void;
};
const GRIP_ICON_SIZE = 12;
// eslint-disable-next-line sonarjs/cognitive-complexity
function TanStackHeaderRow({
function TanStackHeaderRow<TData>({
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());
}: TanStackHeaderRowProps<TData>): JSX.Element {
const columnId = column.id;
const isDragColumn = column.enableMove !== false && column.pin == null;
const isResizableColumn =
column.enableResize !== false && Boolean(header?.column.getCanResize());
const isColumnRemovable = Boolean(
canRemoveColumn &&
onRemoveColumn &&
column.key !== 'expand' &&
column.key !== 'state-indicator',
canRemoveColumn && onRemoveColumn && column.enableRemove,
);
const isResizing = Boolean(header?.column.getIsResizing());
const resizeHandler = header?.getResizeHandler();
const headerText =
typeof column.title === 'string' && column.title
? column.title
typeof column.header === 'string' && column.header
? column.header
: String(header?.id ?? columnId);
const headerTitleAttr = headerText.replace(/^\w/, (c) => c.toUpperCase());
const handleResizeStart = (
@@ -83,54 +78,57 @@ function TanStackHeaderRow({
} 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(' ');
const headerCellClassName = cx(
headerStyles.tanstackHeaderCell,
isDragging && headerStyles.isDragging,
isResizing && headerStyles.isResizing,
);
const headerContentClassName = cx(
headerStyles.tanstackHeaderContent,
isResizableColumn && headerStyles.hasResizeControl,
isColumnRemovable && headerStyles.hasActionControl,
);
const thClassName = cx(
tableStyles.tableHeaderCell,
headerCellClassName,
column.id,
);
return (
<TableHeaderCellStyled
<th
ref={setNodeRef}
$isLogIndicator={column.key === 'state-indicator'}
$isDarkMode={isDarkMode}
$isDragColumn={false}
className={headerCellClassName}
className={thClassName}
key={columnId}
fontSize={fontSize}
$hasSingleColumn={hasSingleColumn}
style={headerCellStyle}
data-dark-mode={isDarkMode}
data-single-column={hasSingleColumn || undefined}
>
<span className={headerContentClassName}>
{isDragColumn ? (
<span className="tanstack-grip-slot">
<span className={headerStyles.tanstackGripSlot}>
<span
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
role="button"
aria-label={`Drag ${String(
column.title || header?.id || columnId,
(typeof column.header === 'string' && column.header) ||
header?.id ||
columnId,
)} column`}
className="tanstack-grip-activator"
className={headerStyles.tanstackGripActivator}
>
<GripVertical size={GRIP_ICON_SIZE} />
</span>
</span>
) : null}
<span className="tanstack-header-title" title={headerTitleAttr}>
{header
{header?.column?.columnDef
? flexRender(header.column.columnDef.header, header.getContext())
: String(column.title || '').replace(/^\w/, (c) => c.toUpperCase())}
: typeof column.header === 'function'
? column.header()
: String(column.header || '').replace(/^\w/, (c) => c.toUpperCase())}
</span>
{isColumnRemovable && (
<Popover>
@@ -138,7 +136,7 @@ function TanStackHeaderRow({
<span
role="button"
aria-label={`Column actions for ${headerTitleAttr}`}
className="tanstack-header-action-trigger"
className={headerStyles.tanstackHeaderActionTrigger}
onMouseDown={(event): void => {
event.stopPropagation();
}}
@@ -149,18 +147,20 @@ function TanStackHeaderRow({
<PopoverContent
align="end"
sideOffset={6}
className="tanstack-column-actions-content"
className={headerStyles.tanstackColumnActionsContent}
>
<button
type="button"
className="tanstack-remove-column-action"
className={headerStyles.tanstackRemoveColumnAction}
onClick={(event): void => {
event.preventDefault();
event.stopPropagation();
onRemoveColumn?.(String(column.key));
onRemoveColumn?.(column.id);
}}
>
<CloseOutlined className="tanstack-remove-column-action-icon" />
<CloseOutlined
className={headerStyles.tanstackRemoveColumnActionIcon}
/>
Remove column
</button>
</PopoverContent>
@@ -170,7 +170,7 @@ function TanStackHeaderRow({
{isResizableColumn && (
<span
role="presentation"
className="cursor-col-resize"
className={headerStyles.cursorColResize}
title="Drag to resize column"
onClick={(event): void => {
event.preventDefault();
@@ -183,10 +183,10 @@ function TanStackHeaderRow({
handleResizeStart(event);
}}
>
<span className="tanstack-resize-handle-line" />
<span className={headerStyles.tanstackResizeHandleLine} />
</span>
)}
</TableHeaderCellStyled>
</th>
);
}

View File

@@ -0,0 +1,105 @@
import { memo, useCallback } from 'react';
import { Row as TanStackRowModel } from '@tanstack/react-table';
import { useIsRowHovered } from './RowHoverContext';
import { TanStackRowCell } from './TanStackRowCell';
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;
};
function TanStackRowCellsInner<TData>({
row,
context,
itemKind,
hasSingleColumn,
}: TanStackRowCellsProps<TData>): JSX.Element {
// Only re-render this row when ITS hover state changes
const hasHovered = useIsRowHovered(row.id);
const rowData = row.original;
const visibleCells = row.getVisibleCells();
const lastCellIndex = visibleCells.length - 1;
// Stable references via destructuring
const onRowClick = context?.onRowClick;
const onRowDeactivate = context?.onRowDeactivate;
const isRowActive = context?.isRowActive;
const handleClick = useCallback(() => {
const isActive = isRowActive?.(rowData) ?? false;
if (isActive && onRowDeactivate) {
onRowDeactivate();
} else {
onRowClick?.(rowData);
}
}, [isRowActive, onRowDeactivate, onRowClick, rowData]);
if (itemKind === 'expansion') {
return (
<td
colSpan={context?.colCount ?? 1}
className={tableStyles.tableCellExpansion}
>
{context?.renderExpandedRow?.(rowData)}
</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
function areRowCellsPropsEqual<TData>(
prev: Readonly<TanStackRowCellsProps<TData>>,
next: Readonly<TanStackRowCellsProps<TData>>,
): boolean {
return (
// Row identity
prev.row.id === next.row.id &&
// Row kind (row vs expansion)
prev.itemKind === next.itemKind &&
// Row data reference
prev.row.original === next.row.original &&
// Layout
prev.hasSingleColumn === next.hasSingleColumn &&
// Context callbacks for click handlers and row actions
prev.context?.onRowClick === next.context?.onRowClick &&
prev.context?.onRowDeactivate === next.context?.onRowDeactivate &&
prev.context?.isRowActive === next.context?.isRowActive &&
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;

View File

@@ -0,0 +1,68 @@
import type { ReactNode } from 'react';
import { memo } from 'react';
import type { Cell } from '@tanstack/react-table';
import { flexRender } from '@tanstack/react-table';
import cx from 'classnames';
import tableStyles from './TanStackTable.module.scss';
export type TanStackRowCellProps<TData> = {
cell: Cell<TData, unknown>;
hasSingleColumn: boolean;
isLastCell: boolean;
hasHovered: boolean;
rowData: TData;
onClick: () => void;
renderRowActions?: (row: TData) => ReactNode;
};
function TanStackRowCellInner<TData>({
cell,
hasSingleColumn,
isLastCell,
hasHovered,
rowData,
onClick,
renderRowActions,
}: TanStackRowCellProps<TData>): JSX.Element {
return (
<td
className={cx(tableStyles.tableCell, 'tanstack-cell-' + cell.column.id)}
data-single-column={hasSingleColumn || undefined}
onClick={onClick}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
{isLastCell && hasHovered && renderRowActions && (
<span className={tableStyles.tableViewRowActions}>
{renderRowActions(rowData)}
</span>
)}
</td>
);
}
function areTanStackRowCellPropsEqual<TData>(
prev: Readonly<TanStackRowCellProps<TData>>,
next: Readonly<TanStackRowCellProps<TData>>,
): boolean {
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;

View File

@@ -0,0 +1,105 @@
.tanStackTable {
width: 100%;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
min-width: 100%;
max-width: 100%;
& 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;
&: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;
border-top: 1px solid var(--l2-border);
border-bottom: 1px solid var(--l2-border);
border-left: 1px solid var(--l2-border);
box-shadow: inset 0 -1px 0 var(--l2-border);
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;
}

View File

@@ -0,0 +1,20 @@
import type { ReactNode } from 'react';
import cx from 'classnames';
import tableStyles from './TanStackTable.module.scss';
export type TanStackTableTextProps = {
children?: ReactNode;
className?: string;
};
function TanStackTableText({
children,
className,
}: TanStackTableTextProps): JSX.Element {
return (
<span className={cx(tableStyles.tableCellText, className)}>{children}</span>
);
}
export default TanStackTableText;

View File

@@ -0,0 +1,135 @@
.tanstackTableViewWrapper {
display: flex;
flex-direction: column;
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: scroll;
}
.tanstackTableFootLoaderCell {
text-align: center;
padding: 8px 0;
}
.tanstackTableVirtuosoScroll {
width: 100%;
overflow-x: 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);
}
&.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;
}
}
}
.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;
}
: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);
}
}

View File

@@ -0,0 +1,52 @@
import type { Table } from '@tanstack/react-table';
import type { TableColumnDef, TanStackTableProps } from './types';
import { getColumnMinWidthPx } from './utils';
import viewStyles from './TanStackTableView.module.scss';
export function VirtuosoTableColGroup<TData>({
columns,
columnSizingProp,
table,
}: {
columns: TableColumnDef<TData>[];
columnSizingProp: TanStackTableProps<TData>['columnSizing'];
table: Table<TData>;
}): JSX.Element {
return (
<colgroup>
{columns.map((column, colIndex) => {
const columnId = column.id;
const isFixedColumn = column.width?.fixed != null;
const minWidthPx = getColumnMinWidthPx(column);
const persistedWidth = columnSizingProp?.[columnId];
const computedWidth = table.getColumn(columnId)?.getSize();
const effectiveWidth = persistedWidth ?? computedWidth;
if (isFixedColumn) {
return <col key={columnId} className={viewStyles.tanstackFixedCol} />;
}
const isLastColumn = colIndex === columns.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>
);
}

View File

@@ -0,0 +1,102 @@
jest.mock('../TanStackTable.module.scss', () => ({
__esModule: true,
default: {
tableRow: 'tableRow',
tableRowActive: 'tableRowActive',
tableRowExpansion: 'tableRowExpansion',
},
}));
import { 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 } } as never,
});
const virtuosoAttrs = {
'data-index': 0,
'data-item-index': 0,
'data-known-size': 40,
} as const;
describe('TanStackCustomTableRow', () => {
it('renders children', async () => {
render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoAttrs}
item={makeItem('1')}
context={undefined}
>
<td>cell</td>
</TanStackCustomTableRow>
</tbody>
</table>,
);
expect(await screen.findByText('cell')).toBeInTheDocument();
});
it('applies active class when isRowActive returns true', () => {
const ctx: TableRowContext<{ id: string }> = {
isRowActive: (row) => row.id === '1',
colCount: 1,
};
const { container } = render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoAttrs}
item={makeItem('1')}
context={ctx as never}
>
<td>x</td>
</TanStackCustomTableRow>
</tbody>
</table>,
);
expect(container.querySelector('tr')).toHaveClass('tableRowActive');
});
it('does not apply active class when isRowActive returns false', () => {
const ctx: TableRowContext<{ id: string }> = {
isRowActive: (row) => row.id === 'other',
colCount: 1,
};
const { container } = render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoAttrs}
item={makeItem('1')}
context={ctx as never}
>
<td>x</td>
</TanStackCustomTableRow>
</tbody>
</table>,
);
expect(container.querySelector('tr')).not.toHaveClass('tableRowActive');
});
it('renders expansion row without RowHoverContext when kind is expansion', () => {
const item: FlatItem<{ id: string }> = {
kind: 'expansion',
row: { original: { id: '1' } } as never,
};
const { container } = render(
<table>
<tbody>
<TanStackCustomTableRow {...virtuosoAttrs} item={item} context={undefined}>
<td>expanded content</td>
</TanStackCustomTableRow>
</tbody>
</table>,
);
expect(container.querySelector('tr')).toHaveClass('tableRowExpansion');
});
});

View File

@@ -0,0 +1,144 @@
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();
});
});

View File

@@ -0,0 +1,162 @@
import { 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}
/>
</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 };
const row = buildMockRow([{ id: 'body' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells<Row>
row={row as never}
context={ctx}
itemKind="row"
hasSingleColumn={false}
/>
</tr>
</tbody>
</table>,
);
await user.click(screen.getAllByRole('cell')[0]);
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,
};
const row = buildMockRow([{ id: 'body' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells<Row>
row={row as never}
context={ctx}
itemKind="row"
hasSingleColumn={false}
/>
</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>,
};
const row = buildMockRow([{ id: 'body' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells<Row>
row={row as never}
context={ctx}
itemKind="row"
hasSingleColumn={false}
/>
</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>,
};
render(
<table>
<tbody>
<tr>
<TanStackRowCells<Row>
row={row as never}
context={ctx}
itemKind="expansion"
hasSingleColumn={false}
/>
</tr>
</tbody>
</table>,
);
expect(await screen.findByText('expanded-r1')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,68 @@
import { forwardRef } from 'react';
import { render, screen } from '@testing-library/react';
import TanStackTable from '../index';
import type { TableColumnDef, TanStackTableProps } from '../types';
jest.mock('react-virtuoso', () => ({
TableVirtuoso: forwardRef<unknown, { fixedHeaderContent?: () => JSX.Element }>(
function MockVirtuoso({ fixedHeaderContent }, _ref) {
return <div data-testid="virtuoso">{fixedHeaderContent?.()}</div>;
},
),
}));
jest.mock('../useTableParams', () => ({
useTableParams: (): Record<string, unknown> => ({
page: 1,
limit: 50,
orderBy: null,
setPage: jest.fn(),
setLimit: jest.fn(),
setOrderBy: jest.fn(),
}),
}));
jest.mock('../../Spinner', () => ({
__esModule: true,
default: ({ tip }: { tip: string }): JSX.Element => (
<div data-testid="spinner">{tip}</div>
),
}));
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
type Row = { id: string };
const col = (): TableColumnDef<Row> => ({
id: 'id',
header: 'ID',
cell: ({ row }): string => row.id,
accessorKey: 'id',
});
const baseProps: TanStackTableProps<Row> = {
data: [{ id: '1' }],
columns: [col()],
};
describe('TanStackTable', () => {
it('renders virtuoso when not loading', () => {
render(<TanStackTable {...baseProps} />);
expect(screen.getByTestId('virtuoso')).toBeInTheDocument();
});
it('shows loading spinner overlay when isLoading is true', () => {
render(<TanStackTable {...baseProps} isLoading />);
expect(screen.getByTestId('spinner')).toBeInTheDocument();
});
it('passes loadingTip to spinner', () => {
render(
<TanStackTable {...baseProps} isLoading loadingTip="Fetching hosts" />,
);
expect(screen.getByTestId('spinner')).toHaveTextContent('Fetching hosts');
});
});

View File

@@ -0,0 +1,194 @@
import { act, renderHook } from '@testing-library/react';
import type { TableColumnDef } from '../types';
import { useTableColumns } from '../useTableColumns';
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),
}));
type Row = { id: string; name: string };
const col = (id: string, pin?: 'left' | 'right'): TableColumnDef<Row> => ({
id,
header: id,
cell: ({ value }): string => String(value),
...(pin ? { pin } : {}),
});
describe('useTableColumns', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGet.mockReturnValue(null);
jest.useFakeTimers();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it('returns definitions in original order when no persisted state', () => {
const defs = [col('timestamp'), col('body'), col('name')];
const { result } = renderHook(() => useTableColumns(defs));
expect(result.current.tableProps.columns.map((c) => c.id)).toEqual([
'timestamp',
'body',
'name',
]);
});
it('restores column order from localStorage', () => {
mockGet.mockReturnValue(
JSON.stringify({
columnOrder: ['name', 'body', 'timestamp'],
columnSizing: {},
removedColumnIds: [],
}),
);
const defs = [col('timestamp'), col('body'), col('name')];
const { result } = renderHook(() =>
useTableColumns(defs, { storageKey: 'test_table' }),
);
expect(result.current.tableProps.columns.map((c) => c.id)).toEqual([
'name',
'body',
'timestamp',
]);
});
it('pinned columns always stay first regardless of persisted order', () => {
mockGet.mockReturnValue(
JSON.stringify({
columnOrder: ['body', 'indicator'],
columnSizing: {},
removedColumnIds: [],
}),
);
const defs = [col('indicator', 'left'), col('body')];
const { result } = renderHook(() =>
useTableColumns(defs, { storageKey: 'test_table' }),
);
expect(result.current.tableProps.columns[0].id).toBe('indicator');
});
it('excludes removed columns from tableProps.columns', () => {
mockGet.mockReturnValue(
JSON.stringify({
columnOrder: [],
columnSizing: {},
removedColumnIds: ['name'],
}),
);
const defs = [col('body'), col('name')];
const { result } = renderHook(() =>
useTableColumns(defs, { storageKey: 'test_table' }),
);
expect(result.current.tableProps.columns.map((c) => c.id)).toEqual(['body']);
expect(result.current.activeColumnIds).toEqual(['body']);
});
it('activeColumnIds reflects only currently visible columns', () => {
const defs = [col('body'), col('timestamp'), col('name')];
const { result } = renderHook(() => useTableColumns(defs));
expect(result.current.activeColumnIds).toEqual(['body', 'timestamp', 'name']);
});
it('onRemoveColumn removes column and persists after debounce', () => {
const defs = [col('body'), col('name')];
const { result } = renderHook(() =>
useTableColumns(defs, { storageKey: 'test_table' }),
);
act(() => {
result.current.tableProps.onRemoveColumn('body');
});
expect(result.current.tableProps.columns.map((c) => c.id)).toEqual(['name']);
act(() => {
jest.advanceTimersByTime(250);
});
expect(mockSet).toHaveBeenCalledWith(
'test_table',
expect.stringContaining('"removedColumnIds":["body"]'),
);
});
it('onColumnOrderChange updates column order', () => {
const defs = [col('a'), col('b'), col('c')];
const { result } = renderHook(() => useTableColumns(defs));
act(() => {
result.current.tableProps.onColumnOrderChange([
col('c'),
col('b'),
col('a'),
]);
});
expect(result.current.tableProps.columns.map((c) => c.id)).toEqual([
'c',
'b',
'a',
]);
});
it('restores column sizing from localStorage', () => {
mockGet.mockReturnValue(
JSON.stringify({
columnOrder: [],
columnSizing: { body: 400 },
removedColumnIds: [],
}),
);
const defs = [col('body')];
const { result } = renderHook(() =>
useTableColumns(defs, { storageKey: 'test_table' }),
);
expect(result.current.tableProps.columnSizing).toEqual({ body: 400 });
});
it('debounces sizing writes to localStorage', () => {
const defs = [col('body')];
const { result } = renderHook(() =>
useTableColumns(defs, { storageKey: 'test_table' }),
);
act(() => {
result.current.tableProps.onColumnSizingChange({ body: 500 });
});
expect(mockSet).not.toHaveBeenCalled();
act(() => {
jest.advanceTimersByTime(250);
});
expect(mockSet).toHaveBeenCalledWith(
'test_table',
expect.stringContaining('"body":500'),
);
});
it('falls back to definitions order when localStorage is corrupt', () => {
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
mockGet.mockReturnValue('not-json');
const defs = [col('a'), col('b')];
const { result } = renderHook(() =>
useTableColumns(defs, { storageKey: 'test_table' }),
);
expect(result.current.tableProps.columns.map((c) => c.id)).toEqual([
'a',
'b',
]);
spy.mockRestore();
});
});

View File

@@ -0,0 +1,105 @@
import { act, renderHook } from '@testing-library/react';
import { useTableParams } from '../useTableParams';
jest.mock('utils/nuqsParsers', () => ({
parseAsJsonNoValidate: (): any => ({
withDefault: (d: unknown): any => ({
withOptions: (): any => ({ _default: d }),
_default: d,
}),
}),
}));
jest.mock('nuqs', () => ({
parseAsInteger: {
withDefault: (d: number): any => ({
withOptions: (): any => ({ _default: d }),
_default: d,
}),
},
parseAsJson: (): any => ({
withDefault: (d: unknown): any => ({
withOptions: (): any => ({ _default: d }),
_default: d,
}),
}),
useQueryState: jest
.fn()
.mockImplementation((_key: string, parser: { _default: unknown }) => {
const [val, setVal] = (jest.requireActual(
'react',
) as typeof import('react')).useState(parser?._default);
return [val, setVal];
}),
}));
describe('useTableParams (local mode — enableQueryParams not set)', () => {
it('returns default page=1 and limit=50', () => {
const { result } = renderHook(() => useTableParams());
expect(result.current.page).toBe(1);
expect(result.current.limit).toBe(50);
expect(result.current.orderBy).toBeNull();
});
it('respects custom defaults', () => {
const { result } = renderHook(() =>
useTableParams(undefined, { page: 2, limit: 25 }),
);
expect(result.current.page).toBe(2);
expect(result.current.limit).toBe(25);
});
it('setPage updates page', () => {
const { result } = renderHook(() => useTableParams());
act(() => {
result.current.setPage(3);
});
expect(result.current.page).toBe(3);
});
it('setLimit updates limit', () => {
const { result } = renderHook(() => useTableParams());
act(() => {
result.current.setLimit(100);
});
expect(result.current.limit).toBe(100);
});
it('setOrderBy updates orderBy', () => {
const { result } = renderHook(() => useTableParams());
act(() => {
result.current.setOrderBy({ columnId: 'cpu', desc: true });
});
expect(result.current.orderBy).toEqual({ columnId: 'cpu', desc: true });
});
});
describe('useTableParams (URL mode — enableQueryParams set)', () => {
it('uses nuqs state when enableQueryParams=true', () => {
const { result } = renderHook(() => useTableParams(true));
expect(result.current.page).toBe(1);
act(() => {
result.current.setPage(5);
});
expect(result.current.page).toBe(5);
});
it('uses prefixed keys when enableQueryParams is a string', () => {
const { result } = renderHook(() => useTableParams('pods', { page: 2 }));
expect(result.current.page).toBe(2);
act(() => {
result.current.setPage(4);
});
expect(result.current.page).toBe(4);
});
it('local state is ignored when enableQueryParams is set', () => {
const { result: local } = renderHook(() => useTableParams());
const { result: url } = renderHook(() => useTableParams(true));
act(() => {
local.current.setPage(99);
});
expect(url.current.page).toBe(1);
});
});

View File

@@ -0,0 +1,618 @@
import type { ComponentProps, CSSProperties, Ref } from 'react';
import {
forwardRef,
memo,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import type { TableComponents } from 'react-virtuoso';
import { TableVirtuoso, TableVirtuosoHandle } from 'react-virtuoso';
import {
DndContext,
DragEndEvent,
PointerSensor,
pointerWithin,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
horizontalListSortingStrategy,
SortableContext,
} from '@dnd-kit/sortable';
import { TooltipProvider } from '@signozhq/ui';
import type { Row } from '@tanstack/react-table';
import {
ColumnDef,
ColumnPinningState,
ExpandedState,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import { Pagination } from 'antd';
import cx from 'classnames';
import { useIsDarkMode } from 'hooks/useDarkMode';
import Spinner from '../Spinner';
import { RowHoverProvider } from './RowHoverContext';
import TanStackCustomTableRow from './TanStackCustomTableRow';
import TanStackHeaderRow from './TanStackHeaderRow';
import TanStackRowCells from './TanStackRow';
import TanStackTableText from './TanStackTableText';
import {
FlatItem,
TableRowContext,
TanStackTableHandle,
TanStackTableProps,
} from './types';
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 PAGINATION_STYLE: CSSProperties = { marginTop: 12, textAlign: 'right' };
const noopColumnSizing = (): void => {};
function TanStackTableInner<TData>(
{
data,
columns,
columnSizing: columnSizingProp,
onColumnSizingChange,
onColumnOrderChange,
onRemoveColumn,
isLoading = false,
loadingTip = 'Loading',
enableQueryParams,
pagination,
onEndReached,
getRowStyle,
getRowClassName,
isRowActive,
renderRowActions,
onRowClick,
onRowDeactivate,
activeRowIndex,
renderExpandedRow,
getRowCanExpand,
tableScrollerProps,
plainTextCellLineClamp,
cellTypographySize,
}: TanStackTableProps<TData>,
forwardedRef: React.ForwardedRef<TanStackTableHandle>,
): JSX.Element {
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
const isDarkMode = useIsDarkMode();
const { page, limit, setPage, setLimit } = useTableParams(enableQueryParams, {
page: pagination?.defaultPage,
limit: pagination?.defaultLimit,
});
const [expanded, setExpanded] = useState<ExpandedState>({});
const columnPinning = useMemo<ColumnPinningState>(
() => ({
left: columns.filter((c) => c.pin === 'left').map((c) => c.id),
right: columns.filter((c) => c.pin === 'right').map((c) => c.id),
}),
[columns],
);
const tanstackColumns = useMemo<ColumnDef<TData>[]>(
() => columns.map((colDef) => buildTanstackColumnDef(colDef, isRowActive)),
[columns, isRowActive],
);
const getRowId = useCallback((row: TData, index: number): string => {
const r = row as Record<string, unknown>;
if (r != null && typeof r.id !== 'undefined') {
return String(r.id);
}
return String(index);
}, []);
const tableGetRowCanExpand = useCallback(
(row: Row<TData>): boolean =>
getRowCanExpand ? getRowCanExpand(row.original) : true,
[getRowCanExpand],
);
const table = useReactTable({
data,
columns: tanstackColumns,
enableColumnResizing: true,
enableColumnPinning: true,
columnResizeMode: 'onChange',
getCoreRowModel: getCoreRowModel(),
getRowId,
enableExpanding: Boolean(renderExpandedRow),
getRowCanExpand: renderExpandedRow ? tableGetRowCanExpand : undefined,
onColumnSizingChange: onColumnSizingChange ?? noopColumnSizing,
onExpandedChange: setExpanded,
state: {
columnSizing: columnSizingProp ?? {},
columnPinning,
expanded,
},
});
const tableRows = table.getRowModel().rows;
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;
}, [tableRows, renderExpandedRow]);
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]);
useEffect(() => {
if (flatIndexForActiveRow < 0) {
return;
}
virtuosoRef.current?.scrollToIndex({
index: flatIndexForActiveRow,
align: 'center',
behavior: 'auto',
});
}, [flatIndexForActiveRow]);
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 || !onColumnOrderChange) {
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],
);
const hasSingleColumn = useMemo(
() => columns.filter((c) => !c.pin && c.enableRemove !== false).length <= 1,
[columns],
);
const canRemoveColumn = !hasSingleColumn;
const flatHeaders = useMemo(
() => table.getFlatHeaders().filter((header) => !header.isPlaceholder),
// eslint-disable-next-line react-hooks/exhaustive-deps
[tanstackColumns, columnPinning],
);
const columnsById = useMemo(
() => new Map(columns.map((c) => [c.id, c] as const)),
[columns],
);
const virtuosoContext = useMemo<TableRowContext<TData>>(
() => ({
getRowStyle,
getRowClassName,
isRowActive,
renderRowActions,
onRowClick,
onRowDeactivate,
renderExpandedRow,
colCount: columns.length,
isDarkMode,
plainTextCellLineClamp,
}),
[
getRowStyle,
getRowClassName,
isRowActive,
renderRowActions,
onRowClick,
onRowDeactivate,
renderExpandedRow,
columns.length,
isDarkMode,
plainTextCellLineClamp,
],
);
const itemContent = useCallback(
(_index: number, item: FlatItem<TData>): JSX.Element => (
<TanStackRowCells
row={item.row}
itemKind={item.kind}
context={virtuosoContext}
hasSingleColumn={hasSingleColumn}
/>
),
[virtuosoContext, hasSingleColumn],
);
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) => {
const column = columnsById.get(header.id);
if (!column) {
return null;
}
return (
<TanStackHeaderRow
key={header.id}
column={column}
header={header}
isDarkMode={isDarkMode}
hasSingleColumn={hasSingleColumn}
onRemoveColumn={onRemoveColumn}
canRemoveColumn={canRemoveColumn}
/>
);
})}
</tr>
</SortableContext>
</DndContext>
);
}, [
sensors,
handleDragEnd,
columnIds,
flatHeaders,
columnsById,
isDarkMode,
hasSingleColumn,
onRemoveColumn,
canRemoveColumn,
]);
const handleEndReached = useCallback(
(index: number): void => {
onEndReached?.(index);
},
[onEndReached],
);
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']>
>;
const virtuosoComponents = useMemo(
() => ({
Table: ({ style, children }: VirtuosoTableComponentProps): JSX.Element => (
<table className={tableStyles.tanStackTable} style={style}>
<VirtuosoTableColGroup
columns={columns}
columnSizingProp={columnSizingProp}
table={table}
/>
{children}
</table>
),
TableRow: TanStackCustomTableRow,
}),
[columns, columnSizingProp, table],
);
return (
<div className={viewStyles.tanstackTableViewWrapper}>
<RowHoverProvider>
<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}
itemContent={itemContent}
style={virtuosoTableStyle}
components={virtuosoComponents}
endReached={onEndReached ? handleEndReached : undefined}
/>
{isLoading && (
<div className={viewStyles.tanstackLoadingOverlay}>
<Spinner height="35px" tip={loadingTip} />
</div>
)}
{showPagination && pagination && (
<Pagination
style={PAGINATION_STYLE}
current={page}
pageSize={limit}
total={pagination.total}
showSizeChanger
onChange={(p, ps): void => {
setPage(p);
if (ps != null && ps !== limit) {
setLimit(ps);
}
}}
/>
)}
</TooltipProvider>
</RowHoverProvider>
</div>
);
}
const TanStackTableForward = forwardRef(TanStackTableInner) as <TData>(
props: TanStackTableProps<TData> & {
ref?: React.Ref<TanStackTableHandle>;
},
) => JSX.Element;
const TanStackTableBase = memo(
TanStackTableForward,
) as typeof TanStackTableForward;
/**
* Virtualized data table built on TanStack Table and `react-virtuoso`: resizable and pinnable columns,
* optional drag-to-reorder headers, expandable rows, and Ant Design pagination or infinite scroll.
*
* @example Minimal usage
* ```tsx
* import TanStackTable from 'components/TanStackTableView';
* import type { TableColumnDef } from 'components/TanStackTableView/types';
*
* 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
* ```tsx
* const columns: TableColumnDef<Row>[] = [
* {
* id: 'id',
* header: 'ID',
* accessorKey: 'id',
* pin: 'left',
* width: { min: 80, default: 120 },
* cell: ({ value }) => <TanStackTable.Text>{String(value)}</TanStackTable.Text>,
* },
* {
* id: 'computed',
* header: () => <span>Computed</span>,
* accessorFn: (row) => row.first + row.last,
* enableMove: false,
* cell: ({ value }) => <TanStackTable.Text>{String(value)}</TanStackTable.Text>,
* },
* ];
* ```
*
* @example Controlled column sizing and reorder (persist in parent state)
* ```tsx
* import type { ColumnSizingState } from '@tanstack/react-table';
*
* const [columnSizing, setColumnSizing] = useState<ColumnSizingState>({});
*
* <TanStackTable
* data={data}
* columns={columns}
* columnSizing={columnSizing}
* onColumnSizingChange={setColumnSizing}
* onColumnOrderChange={setColumns}
* onRemoveColumn={(id) => setColumns((cols) => cols.filter((c) => c.id !== id))}
* />
* ```
*
* @example Pagination (Ant Design). Omit `onEndReached` so the footer pager is shown.
* ```tsx
* <TanStackTable
* data={pageRows}
* columns={columns}
* pagination={{ total: totalCount, defaultPage: 1, defaultLimit: 20 }}
* enableQueryParams
* />
* ```
*
* @example Infinite scroll — use `onEndReached` instead of `pagination` (pagination UI is hidden when `onEndReached` is set).
* ```tsx
* <TanStackTable
* data={accumulatedRows}
* columns={columns}
* onEndReached={(lastIndex) => fetchMore(lastIndex)}
* />
* ```
*
* @example Loading overlay and typography for plain string/number cells
* ```tsx
* <TanStackTable
* data={data}
* columns={columns}
* isLoading={isFetching}
* loadingTip="Loading logs…"
* cellTypographySize="small"
* plainTextCellLineClamp={2}
* />
* ```
*
* @example Row styling, selection, and actions
* ```tsx
* <TanStackTable
* data={data}
* columns={columns}
* isRowActive={(row) => row.id === selectedId}
* activeRowIndex={selectedIndex}
* onRowClick={(row) => setSelectedId(row.id)}
* 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
* ```tsx
* <TanStackTable
* data={data}
* columns={columns}
* renderExpandedRow={(row) => <pre>{JSON.stringify(row.raw, null, 2)}</pre>}
* getRowCanExpand={(row) => Boolean(row.raw)}
* />
* ```
*
* @example Imperative handle — `goToPage` plus Virtuoso methods (e.g. `scrollToIndex`)
* ```tsx
* import type { TanStackTableHandle } from 'components/TanStackTableView/types';
*
* 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 (className, `data-testid`, etc.). `data` is reserved by Virtuoso and cannot be passed here.
* ```tsx
* <TanStackTable
* data={data}
* columns={columns}
* tableScrollerProps={{ className: 'my-table-scroll', 'data-testid': 'logs-table' }}
* />
* ```
*/
const TanStackTable = Object.assign(TanStackTableBase, {
Text: TanStackTableText,
});
export default TanStackTable;

View File

@@ -0,0 +1,112 @@
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 = { columnId: string; desc: boolean };
/** 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> = {
row: TData;
value: unknown;
isActive: boolean;
rowIndex: number;
isExpanded: boolean;
canExpand: boolean;
toggleExpanded: () => void;
};
export type TableColumnDef<TData> = {
id: string;
header: string | (() => ReactNode);
cell: (context: TableCellContext<TData>) => ReactNode;
accessorKey?: keyof TData & string;
accessorFn?: (row: TData) => unknown;
pin?: 'left' | 'right';
enableMove?: boolean;
enableResize?: boolean;
enableRemove?: boolean;
enableSort?: boolean;
width?: {
fixed?: number;
min?: number;
default?: number;
};
};
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) => void;
onRowDeactivate?: () => void;
renderExpandedRow?: (row: TData) => ReactNode;
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;
};
export type PaginationProps = {
total: number;
defaultPage?: number;
defaultLimit?: number;
};
export type TanStackTableProps<TData> = {
data: TData[];
columns: TableColumnDef<TData>[];
columnSizing?: ColumnSizingState;
onColumnSizingChange?: Dispatch<SetStateAction<ColumnSizingState>>;
onColumnOrderChange?: (cols: TableColumnDef<TData>[]) => void;
onRemoveColumn?: (id: string) => void;
isLoading?: boolean;
loadingTip?: string;
enableQueryParams?: boolean | string;
pagination?: PaginationProps;
onEndReached?: (index: number) => void;
getRowStyle?: (row: TData) => CSSProperties;
getRowClassName?: (row: TData) => string;
isRowActive?: (row: TData) => boolean;
renderRowActions?: (row: TData) => ReactNode;
onRowClick?: (row: TData) => void;
onRowDeactivate?: () => void;
activeRowIndex?: number;
renderExpandedRow?: (row: TData) => 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'>;
};
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;
};

View File

@@ -0,0 +1,200 @@
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { ColumnSizingState } from '@tanstack/react-table';
import getFromLocalstorage from 'api/browser/localstorage/get';
import setToLocalstorage from 'api/browser/localstorage/set';
import { TableColumnDef, TableColumnsState } from './types';
const DEBOUNCE_MS = 250;
type PersistedState = {
columnOrder: string[];
columnSizing: ColumnSizingState;
removedColumnIds: string[];
};
const EMPTY: PersistedState = {
columnOrder: [],
columnSizing: {},
removedColumnIds: [],
};
function readStorage(storageKey: string): PersistedState {
const raw = getFromLocalstorage(storageKey);
if (!raw) {
return EMPTY;
}
try {
const parsed = JSON.parse(raw) as PersistedState;
return {
columnOrder: Array.isArray(parsed.columnOrder) ? parsed.columnOrder : [],
columnSizing:
parsed.columnSizing && typeof parsed.columnSizing === 'object'
? Object.fromEntries(
Object.entries(parsed.columnSizing).filter(
([, v]) => typeof v === 'number' && Number.isFinite(v) && v > 0,
),
)
: {},
removedColumnIds: Array.isArray(parsed.removedColumnIds)
? parsed.removedColumnIds
: [],
};
} catch (e) {
console.error('useTableColumns: failed to parse storage', e);
return EMPTY;
}
}
type UseTableColumnsOptions = { storageKey?: string };
type UseTableColumnsResult<TData> = {
tableProps: TableColumnsState<TData>;
activeColumnIds: string[];
};
export function useTableColumns<TData>(
definitions: TableColumnDef<TData>[],
options?: UseTableColumnsOptions,
): UseTableColumnsResult<TData> {
const { storageKey } = options ?? {};
const [persisted, setPersisted] = useState<PersistedState>(() =>
storageKey ? readStorage(storageKey) : EMPTY,
);
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>(
() => persisted.columnSizing,
);
const pendingRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const persistedRef = useRef(persisted);
persistedRef.current = persisted;
const columnSizingRef = useRef(columnSizing);
columnSizingRef.current = columnSizing;
const scheduleWrite = useCallback(() => {
if (!storageKey) {
return;
}
if (pendingRef.current !== null) {
clearTimeout(pendingRef.current);
}
pendingRef.current = setTimeout(() => {
setToLocalstorage(
storageKey,
JSON.stringify({
...persistedRef.current,
columnSizing: columnSizingRef.current,
}),
);
}, DEBOUNCE_MS);
}, [storageKey]);
useEffect(() => {
scheduleWrite();
return (): void => {
if (pendingRef.current !== null) {
clearTimeout(pendingRef.current);
}
};
}, [columnSizing, scheduleWrite]);
const handleColumnSizingChange: Dispatch<
SetStateAction<ColumnSizingState>
> = useCallback((updater) => {
setColumnSizing((prev) =>
typeof updater === 'function' ? updater(prev) : updater,
);
}, []);
const handleColumnOrderChange = useCallback(
(updated: TableColumnDef<TData>[]) => {
const newOrder = updated.map((c) => c.id);
setPersisted((prev) => {
const next = { ...prev, columnOrder: newOrder };
if (storageKey) {
setToLocalstorage(
storageKey,
JSON.stringify({
...next,
columnSizing: columnSizingRef.current,
}),
);
}
return next;
});
},
[storageKey],
);
const handleRemoveColumn = useCallback(
(id: string) => {
setPersisted((prev) => {
if (prev.removedColumnIds.includes(id)) {
return prev;
}
const next = {
...prev,
removedColumnIds: [...prev.removedColumnIds, id],
};
if (storageKey) {
if (pendingRef.current !== null) {
clearTimeout(pendingRef.current);
}
pendingRef.current = setTimeout(() => {
setToLocalstorage(
storageKey,
JSON.stringify({
...next,
columnSizing: columnSizingRef.current,
}),
);
}, DEBOUNCE_MS);
}
return next;
});
},
[storageKey],
);
const columns = useMemo<TableColumnDef<TData>[]>(() => {
const removedSet = new Set(persisted.removedColumnIds);
const active = definitions.filter((d) => !removedSet.has(d.id));
if (persisted.columnOrder.length === 0) {
return active;
}
const orderMap = new Map(persisted.columnOrder.map((id, i) => [id, i]));
const pinned = active.filter((c) => c.pin != null);
const rest = active.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];
}, [definitions, persisted]);
const activeColumnIds = useMemo(() => columns.map((c) => c.id), [columns]);
return {
tableProps: {
columns,
columnSizing,
onColumnSizingChange: handleColumnSizingChange,
onColumnOrderChange: handleColumnOrderChange,
onRemoveColumn: handleRemoveColumn,
},
activeColumnIds,
};
}

View File

@@ -0,0 +1,72 @@
import { useState } from 'react';
import { parseAsInteger, useQueryState } from 'nuqs';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import { SortState } 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 };
type TableParamsResult = {
page: number;
limit: number;
orderBy: SortState | null;
setPage: (p: number) => void;
setLimit: (l: number) => void;
setOrderBy: (s: SortState | null) => void;
};
export function useTableParams(
enableQueryParams?: boolean | string,
defaults?: Defaults,
): TableParamsResult {
const prefix = typeof enableQueryParams === 'string' ? enableQueryParams : '';
const sep = prefix ? '_' : '';
const pageDefault = defaults?.page ?? DEFAULT_PAGE;
const limitDefault = defaults?.limit ?? DEFAULT_LIMIT;
const orderByDefault = defaults?.orderBy ?? null;
const [localPage, setLocalPage] = useState(pageDefault);
const [localLimit, setLocalLimit] = useState(limitDefault);
const [localOrderBy, setLocalOrderBy] = useState<SortState | null>(
orderByDefault,
);
const [urlPage, setUrlPage] = useQueryState(
`${prefix}${sep}page`,
parseAsInteger.withDefault(pageDefault).withOptions(NUQS_OPTIONS),
);
const [urlLimit, setUrlLimit] = useQueryState(
`${prefix}${sep}limit`,
parseAsInteger.withDefault(limitDefault).withOptions(NUQS_OPTIONS),
);
const [urlOrderBy, setUrlOrderBy] = useQueryState(
`${prefix}${sep}order_by`,
parseAsJsonNoValidate<SortState | null>()
.withDefault(orderByDefault as never)
.withOptions(NUQS_OPTIONS),
);
if (enableQueryParams) {
return {
page: urlPage,
limit: urlLimit,
orderBy: urlOrderBy as SortState | null,
setPage: setUrlPage,
setLimit: setUrlLimit,
setOrderBy: setUrlOrderBy,
};
}
return {
page: localPage,
limit: localLimit,
orderBy: localOrderBy,
setPage: setLocalPage,
setLimit: setLocalLimit,
setOrderBy: setLocalOrderBy,
};
}

View File

@@ -0,0 +1,67 @@
import type { ReactNode } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { TableColumnDef } from './types';
export const getColumnId = <TData>(column: TableColumnDef<TData>): string =>
column.id;
const REM_PX = 16;
const MIN_WIDTH_DEFAULT_REM = 12;
export const getColumnMinWidthPx = <TData>(
column: TableColumnDef<TData>,
): number => {
if (column.width?.fixed != null) {
return column.width.fixed;
}
if (column.width?.min != null) {
return column.width.min;
}
return MIN_WIDTH_DEFAULT_REM * REM_PX;
};
export function buildTanstackColumnDef<TData>(
colDef: TableColumnDef<TData>,
isRowActive?: (row: TData) => boolean,
): ColumnDef<TData> {
const isFixed = colDef.width?.fixed != null;
const fixedWidth = colDef.width?.fixed;
const minWidthPx = getColumnMinWidthPx(colDef);
return {
id: colDef.id,
header:
typeof colDef.header === 'string'
? colDef.header
: (): ReactNode =>
typeof colDef.header === 'function' ? colDef.header() : null,
accessorFn: (row: TData): unknown => {
if (colDef.accessorFn) {
return colDef.accessorFn(row);
}
if (colDef.accessorKey) {
return (row as Record<string, unknown>)[colDef.accessorKey];
}
return undefined;
},
enableResizing: colDef.enableResize !== false && !isFixed,
enableSorting: colDef.enableSort === true,
minSize: fixedWidth ?? minWidthPx,
size: colDef.width?.default ?? fixedWidth,
maxSize: fixedWidth,
cell: ({ row, getValue }): ReactNode => {
const rowData = row.original;
return colDef.cell({
row: rowData,
value: getValue(),
isActive: isRowActive?.(rowData) ?? false,
rowIndex: row.index,
isExpanded: row.getIsExpanded(),
canExpand: row.getCanExpand(),
toggleExpanded: (): void => {
row.toggleExpanded();
},
});
},
};
}

View File

@@ -1,4 +0,0 @@
.timeline-v3-container {
// flex: 1;
overflow: visible;
}

View File

@@ -1,87 +0,0 @@
import { useEffect, useState } from 'react';
import { useMeasure } from 'react-use';
import { useIsDarkMode } from 'hooks/useDarkMode';
import {
getIntervals,
getMinimumIntervalsBasedOnWidth,
Interval,
} from './utils';
import './TimelineV3.styles.scss';
interface ITimelineV3Props {
startTimestamp: number;
endTimestamp: number;
timelineHeight: number;
offsetTimestamp: number;
}
function TimelineV3(props: ITimelineV3Props): JSX.Element {
const {
startTimestamp,
endTimestamp,
timelineHeight,
offsetTimestamp,
} = props;
const [intervals, setIntervals] = useState<Interval[]>([]);
const [ref, { width }] = useMeasure<HTMLDivElement>();
const isDarkMode = useIsDarkMode();
useEffect(() => {
const spread = endTimestamp - startTimestamp;
if (spread < 0) {
return;
}
const minIntervals = getMinimumIntervalsBasedOnWidth(width);
const intervalisedSpread = (spread / minIntervals) * 1.0;
const intervals = getIntervals(intervalisedSpread, spread, offsetTimestamp);
setIntervals(intervals);
}, [startTimestamp, endTimestamp, width, offsetTimestamp]);
if (endTimestamp < startTimestamp) {
console.error(
'endTimestamp cannot be less than startTimestamp',
startTimestamp,
endTimestamp,
);
return <div />;
}
const strokeColor = isDarkMode ? ' rgb(192,193,195,0.8)' : 'black';
return (
<div ref={ref as never} className="timeline-v3-container">
<svg
width={width}
height={timelineHeight * 2.5}
xmlns="http://www.w3.org/2000/svg"
overflow="visible"
>
{intervals &&
intervals.length > 0 &&
intervals.map((interval, index) => (
<g
transform={`translate(${(interval.percentage * width) / 100},0)`}
key={`${interval.percentage + interval.label + index}`}
textAnchor="middle"
fontSize="0.6rem"
>
<text
x={index === intervals.length - 1 ? -10 : 0}
y={timelineHeight * 2}
fill={strokeColor}
>
{interval.label}
</text>
<line y1={0} y2={timelineHeight} stroke={strokeColor} strokeWidth="1" />
</g>
))}
</svg>
</div>
);
}
export default TimelineV3;

View File

@@ -1,93 +0,0 @@
import {
IIntervalUnit,
Interval,
INTERVAL_UNITS,
resolveTimeFromInterval,
} from 'components/TimelineV2/utils';
import { toFixed } from 'utils/toFixed';
export type { Interval };
/** Fewer intervals than TimelineV2 for a cleaner flamegraph ruler. */
export function getMinimumIntervalsBasedOnWidth(width: number): number {
if (width < 640) {
return 3;
}
if (width < 768) {
return 4;
}
if (width < 1024) {
return 5;
}
return 6;
}
/**
* Computes timeline intervals with offset-aware labels.
* Labels reflect absolute time from trace start (offsetTimestamp + elapsed),
* so when zoomed into a window, the first tick shows e.g. "50ms" not "0ms".
*/
export function getIntervals(
intervalSpread: number,
baseSpread: number,
offsetTimestamp: number,
): Interval[] {
const integerPartString = intervalSpread.toString().split('.')[0];
const integerPartLength = integerPartString.length;
const intervalSpreadNormalized =
intervalSpread < 1.0
? intervalSpread
: Math.floor(Number(integerPartString) / 10 ** (integerPartLength - 1)) *
10 ** (integerPartLength - 1);
// Unit must suit both: (1) tick granularity (intervalSpread) and (2) label magnitude
// (offsetTimestamp). When zoomed deep into a trace, labels show offsetTimestamp + elapsed,
// so we must pick a unit where that value is readable (e.g. "500.00s" not "500000.00ms").
const valueForUnitSelection = Math.max(offsetTimestamp, intervalSpread);
let intervalUnit: IIntervalUnit = INTERVAL_UNITS[0];
for (let idx = INTERVAL_UNITS.length - 1; idx >= 0; idx -= 1) {
const standardInterval = INTERVAL_UNITS[idx];
if (valueForUnitSelection * standardInterval.multiplier >= 1) {
intervalUnit = INTERVAL_UNITS[idx];
break;
}
}
const intervals: Interval[] = [
{
label: `${toFixed(
resolveTimeFromInterval(offsetTimestamp, intervalUnit),
2,
)}${intervalUnit.name}`,
percentage: 0,
},
];
let tempBaseSpread = baseSpread;
let elapsedIntervals = 0;
while (tempBaseSpread && intervals.length < 20) {
let intervalTime: number;
if (tempBaseSpread <= 1.5 * intervalSpreadNormalized) {
intervalTime = elapsedIntervals + tempBaseSpread;
tempBaseSpread = 0;
} else {
intervalTime = elapsedIntervals + intervalSpreadNormalized;
tempBaseSpread -= intervalSpreadNormalized;
}
elapsedIntervals = intervalTime;
const labelTime = offsetTimestamp + intervalTime;
intervals.push({
label: `${toFixed(resolveTimeFromInterval(labelTime, intervalUnit), 2)}${
intervalUnit.name
}`,
percentage: (intervalTime / baseSpread) * 100,
});
}
return intervals;
}

View File

@@ -37,5 +37,4 @@ export enum LOCALSTORAGE {
SHOW_FREQUENCY_CHART = 'SHOW_FREQUENCY_CHART',
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
TRACE_DETAILS_SPAN_DETAILS_POSITION = 'TRACE_DETAILS_SPAN_DETAILS_POSITION',
}

View File

@@ -32,7 +32,6 @@ export const REACT_QUERY_KEY = {
UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE',
GET_ACTIVE_LICENSE_V3: 'GET_ACTIVE_LICENSE_V3',
GET_TRACE_V2_WATERFALL: 'GET_TRACE_V2_WATERFALL',
GET_TRACE_V3_WATERFALL: 'GET_TRACE_V3_WATERFALL',
GET_TRACE_V2_FLAMEGRAPH: 'GET_TRACE_V2_FLAMEGRAPH',
GET_POD_LIST: 'GET_POD_LIST',
GET_NODE_LIST: 'GET_NODE_LIST',

View File

@@ -33,125 +33,6 @@ const themeColors = {
purple: '#800080',
cyan: '#00FFFF',
},
traceDetailColorsV3: {
// Blues
dodgerBlue: '#2F80ED',
royalBlue: '#3366E6',
steelBlue: '#4682B4',
// Teals / Cyans
turquoise: '#00CEC9',
lagoon: '#1ABC9C',
cyanBright: '#22A6F2',
// Greens
emeraldGreen: '#27AE60',
mediumSeaGreen: '#3CB371',
limeGreen: '#A3E635',
// Yellows / Golds
festivalYellow: '#F2C94C',
sunflower: '#FFD93D',
warmAmber: '#FFCA28',
// Purples / Violets
mediumPurple: '#BB6BD9',
royalPurple: '#9B51E0',
orchid: '#DA77F2',
// Accent
neonViolet: '#C77DFF',
electricPurple: '#6C5CE7',
arcticBlue: '#48DBFB',
// Blues extended
blue1: '#1F63E0',
blue2: '#3A7AED',
blue3: '#5A9DF5',
blue4: '#2874A6',
blue5: '#2E86C1',
blue6: '#3498DB',
// Cyans
cyan1: '#00B0AA',
cyan2: '#33D6C2',
cyan3: '#66E9DA',
// Greens extended
green1: '#1E8449',
green2: '#2ECC71',
green3: '#58D68D',
green4: '#229954',
green5: '#27AE60',
green6: '#52BE80',
// Forest
forest1: '#27AE60',
forest2: '#2ECC71',
forest3: '#58D68D',
// Lime
lime1: '#A3E635',
lime2: '#B9F18D',
lime3: '#D4FFB0',
// Teals
teal1: '#009688',
teal2: '#1ABC9C',
teal3: '#48C9B0',
teal4: '#1ABC9C',
teal5: '#48C9B0',
teal6: '#76D7C4',
// Yellows
yellow1: '#F1C40F',
yellow2: '#F7DC6F',
yellow3: '#F9E79F',
// Gold
gold1: '#F39C12',
gold2: '#F1C40F',
gold3: '#F7DC6F',
gold4: '#B7950B',
gold5: '#F1C40F',
gold6: '#F4D03F',
// Mustard
mustard1: '#F1C40F',
mustard2: '#F7DC6F',
mustard3: '#F9E79F',
// Aqua
aqua1: '#00BFFF',
aqua2: '#1E90FF',
aqua3: '#63B8FF',
// Purple extended
purple1: '#8E44AD',
purple2: '#9B59B6',
purple3: '#BB8FCE',
violet1: '#8E44AD',
violet2: '#9B59B6',
violet3: '#BB8FCE',
violet4: '#7D3C98',
violet5: '#8E44AD',
violet6: '#9B59B6',
// Lavender
lavender1: '#9B59B6',
lavender2: '#AF7AC5',
lavender3: '#C39BD3',
// Oranges (safe ones, not red-ish)
orange4: '#D35400',
orange5: '#E67E22',
orange6: '#EB984E',
coral1: '#E67E22',
coral2: '#F39C12',
coral3: '#F5B041',
},
chartcolors: {
// Blues (3)
dodgerBlue: '#2F80ED',

View File

@@ -123,7 +123,6 @@
&__row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: flex-end;
max-width: 825px;
gap: 25px;

View File

@@ -1,21 +1,34 @@
import type { CSSProperties, MouseEvent, ReactNode } 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 { toast } from '@signozhq/sonner';
import { Card, Typography } from 'antd';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
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 { useLogsTableColumns } from 'components/Logs/TableView/useLogsTableColumns';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import TanStackTable from 'components/TanStackTableView';
import type { TanStackTableHandle } from 'components/TanStackTableView/types';
import { useTableColumns } from 'components/TanStackTableView/useTableColumns';
import { CARD_BODY_STYLE } from 'constants/card';
import { LOCALSTORAGE } from 'constants/localStorage';
import { OptionFormatTypes } from 'constants/optionsFormatTypes';
import { QueryParams } from 'constants/query';
import { InfinityWrapperStyled } from 'container/LogsExplorerList/styles';
import TanStackTableView from 'container/LogsExplorerList/TanStackTableView';
import { convertKeysToColumnFields } from 'container/LogsExplorerList/utils';
import { useOptionsMenu } from 'container/OptionsMenu';
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useEventSource } from 'providers/EventSource';
// interfaces
import { ILog } from 'types/api/logs/log';
@@ -30,7 +43,10 @@ function LiveLogsList({
isLoading,
handleChangeSelectedView,
}: LiveLogsListProps): JSX.Element {
const ref = useRef<VirtuosoHandle>(null);
const ref = useRef<TanStackTableHandle | VirtuosoHandle | null>(null);
const { pathname } = useLocation();
const [, setCopy] = useCopyToClipboard();
const isDarkMode = useIsDarkMode();
const { isConnectionLoading } = useEventSource();
@@ -66,9 +82,46 @@ function LiveLogsList({
...options.selectColumns,
]);
const logsColumns = useLogsTableColumns({
fields: selectedFields,
linesPerRow: options.maxLines,
fontSize: options.fontSize,
appendTo: 'end',
});
const { tableProps } = useTableColumns(logsColumns, {
storageKey: LOCALSTORAGE.LOGS_LIST_COLUMNS,
});
const handleRemoveColumn = useCallback(
(columnId: string): void => {
tableProps.onRemoveColumn(columnId);
config.addColumn?.onRemove?.(columnId);
},
[tableProps, config.addColumn],
);
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({
logs: formattedLogs,
virtuosoRef: ref,
virtuosoRef: ref as React.RefObject<Pick<
VirtuosoHandle,
'scrollToIndex'
> | null>,
});
const getItemContent = useCallback(
@@ -158,29 +211,48 @@ function LiveLogsList({
{formattedLogs.length !== 0 && (
<InfinityWrapperStyled>
{options.format === OptionFormatTypes.TABLE ? (
<TanStackTableView
ref={ref}
<TanStackTable
ref={ref as React.Ref<TanStackTableHandle>}
{...tableProps}
plainTextCellLineClamp={options.maxLines}
cellTypographySize={options.fontSize}
onRemoveColumn={handleRemoveColumn}
data={formattedLogs}
isLoading={false}
tableViewProps={{
logs: formattedLogs,
fields: selectedFields,
linesPerRow: options.maxLines,
fontSize: options.fontSize,
appendTo: 'end',
activeLogIndex,
isRowActive={(log): boolean => log.id === activeLog?.id}
getRowStyle={(log): CSSProperties =>
({
'--row-active-bg': getRowBackgroundColor(
isDarkMode,
getLogIndicatorType(log),
),
'--row-hover-bg': getRowBackgroundColor(
isDarkMode,
getLogIndicatorType(log),
),
} as CSSProperties)
}
onRowClick={(log): void => {
handleSetActiveLog(log);
}}
handleChangeSelectedView={handleChangeSelectedView}
logs={formattedLogs}
onSetActiveLog={handleSetActiveLog}
onClearActiveLog={handleCloseLogDetail}
activeLog={activeLog}
onRemoveColumn={config.addColumn?.onRemove}
onRowDeactivate={handleCloseLogDetail}
activeRowIndex={activeLogIndex}
renderRowActions={(log): ReactNode => (
<LogLinesActionButtons
handleShowContext={(e): void => {
e.preventDefault();
e.stopPropagation();
handleSetActiveLog(log, VIEW_TYPES.CONTEXT);
}}
onLogCopy={makeOnLogCopy(log)}
/>
)}
/>
) : (
<Card style={{ width: '100%' }} bodyStyle={CARD_BODY_STYLE}>
<OverlayScrollbar isVirtuoso>
<Virtuoso
ref={ref}
ref={ref as React.Ref<VirtuosoHandle>}
initialTopMostItemIndex={activeLogIndex !== -1 ? activeLogIndex : 0}
data={formattedLogs}
totalCount={formattedLogs.length}

View File

@@ -11,12 +11,6 @@
}
}
.infra-metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 24px;
}
.infra-metrics-card {
margin: 1rem 0;
height: 300px;

View File

@@ -1,6 +1,6 @@
import { useMemo, useRef } from 'react';
import { useQueries, UseQueryResult } from 'react-query';
import { Card, Skeleton, Typography } from 'antd';
import { Card, Col, Row, Skeleton, Typography } from 'antd';
import cx from 'classnames';
import Uplot from 'components/Uplot';
import { ENTITY_VERSION_V4 } from 'constants/app';
@@ -163,16 +163,16 @@ function NodeMetrics({
);
};
return (
<div className="infra-metrics-grid">
<Row gutter={24}>
{queries.map((query, idx) => (
<div key={widgetInfo[idx].title}>
<Col span={12} key={widgetInfo[idx].title}>
<Typography.Text>{widgetInfo[idx].title}</Typography.Text>
<Card bordered className="infra-metrics-card" ref={graphRef}>
{renderCardContent(query, idx)}
</Card>
</div>
</Col>
))}
</div>
</Row>
);
}

View File

@@ -1,6 +1,6 @@
import { useMemo, useRef } from 'react';
import { useQueries, UseQueryResult } from 'react-query';
import { Card, Skeleton, Typography } from 'antd';
import { Card, Col, Row, Skeleton, Typography } from 'antd';
import cx from 'classnames';
import Uplot from 'components/Uplot';
import { ENTITY_VERSION_V4 } from 'constants/app';
@@ -146,16 +146,16 @@ function PodMetrics({
};
return (
<div className="infra-metrics-grid">
<Row gutter={24}>
{queries.map((query, idx) => (
<div key={podWidgetInfo[idx].title}>
<Col span={12} key={podWidgetInfo[idx].title}>
<Typography.Text>{podWidgetInfo[idx].title}</Typography.Text>
<Card bordered className="infra-metrics-card" ref={graphRef}>
{renderCardContent(query, idx)}
</Card>
</div>
</Col>
))}
</div>
</Row>
);
}

View File

@@ -221,7 +221,7 @@ function ColumnView({
onColumnOrderChange(formattedColumns);
};
const handleRowClick = (row: Row<Record<string, unknown>>): void => {
const handleRowClick = (row: Row<Record<string, string>>): void => {
const currentLog = logs.find(({ id }) => id === row.original.id);
setShowActiveLog(true);

View File

@@ -1,8 +0,0 @@
// 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;

View File

@@ -1,84 +0,0 @@
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;
});

View File

@@ -1,95 +0,0 @@
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;

View File

@@ -1,105 +0,0 @@
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();
});
});

View File

@@ -1,152 +0,0 @@
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();
});
});

View File

@@ -1,193 +0,0 @@
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();
});
});

View File

@@ -1,104 +0,0 @@
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();
});
});

View File

@@ -1,173 +0,0 @@
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();
});
});

View File

@@ -1,222 +0,0 @@
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',
]);
});
});

View File

@@ -1,433 +0,0 @@
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);

View File

@@ -1,55 +0,0 @@
.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;
}

View File

@@ -1,29 +0,0 @@
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;
};

View File

@@ -1,111 +0,0 @@
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 };
};

View File

@@ -1,108 +0,0 @@
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,
};
};

View File

@@ -1,61 +0,0 @@
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;
};

View File

@@ -1,16 +1,28 @@
import type { CSSProperties, MouseEvent, ReactNode } 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 { toast } from '@signozhq/sonner';
import { Card } from 'antd';
import logEvent from 'api/common/logEvent';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import LogDetail from 'components/LogDetail';
// components
import { VIEW_TYPES } from 'components/LogDetail/constants';
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 { useLogsTableColumns } from 'components/Logs/TableView/useLogsTableColumns';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import Spinner from 'components/Spinner';
import TanStackTable from 'components/TanStackTableView';
import type { TanStackTableHandle } from 'components/TanStackTableView/types';
import { useTableColumns } from 'components/TanStackTableView/useTableColumns';
import { CARD_BODY_STYLE } from 'constants/card';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { useOptionsMenu } from 'container/OptionsMenu';
@@ -19,6 +31,7 @@ import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import APIError from 'types/api/error';
// interfaces
import { ILog } from 'types/api/logs/log';
@@ -27,7 +40,6 @@ import { DataSource, StringOperators } from 'types/common/queryBuilder';
import NoLogs from '../NoLogs/NoLogs';
import { LogsExplorerListProps } from './LogsExplorerList.interfaces';
import { InfinityWrapperStyled } from './styles';
import TanStackTableView from './TanStackTableView';
import {
convertKeysToColumnFields,
getEmptyLogsListConfig,
@@ -50,7 +62,10 @@ function LogsExplorerList({
isFilterApplied,
handleChangeSelectedView,
}: LogsExplorerListProps): JSX.Element {
const ref = useRef<VirtuosoHandle>(null);
const ref = useRef<TanStackTableHandle | VirtuosoHandle | null>(null);
const { pathname } = useLocation();
const [, setCopy] = useCopyToClipboard();
const isDarkMode = useIsDarkMode();
const { activeLogId } = useCopyLogLink();
const {
@@ -84,9 +99,46 @@ function LogsExplorerList({
[options],
);
const logsColumns = useLogsTableColumns({
fields: selectedFields,
linesPerRow: options.maxLines,
fontSize: options.fontSize,
appendTo: 'end',
});
const { tableProps } = useTableColumns(logsColumns, {
storageKey: LOCALSTORAGE.LOGS_LIST_COLUMNS,
});
const handleRemoveColumn = useCallback(
(columnId: string): void => {
tableProps.onRemoveColumn(columnId);
config.addColumn?.onRemove?.(columnId);
},
[tableProps, config.addColumn],
);
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({
logs,
virtuosoRef: ref,
virtuosoRef: ref as React.RefObject<Pick<
VirtuosoHandle,
'scrollToIndex'
> | null>,
});
useEffect(() => {
@@ -155,25 +207,46 @@ function LogsExplorerList({
if (options.format === 'table') {
return (
<TanStackTableView
ref={ref}
isLoading={isLoading}
isFetching={isFetching}
tableViewProps={{
logs,
fields: selectedFields,
linesPerRow: options.maxLines,
fontSize: options.fontSize,
appendTo: 'end',
activeLogIndex,
<TanStackTable
ref={ref as React.Ref<TanStackTableHandle>}
{...tableProps}
plainTextCellLineClamp={options.maxLines}
cellTypographySize={options.fontSize}
onRemoveColumn={handleRemoveColumn}
data={logs}
isLoading={isLoading || isFetching}
loadingTip="Getting Logs"
onEndReached={onEndReached}
isRowActive={(log): boolean =>
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);
}}
infitiyTableProps={{ onEndReached }}
handleChangeSelectedView={handleChangeSelectedView}
logs={logs}
onSetActiveLog={handleSetActiveLog}
onClearActiveLog={handleCloseLogDetail}
activeLog={activeLog}
onRemoveColumn={config.addColumn?.onRemove}
onRowDeactivate={handleCloseLogDetail}
activeRowIndex={activeLogIndex}
renderRowActions={(log): ReactNode => (
<LogLinesActionButtons
handleShowContext={(e): void => {
e.preventDefault();
e.stopPropagation();
handleSetActiveLog(log, VIEW_TYPES.CONTEXT);
}}
onLogCopy={makeOnLogCopy(log)}
/>
)}
/>
);
}
@@ -198,7 +271,7 @@ function LogsExplorerList({
<OverlayScrollbar isVirtuoso>
<Virtuoso
key={activeLogIndex || 'logs-virtuoso'}
ref={ref}
ref={ref as React.Ref<VirtuosoHandle>}
initialTopMostItemIndex={activeLogIndex !== -1 ? activeLogIndex : 0}
data={logs}
endReached={onEndReached}
@@ -219,12 +292,13 @@ function LogsExplorerList({
onEndReached,
getItemContent,
isFetching,
selectedFields,
handleChangeSelectedView,
handleSetActiveLog,
handleCloseLogDetail,
activeLog,
config.addColumn?.onRemove,
tableProps,
handleRemoveColumn,
isDarkMode,
makeOnLogCopy,
]);
const isTraceToLogsNavigation = useMemo(() => {

View File

@@ -1,38 +0,0 @@
.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);
}
}

View File

@@ -677,18 +677,6 @@ function NewWidget({
queryType: currentQuery.queryType,
isNewPanel,
dataSource: currentQuery?.builder?.queryData?.[0]?.dataSource,
...(currentQuery.queryType === EQueryType.CLICKHOUSE && {
clickhouseQueryCount: currentQuery.clickhouse_sql.length,
clickhouseQueries: currentQuery.clickhouse_sql.map((q) => ({
name: q.name,
query: (q.query ?? '')
.replace(/--[^\n]*/g, '') // strip line comments
.replace(/\/\*[\s\S]*?\*\//g, '') // strip block comments
.replace(/'(?:[^'\\]|\\.|'')*'/g, "'?'") // replace single-quoted strings (handles \' and '' escapes)
.replace(/\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b/g, '?'), // replace numeric literals (int, float, scientific)
disabled: q.disabled,
})),
}),
});
setSaveModal(true);
// eslint-disable-next-line react-hooks/exhaustive-deps

View File

@@ -23,6 +23,7 @@
&-empty-content {
height: 100%;
border: 1px solid var(--bg-slate-500);
border-top: none;
display: flex;
flex-direction: column;

View File

@@ -1,9 +1,11 @@
import { useCallback } from 'react';
import type { VirtuosoHandle } from 'react-virtuoso';
type ScrollToIndexHandle = Pick<VirtuosoHandle, 'scrollToIndex'>;
type UseScrollToLogParams = {
logs: Array<{ id: string }>;
virtuosoRef: React.RefObject<VirtuosoHandle | null>;
virtuosoRef: React.RefObject<ScrollToIndexHandle | null>;
};
function useScrollToLog({

View File

@@ -1,7 +1,7 @@
import { MouseEventHandler, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { toast } from '@signozhq/sonner';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQuery from 'hooks/useUrlQuery';
import { Span } from 'types/api/trace/getTraceV2';
@@ -11,6 +11,7 @@ export const useCopySpanLink = (
const urlQuery = useUrlQuery();
const { pathname } = useLocation();
const [, setCopy] = useCopyToClipboard();
const { notifications } = useNotifications();
const onSpanCopy: MouseEventHandler<HTMLElement> = useCallback(
(event) => {
@@ -30,12 +31,11 @@ export const useCopySpanLink = (
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
setCopy(link);
toast.success('Copied to clipboard', {
richColors: true,
position: 'top-right',
notifications.success({
message: 'Copied to clipboard',
});
},
[span, urlQuery, pathname, setCopy],
[span, urlQuery, pathname, setCopy, notifications],
);
return {

View File

@@ -16,7 +16,7 @@ const useGetTraceFlamegraph = (
queryKey: [
REACT_QUERY_KEY.GET_TRACE_V2_FLAMEGRAPH,
props.traceId,
// props.selectedSpanId,
props.selectedSpanId,
],
enabled: !!props.traceId,
keepPreviousData: true,

View File

@@ -1,29 +0,0 @@
import { useQuery, UseQueryResult } from 'react-query';
import getTraceV3 from 'api/trace/getTraceV3';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
GetTraceV3PayloadProps,
GetTraceV3SuccessResponse,
} from 'types/api/trace/getTraceV3';
const useGetTraceV3 = (props: GetTraceV3PayloadProps): UseTraceV3 =>
useQuery({
queryFn: () => getTraceV3(props),
queryKey: [
REACT_QUERY_KEY.GET_TRACE_V3_WATERFALL,
props.traceId,
props.selectedSpanId,
props.isSelectedSpanIDUnCollapsed,
],
enabled: !!props.traceId,
keepPreviousData: true,
refetchOnWindowFocus: false,
});
type UseTraceV3 = UseQueryResult<
SuccessResponse<GetTraceV3SuccessResponse> | ErrorResponse,
unknown
>;
export default useGetTraceV3;

View File

@@ -7,23 +7,6 @@ export function hashFn(str: string): number {
return hash >>> 0;
}
export function colorToRgb(color: string): string {
// Handle hex colors
const hexMatch = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color);
if (hexMatch) {
return `${parseInt(hexMatch[1], 16)}, ${parseInt(
hexMatch[2],
16,
)}, ${parseInt(hexMatch[3], 16)}`;
}
// Handle rgb() colors
const rgbMatch = /^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/.exec(color);
if (rgbMatch) {
return `${rgbMatch[1]}, ${rgbMatch[2]}, ${rgbMatch[3]}`;
}
return '136, 136, 136';
}
export function generateColor(
key: string,
colorMap: Record<string, string>,

View File

@@ -569,12 +569,8 @@ describe('TooltipPlugin', () => {
}),
);
const resizeCall = addSpy.mock.calls.find(
([type]) => type === ('resize' as keyof WindowEventMap),
);
const scrollCall = addSpy.mock.calls.find(
([type]) => type === ('scroll' as keyof WindowEventMap),
);
const resizeCall = addSpy.mock.calls.find(([type]) => type === 'resize');
const scrollCall = addSpy.mock.calls.find(([type]) => type === 'scroll');
expect(resizeCall).toBeDefined();
expect(scrollCall).toBeDefined();

View File

@@ -4,11 +4,19 @@ import ROUTES from 'constants/routes';
import history from 'lib/history';
import { Compass, Cone, TowerControl } from 'lucide-react';
import TraceDetailsV3 from '../TraceDetailsV3';
import TraceDetailsV2 from './TraceDetailV2';
import './TraceDetailV2.styles.scss';
function NewTraceDetail(props: any): JSX.Element {
interface INewTraceDetailProps {
items: {
label: JSX.Element;
key: string;
children: JSX.Element;
}[];
}
function NewTraceDetail(props: INewTraceDetailProps): JSX.Element {
const { items } = props;
return (
<div className="traces-module-container">
@@ -42,7 +50,7 @@ export default function TraceDetailsPage(): JSX.Element {
</div>
),
key: 'trace-details',
children: <TraceDetailsV3 />,
children: <TraceDetailsV2 />,
},
{
label: (

View File

@@ -1,96 +0,0 @@
.analytics-panel {
&__body {
padding: 12px 6px;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
background: var(--l1-background);
// TabsRoot — last direct child div
> div:last-child {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
[role='tablist'] {
flex-shrink: 0;
}
}
&__tabs-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-width: none;
}
&__list {
display: grid;
grid-template-columns: auto auto 1fr;
gap: 4px 8px;
padding: 8px 0;
align-items: center;
}
&__dot {
width: 8px;
height: 8px;
border-radius: 2px;
}
&__service-name {
font-size: 13px;
color: var(--l1-foreground);
word-break: break-word;
}
&__bar-cell {
display: flex;
align-items: center;
gap: 8px;
}
&__bar {
flex: 1;
height: 6px;
background: var(--l3-background);
border-radius: 3px;
min-width: 40px;
&--small {
max-width: 80px;
flex: 0 0 80px;
}
}
&__bar-fill {
height: 100%;
border-radius: 3px;
}
&__value {
flex-shrink: 0;
text-align: right;
font-size: 13px;
font-weight: 500;
color: var(--l1-foreground);
&--wide {
min-width: 55px;
}
&--narrow {
min-width: 25px;
}
}
// Tabs root
[class*='tabs__list-wrapper'] {
padding-left: 0 !important;
}
}

View File

@@ -1,185 +0,0 @@
import { useMemo } from 'react';
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from '@signozhq/ui';
import { DetailsHeader } from 'components/DetailsPanel';
import { themeColors } from 'constants/theme';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { FloatingPanel } from 'periscope/components/FloatingPanel';
import './AnalyticsPanel.styles.scss';
interface AnalyticsPanelProps {
isOpen: boolean;
onClose: () => void;
serviceExecTime?: Record<string, number>;
traceStartTime?: number;
traceEndTime?: number;
// TODO: Re-enable when backend provides per-service span counts
// spans?: Span[];
}
const PANEL_WIDTH = 350;
const PANEL_MARGIN_RIGHT = 100;
const PANEL_MARGIN_TOP = 50;
const PANEL_MARGIN_BOTTOM = 50;
function AnalyticsPanel({
isOpen,
onClose,
serviceExecTime = {},
traceStartTime = 0,
traceEndTime = 0,
}: AnalyticsPanelProps): JSX.Element | null {
const spread = traceEndTime - traceStartTime;
const execTimeRows = useMemo(() => {
if (spread <= 0) {
return [];
}
return Object.entries(serviceExecTime)
.map(([service, duration]) => ({
service,
percentage: (duration * 100) / spread,
color: generateColor(service, themeColors.traceDetailColorsV3),
}))
.sort((a, b) => b.percentage - a.percentage);
}, [serviceExecTime, spread]);
// const spanCountRows = useMemo(() => {
// const counts: Record<string, number> = {};
// for (const span of spans) {
// const name = span.serviceName || 'unknown';
// counts[name] = (counts[name] || 0) + 1;
// }
// return Object.entries(counts)
// .map(([service, count]) => ({
// service,
// count,
// color: generateColor(service, themeColors.traceDetailColorsV3),
// }))
// .sort((a, b) => b.count - a.count);
// }, [spans]);
// const maxSpanCount = spanCountRows[0]?.count || 1;
if (!isOpen) {
return null;
}
return (
<FloatingPanel
isOpen
className="analytics-panel"
width={PANEL_WIDTH}
height={window.innerHeight - PANEL_MARGIN_TOP - PANEL_MARGIN_BOTTOM}
defaultPosition={{
x: window.innerWidth - PANEL_WIDTH - PANEL_MARGIN_RIGHT,
y: PANEL_MARGIN_TOP,
}}
enableResizing={{
top: true,
bottom: true,
left: false,
right: false,
topLeft: false,
topRight: false,
bottomLeft: false,
bottomRight: false,
}}
>
<DetailsHeader
title="Analytics"
onClose={onClose}
className="floating-panel__drag-handle"
/>
<div className="analytics-panel__body">
<TabsRoot defaultValue="exec-time">
<TabsList variant="secondary">
<TabsTrigger value="exec-time" variant="secondary">
% exec time
</TabsTrigger>
{/* TODO: Enable when backend provides per-service span counts
<TabsTrigger value="spans" variant="secondary">
Spans
</TabsTrigger>
*/}
</TabsList>
<div className="analytics-panel__tabs-scroll">
<TabsContent value="exec-time">
<div className="analytics-panel__list">
{execTimeRows.map((row) => (
<>
<div
key={`${row.service}-dot`}
className="analytics-panel__dot"
style={{ backgroundColor: row.color }}
/>
<span
key={`${row.service}-name`}
className="analytics-panel__service-name"
>
{row.service}
</span>
<div key={`${row.service}-bar`} className="analytics-panel__bar-cell">
<div className="analytics-panel__bar">
<div
className="analytics-panel__bar-fill"
style={{
width: `${Math.min(row.percentage, 100)}%`,
backgroundColor: row.color,
}}
/>
</div>
<span className="analytics-panel__value analytics-panel__value--wide">
{row.percentage.toFixed(2)}%
</span>
</div>
</>
))}
</div>
</TabsContent>
{/* TODO: Enable when backend provides per-service span counts
<TabsContent value="spans">
<div className="analytics-panel__list">
{spanCountRows.map((row) => (
<>
<div
key={`${row.service}-dot`}
className="analytics-panel__dot"
style={{ backgroundColor: row.color }}
/>
<span
key={`${row.service}-name`}
className="analytics-panel__service-name"
>
{row.service}
</span>
<div key={`${row.service}-bar`} className="analytics-panel__bar-cell">
<div className="analytics-panel__bar">
<div
className="analytics-panel__bar-fill"
style={{
width: `${(row.count / maxSpanCount) * 100}%`,
backgroundColor: row.color,
}}
/>
</div>
<span className="analytics-panel__value analytics-panel__value--narrow">
{row.count}
</span>
</div>
</>
))}
</div>
</TabsContent>
*/}
</div>
</TabsRoot>
</div>
</FloatingPanel>
);
}
export default AnalyticsPanel;

View File

@@ -1,34 +0,0 @@
.linked-spans {
position: relative;
&__toggle {
display: inline-flex;
align-items: center;
gap: 4px;
background: none;
border: none;
padding: 0;
cursor: pointer;
color: inherit;
font: inherit;
}
&__label {
white-space: nowrap;
}
&__chevron {
transition: transform 0.15s ease;
&--open {
transform: rotate(90deg);
}
}
&__list {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px 0;
}
}

View File

@@ -1,118 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Badge } from '@signozhq/badge';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import ROUTES from 'constants/routes';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import './LinkedSpans.styles.scss';
interface SpanReference {
traceId: string;
spanId: string;
refType: string;
}
interface LinkedSpansProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
references: any;
}
interface LinkedSpansState {
linkedSpans: SpanReference[];
count: number;
isOpen: boolean;
toggleOpen: () => void;
}
export function useLinkedSpans(references: any): LinkedSpansState {
const [isOpen, setIsOpen] = useState(false);
const linkedSpans: SpanReference[] = useMemo(
() =>
(references || []).filter(
(ref: SpanReference) => ref.refType !== 'CHILD_OF',
),
[references],
);
const toggleOpen = useCallback(() => setIsOpen((prev) => !prev), []);
return {
linkedSpans,
count: linkedSpans.length,
isOpen,
toggleOpen,
};
}
export function LinkedSpansToggle({
count,
isOpen,
toggleOpen,
}: {
count: number;
isOpen: boolean;
toggleOpen: () => void;
}): JSX.Element {
if (count === 0) {
return <span className="linked-spans__label">0 linked spans</span>;
}
return (
<button type="button" className="linked-spans__toggle" onClick={toggleOpen}>
<span className="linked-spans__label">
{count} linked span{count !== 1 ? 's' : ''}
</span>
{isOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
);
}
export function LinkedSpansPanel({
linkedSpans,
isOpen,
}: {
linkedSpans: SpanReference[];
isOpen: boolean;
}): JSX.Element | null {
const getLink = useCallback(
(item: SpanReference): string =>
`${ROUTES.TRACE}/${item.traceId}?spanId=${item.spanId}`,
[],
);
if (!isOpen || linkedSpans.length === 0) {
return null;
}
return (
<div className="linked-spans__list">
{linkedSpans.map((item) => (
<KeyValueLabel
key={item.spanId}
badgeKey="Linked Span ID"
badgeValue={
<Link to={getLink(item)}>
<Badge color="vanilla">{item.spanId}</Badge>
</Link>
}
direction="column"
/>
))}
</div>
);
}
function LinkedSpans({ references }: LinkedSpansProps): JSX.Element {
const { linkedSpans, count, isOpen, toggleOpen } = useLinkedSpans(references);
return (
<div className="linked-spans">
<LinkedSpansToggle count={count} isOpen={isOpen} toggleOpen={toggleOpen} />
<LinkedSpansPanel linkedSpans={linkedSpans} isOpen={isOpen} />
</div>
);
}
export default LinkedSpans;

View File

@@ -1,150 +0,0 @@
.span-details-panel {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
&__header-nav {
display: flex;
align-items: center;
gap: 2px;
}
&__body {
padding: 12px;
display: flex;
flex-wrap: wrap;
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-width: none;
background: var(--l1-background);
font-size: 14px;
gap: 16px;
.data-viewer {
min-height: 500px;
}
}
&__details-section {
flex: 1 1 375px;
min-width: 0;
max-height: 100%;
overflow-y: auto;
scrollbar-width: none;
}
&__tabs-section {
flex: 1 1 375px;
min-width: 0;
max-height: 100%;
min-height: 400px;
display: flex;
flex-direction: column;
overflow: hidden;
// TabsRoot — direct child of tabs-section
> div {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
[role='tablist'] {
flex-shrink: 0;
}
[class*='tabs__list-wrapper'] {
padding-left: 0 !important;
}
}
&__tabs-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-width: none;
[role='tabpanel'] {
padding: 0;
}
}
&__span-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
justify-content: space-between;
}
&__span-info {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
padding: 8px 0;
}
&__span-info-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--l2-foreground);
white-space: nowrap;
}
&__highlighted-options {
padding: 8px 0;
display: flex;
flex-wrap: wrap;
gap: 0;
.key-value-label {
flex: 1 1 50%;
min-width: 120px;
overflow: hidden;
}
}
&__service-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--bg-forest-500);
}
&__trace-id {
color: var(--accent-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
&__key-attributes {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0;
&-label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--l2-foreground);
text-transform: uppercase;
letter-spacing: 0.48px;
line-height: var(--line-height-20);
}
&-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
}
}

View File

@@ -1,549 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { Button } from '@signozhq/button';
import {
Bookmark,
CalendarClock,
ChartBar,
ChartColumnBig,
Dock,
Ellipsis,
Link2,
Logs,
PanelBottom,
ScrollText,
Timer,
} from '@signozhq/icons';
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from '@signozhq/ui';
import { Skeleton, Tooltip } from 'antd';
import { DetailsHeader, DetailsPanelDrawer } from 'components/DetailsPanel';
import { HeaderAction } from 'components/DetailsPanel/DetailsHeader/DetailsHeader';
import { DetailsPanelState } from 'components/DetailsPanel/types';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { QueryParams } from 'constants/query';
import {
initialQueryBuilderFormValuesMap,
initialQueryState,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
import Events from 'container/SpanDetailsDrawer/Events/Events';
import SpanLogs from 'container/SpanDetailsDrawer/SpanLogs/SpanLogs';
import { useSpanContextLogs } from 'container/SpanDetailsDrawer/SpanLogs/useSpanContextLogs';
import dayjs from 'dayjs';
import { noop } from 'lodash-es';
import { getSpanAttribute, hasInfraMetadata } from 'pages/TraceDetailsV3/utils';
import { DataViewer } from 'periscope/components/DataViewer';
import { FloatingPanel } from 'periscope/components/FloatingPanel';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Span } from 'types/api/trace/getTraceV2';
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
import AnalyticsPanel from './AnalyticsPanel/AnalyticsPanel';
import { HIGHLIGHTED_OPTIONS } from './config';
import { KEY_ATTRIBUTE_KEYS, SpanDetailVariant } from './constants';
import {
LinkedSpansPanel,
LinkedSpansToggle,
useLinkedSpans,
} from './LinkedSpans/LinkedSpans';
import SpanPercentileBadge from './SpanPercentile/SpanPercentileBadge';
import SpanPercentilePanel from './SpanPercentile/SpanPercentilePanel';
import useSpanPercentile from './SpanPercentile/useSpanPercentile';
import './SpanDetailsPanel.styles.scss';
interface SpanDetailsPanelProps {
panelState: DetailsPanelState;
selectedSpan: Span | undefined;
variant?: SpanDetailVariant;
onVariantChange?: (variant: SpanDetailVariant) => void;
traceStartTime?: number;
traceEndTime?: number;
serviceExecTime?: Record<string, number>;
}
function SpanDetailsContent({
selectedSpan,
traceStartTime,
traceEndTime,
}: {
selectedSpan: Span;
traceStartTime?: number;
traceEndTime?: number;
}): JSX.Element {
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
const percentile = useSpanPercentile(selectedSpan);
const linkedSpans = useLinkedSpans(selectedSpan.references);
const {
logs,
isLoading: isLogsLoading,
isError: isLogsError,
isFetching: isLogsFetching,
isLogSpanRelated,
hasTraceIdLogs,
} = useSpanContextLogs({
traceId: selectedSpan.traceId,
spanId: selectedSpan.spanId,
timeRange: {
startTime: (traceStartTime || 0) - FIVE_MINUTES_IN_MS,
endTime: (traceEndTime || 0) + FIVE_MINUTES_IN_MS,
},
isDrawerOpen: true,
});
const infraMetadata = useMemo(() => {
if (!hasInfraMetadata(selectedSpan)) {
return null;
}
return {
clusterName: getSpanAttribute(selectedSpan, 'k8s.cluster.name') || '',
podName: getSpanAttribute(selectedSpan, 'k8s.pod.name') || '',
nodeName: getSpanAttribute(selectedSpan, 'k8s.node.name') || '',
hostName: getSpanAttribute(selectedSpan, 'host.name') || '',
spanTimestamp: dayjs(selectedSpan.timestamp).format(),
};
}, [selectedSpan]);
const handleExplorerPageRedirect = useCallback((): void => {
const startTimeMs = (traceStartTime || 0) - FIVE_MINUTES_IN_MS;
const endTimeMs = (traceEndTime || 0) + FIVE_MINUTES_IN_MS;
const traceIdFilter = {
op: 'AND',
items: [
{
id: 'trace-id-filter',
key: {
key: 'trace_id',
id: 'trace-id-key',
dataType: 'string' as const,
isColumn: true,
type: '',
isJSON: false,
} as BaseAutocompleteData,
op: '=',
value: selectedSpan.traceId,
},
],
};
const compositeQuery = {
...initialQueryState,
queryType: 'builder',
builder: {
...initialQueryState.builder,
queryData: [
{
...initialQueryBuilderFormValuesMap.logs,
aggregateOperator: LogsAggregatorOperator.NOOP,
filters: traceIdFilter,
},
],
},
};
const searchParams = new URLSearchParams();
searchParams.set(QueryParams.compositeQuery, JSON.stringify(compositeQuery));
searchParams.set(QueryParams.startTime, startTimeMs.toString());
searchParams.set(QueryParams.endTime, endTimeMs.toString());
window.open(
`${window.location.origin}${
ROUTES.LOGS_EXPLORER
}?${searchParams.toString()}`,
'_blank',
'noopener,noreferrer',
);
}, [selectedSpan.traceId, traceStartTime, traceEndTime]);
const emptyLogsStateConfig = useMemo(
() => ({
...getEmptyLogsListConfig(() => {}),
showClearFiltersButton: false,
}),
[],
);
const keyAttributes = useMemo(() => {
const keys = KEY_ATTRIBUTE_KEYS.traces || [];
const allAttrs: Record<string, string> = {
...(selectedSpan.attributes || selectedSpan.attributes_string),
...(selectedSpan.resources || selectedSpan.resources_string),
...(selectedSpan.http_method && { http_method: selectedSpan.http_method }),
...(selectedSpan.http_url && { http_url: selectedSpan.http_url }),
...(selectedSpan.http_host && { http_host: selectedSpan.http_host }),
...(selectedSpan.db_name && { db_name: selectedSpan.db_name }),
...(selectedSpan.db_operation && {
db_operation: selectedSpan.db_operation,
}),
...(selectedSpan.external_http_method && {
external_http_method: selectedSpan.external_http_method,
}),
...(selectedSpan.external_http_url && {
external_http_url: selectedSpan.external_http_url,
}),
...(selectedSpan.response_status_code && {
response_status_code: selectedSpan.response_status_code,
}),
datetime: dayjs(selectedSpan.timestamp).format('MMM D, YYYY — HH:mm:ss'),
duration: getYAxisFormattedValue(
`${selectedSpan.durationNano / 1000000}`,
'ms',
),
'span.kind': selectedSpan.spanKind,
status_code_string: selectedSpan.statusCodeString,
};
return keys
.filter((key) => allAttrs[key])
.map((key) => ({ key, value: String(allAttrs[key]) }));
}, [selectedSpan]);
return (
<div className="span-details-panel__body">
<div className="span-details-panel__details-section">
<div className="span-details-panel__span-row">
<KeyValueLabel
badgeKey="Span name"
badgeValue={selectedSpan.name}
maxCharacters={50}
/>
<SpanPercentileBadge
loading={percentile.loading}
percentileValue={percentile.percentileValue}
duration={percentile.duration}
spanPercentileData={percentile.spanPercentileData}
isOpen={percentile.isOpen}
toggleOpen={percentile.toggleOpen}
/>
</div>
<SpanPercentilePanel selectedSpan={selectedSpan} percentile={percentile} />
{/* Span info: exec time + start time */}
<div className="span-details-panel__span-info">
<div className="span-details-panel__span-info-item">
<Timer size={14} />
<span>
{getYAxisFormattedValue(`${selectedSpan.durationNano / 1000000}`, 'ms')}
{traceStartTime && traceEndTime && traceEndTime > traceStartTime && (
<>
{' — '}
<strong>
{(
(selectedSpan.durationNano * 100) /
((traceEndTime - traceStartTime) * 1e6)
).toFixed(2)}
%
</strong>
{' of total exec time'}
</>
)}
</span>
</div>
<div className="span-details-panel__span-info-item">
<CalendarClock size={14} />
<span>
{dayjs(selectedSpan.timestamp).format('HH:mm:ss — MMM D, YYYY')}
</span>
</div>
<div className="span-details-panel__span-info-item">
<Link2 size={14} />
<LinkedSpansToggle
count={linkedSpans.count}
isOpen={linkedSpans.isOpen}
toggleOpen={linkedSpans.toggleOpen}
/>
</div>
</div>
<LinkedSpansPanel
linkedSpans={linkedSpans.linkedSpans}
isOpen={linkedSpans.isOpen}
/>
{/* Step 6: HighlightedOptions */}
<div className="span-details-panel__highlighted-options">
{HIGHLIGHTED_OPTIONS.map((option) => {
const rendered = option.render(selectedSpan);
if (!rendered) {
return null;
}
return (
<KeyValueLabel
key={option.key}
badgeKey={option.label}
badgeValue={rendered}
direction="column"
/>
);
})}
</div>
{/* Step 7: KeyAttributes */}
{keyAttributes.length > 0 && (
<div className="span-details-panel__key-attributes">
<div className="span-details-panel__key-attributes-label">
KEY ATTRIBUTES
</div>
<div className="span-details-panel__key-attributes-chips">
{keyAttributes.map(({ key, value }) => (
<KeyValueLabel key={key} badgeKey={key} badgeValue={value} />
))}
</div>
</div>
)}
{/* Step 8: MiniTraceContext */}
</div>
<div className="span-details-panel__tabs-section">
{/* Step 9: ContentTabs */}
<TabsRoot defaultValue="overview">
<TabsList variant="secondary">
<TabsTrigger value="overview" variant="secondary">
<Bookmark size={14} /> Overview
</TabsTrigger>
<TabsTrigger value="events" variant="secondary">
<ScrollText size={14} /> Events ({selectedSpan.event?.length || 0})
</TabsTrigger>
<TabsTrigger value="logs" variant="secondary">
<Logs size={14} /> Logs
</TabsTrigger>
{infraMetadata && (
<TabsTrigger value="metrics" variant="secondary">
<ChartColumnBig size={14} /> Metrics
</TabsTrigger>
)}
</TabsList>
<div className="span-details-panel__tabs-scroll">
<TabsContent value="overview">
<DataViewer
data={selectedSpan}
drawerKey="trace-details"
prettyViewProps={{ showPinned: true }}
/>
</TabsContent>
<TabsContent value="events">
<Events
span={selectedSpan}
startTime={traceStartTime || 0}
isSearchVisible
/>
</TabsContent>
<TabsContent value="logs">
<SpanLogs
traceId={selectedSpan.traceId}
spanId={selectedSpan.spanId}
timeRange={{
startTime: (traceStartTime || 0) - FIVE_MINUTES_IN_MS,
endTime: (traceEndTime || 0) + FIVE_MINUTES_IN_MS,
}}
logs={logs}
isLoading={isLogsLoading}
isError={isLogsError}
isFetching={isLogsFetching}
isLogSpanRelated={isLogSpanRelated}
handleExplorerPageRedirect={handleExplorerPageRedirect}
emptyStateConfig={!hasTraceIdLogs ? emptyLogsStateConfig : undefined}
/>
</TabsContent>
{infraMetadata && (
<TabsContent value="metrics">
<InfraMetrics
clusterName={infraMetadata.clusterName}
podName={infraMetadata.podName}
nodeName={infraMetadata.nodeName}
hostName={infraMetadata.hostName}
timestamp={infraMetadata.spanTimestamp}
dataSource={DataSource.TRACES}
/>
</TabsContent>
)}
</div>
</TabsRoot>
</div>
</div>
);
}
function SpanDetailsPanel({
panelState,
selectedSpan,
variant = SpanDetailVariant.DIALOG,
onVariantChange,
traceStartTime,
traceEndTime,
serviceExecTime,
}: SpanDetailsPanelProps): JSX.Element {
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
const headerActions = useMemo((): HeaderAction[] => {
const actions: HeaderAction[] = [
{
key: 'overflow',
component: (
<Button variant="ghost" size="icon" color="secondary" onClick={noop}>
<Ellipsis size={14} />
</Button>
),
},
{
key: 'analytics',
component: (
<Button
variant="ghost"
size="sm"
color="secondary"
prefixIcon={<ChartBar size={14} />}
onClick={(): void => setIsAnalyticsOpen((prev) => !prev)}
>
Analytics
</Button>
),
},
// TODO: Add back when driven through separate config for different pages
// {
// key: 'view-full-trace',
// component: (
// <Button variant="ghost" size="sm" color="secondary" prefixIcon={<ExternalLink size={14} />} onClick={noop}>
// View full trace
// </Button>
// ),
// },
// TODO: Add back when used in trace explorer page
// {
// key: 'nav',
// component: (
// <div className="span-details-panel__header-nav">
// <Button variant="ghost" size="icon" color="secondary" onClick={noop}><ChevronUp size={14} /></Button>
// <Button variant="ghost" size="icon" color="secondary" onClick={noop}><ChevronDown size={14} /></Button>
// </div>
// ),
// },
];
if (onVariantChange) {
const isDocked = variant === SpanDetailVariant.DOCKED;
actions.push({
key: 'dock-toggle',
component: (
<Tooltip title={isDocked ? 'Open as floating panel' : 'Dock on the side'}>
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={(): void =>
onVariantChange(
isDocked ? SpanDetailVariant.DIALOG : SpanDetailVariant.DOCKED,
)
}
>
{isDocked ? <Dock size={14} /> : <PanelBottom size={14} />}
</Button>
</Tooltip>
),
});
}
return actions;
}, [variant, onVariantChange]);
const PANEL_WIDTH = 500;
const PANEL_MARGIN_RIGHT = 20;
const PANEL_MARGIN_TOP = 25;
const PANEL_MARGIN_BOTTOM = 25;
const content = (
<>
<DetailsHeader
title="Span details"
onClose={panelState.close}
actions={headerActions}
className={
variant === SpanDetailVariant.DIALOG ? 'floating-panel__drag-handle' : ''
}
/>
{selectedSpan ? (
<SpanDetailsContent
selectedSpan={selectedSpan}
traceStartTime={traceStartTime}
traceEndTime={traceEndTime}
/>
) : (
<div className="span-details-panel__body">
<Skeleton active paragraph={{ rows: 6 }} title={{ width: '60%' }} />
</div>
)}
</>
);
const analyticsPanel = (
<AnalyticsPanel
isOpen={isAnalyticsOpen}
onClose={(): void => setIsAnalyticsOpen(false)}
serviceExecTime={serviceExecTime}
traceStartTime={traceStartTime}
traceEndTime={traceEndTime}
/>
);
if (variant === SpanDetailVariant.DOCKED) {
return (
<>
<div className="span-details-panel">{content}</div>
{analyticsPanel}
</>
);
}
if (variant === SpanDetailVariant.DRAWER) {
return (
<>
<DetailsPanelDrawer
isOpen={panelState.isOpen}
onClose={panelState.close}
className="span-details-panel"
>
{content}
</DetailsPanelDrawer>
{analyticsPanel}
</>
);
}
return (
<>
<FloatingPanel
isOpen={panelState.isOpen}
className="span-details-panel"
width={PANEL_WIDTH}
height={window.innerHeight - PANEL_MARGIN_TOP - PANEL_MARGIN_BOTTOM}
defaultPosition={{
x: window.innerWidth - PANEL_WIDTH - PANEL_MARGIN_RIGHT,
y: PANEL_MARGIN_TOP,
}}
enableResizing={{
top: true,
right: true,
bottom: true,
left: true,
topRight: false,
bottomRight: false,
bottomLeft: false,
topLeft: false,
}}
>
{content}
</FloatingPanel>
{analyticsPanel}
</>
);
}
export default SpanDetailsPanel;

View File

@@ -1,257 +0,0 @@
// Badge — wraps a KeyValueLabel, clickable to toggle panel
.span-percentile-badge {
cursor: pointer;
// Override key color for the percentile value (p99)
.key-value-label__key {
color: var(--text-sakura-400, #f56c87);
}
&__loader {
display: inline-flex;
align-items: center;
padding: 2px 4px;
}
&__value {
display: inline-flex;
align-items: center;
gap: 4px;
}
&__icon {
flex-shrink: 0;
color: var(--l2-foreground);
}
}
// Panel — collapsible, renders below the row
.span-percentile-panel {
display: flex;
flex-direction: column;
position: relative;
border: 1px solid var(--l1-border);
border-radius: 4px;
filter: drop-shadow(2px 4px 16px rgba(0, 0, 0, 0.2));
backdrop-filter: blur(20px);
margin: 8px 16px;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--l1-border);
&-text {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
&-icon {
color: var(--l2-foreground);
}
}
&__content {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
&-title {
font-size: var(--font-size-sm);
line-height: var(--line-height-20);
}
&-highlight {
color: var(--text-sakura-400, #f56c87);
}
&-loader {
display: inline-flex;
align-items: flex-end;
margin: 0 4px;
line-height: 18px;
}
}
&__timerange {
width: 100%;
&-select {
width: 100%;
margin-top: 8px;
margin-bottom: 16px;
.ant-select-selector {
border-radius: 50px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
color: var(--l1-foreground);
font-size: 12px;
height: 32px;
}
}
}
&__table {
&-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
&-text {
color: var(--l1-foreground);
font-size: 11px;
font-weight: 500;
line-height: 20px;
text-transform: uppercase;
}
}
&-rows {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
&-skeleton {
.ant-skeleton-title {
width: 100% !important;
margin-top: 0 !important;
}
.ant-skeleton-paragraph {
margin-top: 8px;
& > li + li {
margin-top: 10px;
width: 100% !important;
}
}
}
&-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 4px;
&-key {
flex: 0 0 auto;
color: var(--l1-foreground);
font-size: 12px;
font-weight: 500;
line-height: 20px;
}
&-value {
color: var(--l2-foreground);
font-size: 12px;
line-height: 20px;
}
&-dash {
flex: 1;
height: 0;
margin: 0 8px;
border-top: 1px solid transparent;
border-image: repeating-linear-gradient(
to right,
var(--l1-border) 0,
var(--l1-border) 10px,
transparent 10px,
transparent 20px
)
1 stretch;
}
&--current {
border-radius: 2px;
background: rgba(78, 116, 248, 0.2);
.span-percentile-panel__table-row-key {
color: var(--text-robin-300);
}
.span-percentile-panel__table-row-dash {
border-image: repeating-linear-gradient(
to right,
#abbdff 0,
#abbdff 10px,
transparent 10px,
transparent 20px
)
1 stretch;
}
.span-percentile-panel__table-row-value {
color: var(--text-robin-400);
}
}
}
}
&__resource-selector {
overflow: hidden;
width: calc(100% + 16px);
position: absolute;
top: 32px;
left: -8px;
z-index: 1000;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
&-header {
border-bottom: 1px solid var(--l1-border);
}
&-input {
border-radius: 0;
border: none !important;
box-shadow: none !important;
height: 36px;
}
&-items {
height: 200px;
overflow-y: auto;
&::-webkit-scrollbar {
width: 0.3rem;
height: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--l3-background);
}
}
&-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
&-value {
color: var(--l1-foreground);
font-size: 13px;
line-height: 20px;
}
}
}
}

View File

@@ -1,506 +0,0 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery } from 'react-query';
import { Checkbox, Input, Select, Skeleton, Tooltip, Typography } from 'antd';
import getSpanPercentiles from 'api/trace/getSpanPercentiles';
import getUserPreference from 'api/v1/user/preferences/name/get';
import updateUserPreference from 'api/v1/user/preferences/name/update';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { USER_PREFERENCES } from 'constants/userPreferences';
import dayjs from 'dayjs';
import useClickOutside from 'hooks/useClickOutside';
import { Check, ChevronDown, ChevronUp, Loader2, PlusIcon } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { Span } from 'types/api/trace/getTraceV2';
import './SpanPercentile.styles.scss';
interface IResourceAttribute {
key: string;
value: string;
isSelected: boolean;
}
const DEFAULT_RESOURCE_ATTRIBUTES = {
serviceName: 'service.name',
name: 'name',
};
const timerangeOptions = [1, 2, 4, 6, 12, 24].map((hours) => ({
label: `${hours}h`,
value: hours,
}));
interface SpanPercentileProps {
selectedSpan: Span;
}
function SpanPercentile({ selectedSpan }: SpanPercentileProps): JSX.Element {
const [isOpen, setIsOpen] = useState(false);
const [selectedTimeRange, setSelectedTimeRange] = useState(1);
const [
resourceAttributesSearchQuery,
setResourceAttributesSearchQuery,
] = useState('');
const [spanPercentileData, setSpanPercentileData] = useState<{
percentile: number;
description: string;
percentiles: Record<string, number>;
} | null>(null);
const [
showResourceAttributesSelector,
setShowResourceAttributesSelector,
] = useState(false);
const [selectedResourceAttributes, setSelectedResourceAttributes] = useState<
Record<string, string>
>({});
const [spanResourceAttributes, updateSpanResourceAttributes] = useState<
IResourceAttribute[]
>([]);
const [initialWaitCompleted, setInitialWaitCompleted] = useState(false);
const [shouldFetchData, setShouldFetchData] = useState(false);
const [shouldUpdateUserPreference, setShouldUpdateUserPreference] = useState(
false,
);
const resourceAttributesSelectorRef = useRef<HTMLDivElement | null>(null);
useClickOutside({
ref: resourceAttributesSelectorRef,
onClickOutside: () => {
if (resourceAttributesSelectorRef.current) {
setShowResourceAttributesSelector(false);
}
},
eventType: 'mousedown',
});
const endTime = useMemo(
() => Math.floor(Number(selectedSpan.timestamp) / 1000) * 1000,
[selectedSpan.timestamp],
);
const startTime = useMemo(
() =>
dayjs(selectedSpan.timestamp)
.subtract(Number(selectedTimeRange), 'hour')
.unix() * 1000,
[selectedSpan.timestamp, selectedTimeRange],
);
const { mutate: updateUserPreferenceMutation } = useMutation(
updateUserPreference,
);
const {
data: userSelectedResourceAttributes,
isError: isErrorUserSelectedResourceAttributes,
} = useQuery({
queryFn: () =>
getUserPreference({
name: USER_PREFERENCES.SPAN_PERCENTILE_RESOURCE_ATTRIBUTES,
}),
queryKey: [
'getUserPreferenceByPreferenceName',
USER_PREFERENCES.SPAN_PERCENTILE_RESOURCE_ATTRIBUTES,
selectedSpan.spanId,
],
enabled: selectedSpan.tagMap !== undefined,
});
const {
isLoading: isLoadingData,
isFetching: isFetchingData,
data,
refetch: refetchData,
isError: isErrorData,
} = useQuery({
queryFn: () =>
getSpanPercentiles({
start: startTime || 0,
end: endTime || 0,
spanDuration: selectedSpan.durationNano || 0,
serviceName: selectedSpan.serviceName || '',
name: selectedSpan.name || '',
resourceAttributes: selectedResourceAttributes,
}),
queryKey: [
REACT_QUERY_KEY.GET_SPAN_PERCENTILES,
selectedSpan.spanId,
startTime,
endTime,
],
enabled:
shouldFetchData && !showResourceAttributesSelector && initialWaitCompleted,
onSuccess: (response) => {
if (response.httpStatusCode !== 200) {
return;
}
if (shouldUpdateUserPreference) {
updateUserPreferenceMutation({
name: USER_PREFERENCES.SPAN_PERCENTILE_RESOURCE_ATTRIBUTES,
value: [...Object.keys(selectedResourceAttributes)],
});
setShouldUpdateUserPreference(false);
}
},
keepPreviousData: false,
cacheTime: 0,
});
// 2-second delay before initial fetch
useEffect(() => {
setSpanPercentileData(null);
setIsOpen(false);
setInitialWaitCompleted(false);
const timer = setTimeout(() => {
setInitialWaitCompleted(true);
}, 2000);
return (): void => {
clearTimeout(timer);
};
}, [selectedSpan.spanId]);
useEffect(() => {
if (data?.httpStatusCode !== 200) {
setSpanPercentileData(null);
return;
}
if (data) {
setSpanPercentileData({
percentile: data.data?.position?.percentile || 0,
description: data.data?.position?.description || '',
percentiles: data.data?.percentiles || {},
});
}
}, [data]);
useEffect(() => {
if (userSelectedResourceAttributes) {
const userList = (userSelectedResourceAttributes?.data
?.value as string[]).map((attr: string) => attr);
let selectedMap: Record<string, string> = {};
userList.forEach((attr: string) => {
selectedMap[attr] = selectedSpan.tagMap?.[attr] || '';
});
selectedMap = Object.fromEntries(
Object.entries(selectedMap).filter(
([key]) => selectedSpan.tagMap?.[key] !== undefined,
),
);
const resourceAttrs = Object.entries(selectedSpan.tagMap || {}).map(
([key, value]) => ({
key,
value,
isSelected:
key === DEFAULT_RESOURCE_ATTRIBUTES.serviceName ||
key === DEFAULT_RESOURCE_ATTRIBUTES.name ||
(key in selectedMap &&
selectedMap[key] !== '' &&
selectedMap[key] !== undefined),
}),
);
const selected = resourceAttrs.filter((a) => a.isSelected);
const unselected = resourceAttrs.filter((a) => !a.isSelected);
updateSpanResourceAttributes([...selected, ...unselected]);
setSelectedResourceAttributes(selectedMap);
setShouldFetchData(true);
}
if (isErrorUserSelectedResourceAttributes) {
const resourceAttrs = Object.entries(selectedSpan.tagMap || {}).map(
([key, value]) => ({
key,
value,
isSelected:
key === DEFAULT_RESOURCE_ATTRIBUTES.serviceName ||
key === DEFAULT_RESOURCE_ATTRIBUTES.name,
}),
);
updateSpanResourceAttributes(resourceAttrs);
setShouldFetchData(true);
}
}, [
userSelectedResourceAttributes,
isErrorUserSelectedResourceAttributes,
selectedSpan.tagMap,
]);
const handleResourceAttributeChange = useCallback(
(key: string, value: string, isSelected: boolean): void => {
updateSpanResourceAttributes((prev) =>
prev.map((attr) => (attr.key === key ? { ...attr, isSelected } : attr)),
);
const newSelected = { ...selectedResourceAttributes };
if (isSelected) {
newSelected[key] = value;
} else {
delete newSelected[key];
}
setSelectedResourceAttributes(newSelected);
setShouldFetchData(true);
setShouldUpdateUserPreference(true);
},
[selectedResourceAttributes],
);
useEffect(() => {
if (
shouldFetchData &&
!showResourceAttributesSelector &&
initialWaitCompleted
) {
refetchData();
setShouldFetchData(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shouldFetchData, showResourceAttributesSelector, initialWaitCompleted]);
const loading = isLoadingData || isFetchingData;
const percentileValue = Math.floor(spanPercentileData?.percentile || 0);
const tooltipText = useMemo(
() => (
<div className="span-percentile__tooltip-text">
<Typography.Text>
This span duration is{' '}
<span className="span-percentile__tooltip-highlight">
p{percentileValue}
</span>{' '}
out of the distribution for this resource evaluated for {selectedTimeRange}{' '}
hour(s) since the span start time.
</Typography.Text>
<br />
<br />
<Typography.Text className="span-percentile__tooltip-link">
Click to learn more
</Typography.Text>
</div>
),
[percentileValue, selectedTimeRange],
);
return (
<div className="span-percentile">
{/* Badge */}
{loading && (
<div className="span-percentile__loader">
<Loader2 size={16} className="animate-spin" />
</div>
)}
{!loading && spanPercentileData && (
<Tooltip
title={isOpen ? '' : tooltipText}
placement="bottomRight"
overlayClassName="span-percentile__tooltip"
arrow={false}
>
<div
className={`span-percentile__badge ${
isOpen ? 'span-percentile__badge--open' : ''
}`}
>
<Typography.Text
className="span-percentile__badge-text"
onClick={(): void => setIsOpen((prev) => !prev)}
>
<span>p{percentileValue}</span>
{isOpen ? (
<ChevronUp size={16} className="span-percentile__badge-icon" />
) : (
<ChevronDown size={16} className="span-percentile__badge-icon" />
)}
</Typography.Text>
</div>
</Tooltip>
)}
{/* Collapsible panel */}
<AnimatePresence initial={false}>
{isOpen && !isErrorData && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
key="span-percentile-panel"
>
<div className="span-percentile__panel">
<div className="span-percentile__panel-header">
<Typography.Text
className="span-percentile__panel-header-text"
onClick={(): void => setIsOpen((prev) => !prev)}
>
<ChevronDown size={16} /> Span Percentile
</Typography.Text>
{showResourceAttributesSelector ? (
<Check
size={16}
className="cursor-pointer span-percentile__panel-header-icon"
onClick={(): void => setShowResourceAttributesSelector(false)}
/>
) : (
<PlusIcon
size={16}
className="cursor-pointer span-percentile__panel-header-icon"
onClick={(): void => setShowResourceAttributesSelector(true)}
/>
)}
</div>
{showResourceAttributesSelector && (
<div
className="span-percentile__resource-selector"
ref={resourceAttributesSelectorRef}
>
<div className="span-percentile__resource-selector-header">
<Input
placeholder="Search resource attributes"
className="span-percentile__resource-selector-input"
value={resourceAttributesSearchQuery}
onChange={(e): void =>
setResourceAttributesSearchQuery(e.target.value as string)
}
/>
</div>
<div className="span-percentile__resource-selector-items">
{spanResourceAttributes
.filter((attr) =>
attr.key
.toLowerCase()
.includes(resourceAttributesSearchQuery.toLowerCase()),
)
.map((attr) => (
<div
className="span-percentile__resource-selector-item"
key={attr.key}
>
<Checkbox
checked={attr.isSelected}
onChange={(e): void => {
handleResourceAttributeChange(
attr.key,
attr.value,
e.target.checked,
);
}}
disabled={
attr.key === DEFAULT_RESOURCE_ATTRIBUTES.serviceName ||
attr.key === DEFAULT_RESOURCE_ATTRIBUTES.name
}
>
<div className="span-percentile__resource-selector-item-value">
{attr.key}
</div>
</Checkbox>
</div>
))}
</div>
</div>
)}
<div className="span-percentile__content">
<Typography.Text className="span-percentile__content-title">
This span duration is{' '}
{!loading && spanPercentileData ? (
<span className="span-percentile__content-highlight">
p{Math.floor(spanPercentileData.percentile || 0)}
</span>
) : (
<span className="span-percentile__content-loader">
<Loader2 size={12} className="animate-spin" />
</span>
)}{' '}
out of the distribution for this resource evaluated for{' '}
{selectedTimeRange} hour(s) since the span start time.
</Typography.Text>
<div className="span-percentile__timerange">
<Select
labelInValue
placeholder="Select timerange"
className="span-percentile__timerange-select"
value={{
label: `${selectedTimeRange}h : ${dayjs(selectedSpan.timestamp)
.subtract(selectedTimeRange, 'hour')
.format(DATE_TIME_FORMATS.TIME_SPAN_PERCENTILE)} - ${dayjs(
selectedSpan.timestamp,
).format(DATE_TIME_FORMATS.TIME_SPAN_PERCENTILE)}`,
value: selectedTimeRange,
}}
onChange={(value): void => {
setShouldFetchData(true);
setSelectedTimeRange(Number(value.value));
}}
options={timerangeOptions}
/>
</div>
<div className="span-percentile__table">
<div className="span-percentile__table-header">
<Typography.Text className="span-percentile__table-header-text">
Percentile
</Typography.Text>
<Typography.Text className="span-percentile__table-header-text">
Duration
</Typography.Text>
</div>
<div className="span-percentile__table-rows">
{isLoadingData || isFetchingData ? (
<Skeleton
active
paragraph={{ rows: 3 }}
className="span-percentile__table-skeleton"
/>
) : (
<>
{Object.entries(spanPercentileData?.percentiles || {}).map(
([percentile, duration]) => (
<div className="span-percentile__table-row" key={percentile}>
<Typography.Text className="span-percentile__table-row-key">
{percentile}
</Typography.Text>
<div className="span-percentile__table-row-dash" />
<Typography.Text className="span-percentile__table-row-value">
{getYAxisFormattedValue(`${duration / 1000000}`, 'ms')}
</Typography.Text>
</div>
),
)}
<div className="span-percentile__table-row span-percentile__table-row--current">
<Typography.Text className="span-percentile__table-row-key">
p{Math.floor(spanPercentileData?.percentile || 0)}
</Typography.Text>
<div className="span-percentile__table-row-dash" />
<Typography.Text className="span-percentile__table-row-value">
(this span){' '}
{getYAxisFormattedValue(
`${selectedSpan.durationNano / 1000000}`,
'ms',
)}
</Typography.Text>
</div>
</>
)}
</div>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
export default SpanPercentile;

View File

@@ -1,67 +0,0 @@
import { ChevronDown, ChevronUp, Loader2 } from 'lucide-react';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { UseSpanPercentileReturn } from './useSpanPercentile';
import './SpanPercentile.styles.scss';
type SpanPercentileBadgeProps = Pick<
UseSpanPercentileReturn,
| 'loading'
| 'percentileValue'
| 'duration'
| 'spanPercentileData'
| 'isOpen'
| 'toggleOpen'
>;
function SpanPercentileBadge({
loading,
percentileValue,
duration,
spanPercentileData,
isOpen,
toggleOpen,
}: SpanPercentileBadgeProps): JSX.Element | null {
if (loading) {
return (
<div className="span-percentile-badge__loader">
<Loader2 size={14} className="animate-spin" />
</div>
);
}
if (!spanPercentileData) {
return null;
}
return (
<div
className="span-percentile-badge"
onClick={toggleOpen}
role="button"
tabIndex={0}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {
toggleOpen();
}
}}
>
<KeyValueLabel
badgeKey={`p${percentileValue}`}
badgeValue={
<span className="span-percentile-badge__value">
{duration}
{isOpen ? (
<ChevronUp size={14} className="span-percentile-badge__icon" />
) : (
<ChevronDown size={14} className="span-percentile-badge__icon" />
)}
</span>
}
/>
</div>
);
}
export default SpanPercentileBadge;

View File

@@ -1,224 +0,0 @@
import { Checkbox, Input, Select, Skeleton, Typography } from 'antd';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { Check, ChevronDown, Loader2, PlusIcon } from 'lucide-react';
import { Span } from 'types/api/trace/getTraceV2';
import { UseSpanPercentileReturn } from './useSpanPercentile';
import './SpanPercentile.styles.scss';
const DEFAULT_RESOURCE_ATTRIBUTES = {
serviceName: 'service.name',
name: 'name',
};
const timerangeOptions = [1, 2, 4, 6, 12, 24].map((hours) => ({
label: `${hours}h`,
value: hours,
}));
interface SpanPercentilePanelProps {
selectedSpan: Span;
percentile: UseSpanPercentileReturn;
}
function SpanPercentilePanel({
selectedSpan,
percentile,
}: SpanPercentilePanelProps): JSX.Element | null {
const {
isOpen,
toggleOpen,
isError,
loading,
spanPercentileData,
selectedTimeRange,
setSelectedTimeRange,
showResourceAttributesSelector,
setShowResourceAttributesSelector,
resourceAttributesSearchQuery,
setResourceAttributesSearchQuery,
spanResourceAttributes,
handleResourceAttributeChange,
resourceAttributesSelectorRef,
isLoadingData,
isFetchingData,
} = percentile;
if (!isOpen || isError) {
return null;
}
return (
<div className="span-percentile-panel">
<div className="span-percentile-panel__header">
<Typography.Text
className="span-percentile-panel__header-text"
onClick={toggleOpen}
>
<ChevronDown size={16} /> Span Percentile
</Typography.Text>
{showResourceAttributesSelector ? (
<Check
size={16}
className="cursor-pointer span-percentile-panel__header-icon"
onClick={(): void => setShowResourceAttributesSelector(false)}
/>
) : (
<PlusIcon
size={16}
className="cursor-pointer span-percentile-panel__header-icon"
onClick={(): void => setShowResourceAttributesSelector(true)}
/>
)}
</div>
{showResourceAttributesSelector && (
<div
className="span-percentile-panel__resource-selector"
ref={resourceAttributesSelectorRef}
>
<div className="span-percentile-panel__resource-selector-header">
<Input
placeholder="Search resource attributes"
className="span-percentile-panel__resource-selector-input"
value={resourceAttributesSearchQuery}
onChange={(e): void =>
setResourceAttributesSearchQuery(e.target.value as string)
}
/>
</div>
<div className="span-percentile-panel__resource-selector-items">
{spanResourceAttributes
.filter((attr) =>
attr.key
.toLowerCase()
.includes(resourceAttributesSearchQuery.toLowerCase()),
)
.map((attr) => (
<div
className="span-percentile-panel__resource-selector-item"
key={attr.key}
>
<Checkbox
checked={attr.isSelected}
onChange={(e): void => {
handleResourceAttributeChange(
attr.key,
attr.value,
e.target.checked,
);
}}
disabled={
attr.key === DEFAULT_RESOURCE_ATTRIBUTES.serviceName ||
attr.key === DEFAULT_RESOURCE_ATTRIBUTES.name
}
>
<div className="span-percentile-panel__resource-selector-item-value">
{attr.key}
</div>
</Checkbox>
</div>
))}
</div>
</div>
)}
<div className="span-percentile-panel__content">
<Typography.Text className="span-percentile-panel__content-title">
This span duration is{' '}
{!loading && spanPercentileData ? (
<span className="span-percentile-panel__content-highlight">
p{Math.floor(spanPercentileData.percentile || 0)}
</span>
) : (
<span className="span-percentile-panel__content-loader">
<Loader2 size={12} className="animate-spin" />
</span>
)}{' '}
out of the distribution for this resource evaluated for {selectedTimeRange}{' '}
hour(s) since the span start time.
</Typography.Text>
<div className="span-percentile-panel__timerange">
<Select
labelInValue
placeholder="Select timerange"
className="span-percentile-panel__timerange-select"
getPopupContainer={(trigger): HTMLElement =>
trigger.parentElement || document.body
}
value={{
label: `${selectedTimeRange}h : ${dayjs(selectedSpan.timestamp)
.subtract(selectedTimeRange, 'hour')
.format(DATE_TIME_FORMATS.TIME_SPAN_PERCENTILE)} - ${dayjs(
selectedSpan.timestamp,
).format(DATE_TIME_FORMATS.TIME_SPAN_PERCENTILE)}`,
value: selectedTimeRange,
}}
onChange={(value): void => {
setSelectedTimeRange(Number(value.value));
}}
options={timerangeOptions}
/>
</div>
<div className="span-percentile-panel__table">
<div className="span-percentile-panel__table-header">
<Typography.Text className="span-percentile-panel__table-header-text">
Percentile
</Typography.Text>
<Typography.Text className="span-percentile-panel__table-header-text">
Duration
</Typography.Text>
</div>
<div className="span-percentile-panel__table-rows">
{isLoadingData || isFetchingData ? (
<Skeleton
active
paragraph={{ rows: 3 }}
className="span-percentile-panel__table-skeleton"
/>
) : (
<>
{Object.entries(spanPercentileData?.percentiles || {}).map(
([pKey, pDuration]) => (
<div className="span-percentile-panel__table-row" key={pKey}>
<Typography.Text className="span-percentile-panel__table-row-key">
{pKey}
</Typography.Text>
<div className="span-percentile-panel__table-row-dash" />
<Typography.Text className="span-percentile-panel__table-row-value">
{getYAxisFormattedValue(`${pDuration / 1000000}`, 'ms')}
</Typography.Text>
</div>
),
)}
<div className="span-percentile-panel__table-row span-percentile-panel__table-row--current">
<Typography.Text className="span-percentile-panel__table-row-key">
p{Math.floor(spanPercentileData?.percentile || 0)}
</Typography.Text>
<div className="span-percentile-panel__table-row-dash" />
<Typography.Text className="span-percentile-panel__table-row-value">
(this span){' '}
{getYAxisFormattedValue(
`${selectedSpan.durationNano / 1000000}`,
'ms',
)}
</Typography.Text>
</div>
</>
)}
</div>
</div>
</div>
</div>
);
}
export default SpanPercentilePanel;

View File

@@ -1,318 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery } from 'react-query';
import getSpanPercentiles from 'api/trace/getSpanPercentiles';
import getUserPreference from 'api/v1/user/preferences/name/get';
import updateUserPreference from 'api/v1/user/preferences/name/update';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { USER_PREFERENCES } from 'constants/userPreferences';
import dayjs from 'dayjs';
import useClickOutside from 'hooks/useClickOutside';
import { Span } from 'types/api/trace/getTraceV2';
export interface IResourceAttribute {
key: string;
value: string;
isSelected: boolean;
}
const DEFAULT_RESOURCE_ATTRIBUTES = {
serviceName: 'service.name',
name: 'name',
};
export interface UseSpanPercentileReturn {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
toggleOpen: () => void;
loading: boolean;
percentileValue: number;
duration: string;
spanPercentileData: {
percentile: number;
description: string;
percentiles: Record<string, number>;
} | null;
isError: boolean;
selectedTimeRange: number;
setSelectedTimeRange: (range: number) => void;
showResourceAttributesSelector: boolean;
setShowResourceAttributesSelector: (show: boolean) => void;
resourceAttributesSearchQuery: string;
setResourceAttributesSearchQuery: (query: string) => void;
spanResourceAttributes: IResourceAttribute[];
handleResourceAttributeChange: (
key: string,
value: string,
isSelected: boolean,
) => void;
resourceAttributesSelectorRef: React.MutableRefObject<HTMLDivElement | null>;
isLoadingData: boolean;
isFetchingData: boolean;
}
function useSpanPercentile(selectedSpan: Span): UseSpanPercentileReturn {
const [isOpen, setIsOpen] = useState(false);
const [selectedTimeRange, setSelectedTimeRange] = useState(1);
const [
resourceAttributesSearchQuery,
setResourceAttributesSearchQuery,
] = useState('');
const [spanPercentileData, setSpanPercentileData] = useState<{
percentile: number;
description: string;
percentiles: Record<string, number>;
} | null>(null);
const [
showResourceAttributesSelector,
setShowResourceAttributesSelector,
] = useState(false);
const [selectedResourceAttributes, setSelectedResourceAttributes] = useState<
Record<string, string>
>({});
const [spanResourceAttributes, updateSpanResourceAttributes] = useState<
IResourceAttribute[]
>([]);
const [initialWaitCompleted, setInitialWaitCompleted] = useState(false);
const [shouldFetchData, setShouldFetchData] = useState(false);
const [shouldUpdateUserPreference, setShouldUpdateUserPreference] = useState(
false,
);
const resourceAttributesSelectorRef = useRef<HTMLDivElement | null>(null);
useClickOutside({
ref: resourceAttributesSelectorRef,
onClickOutside: () => {
if (resourceAttributesSelectorRef.current) {
setShowResourceAttributesSelector(false);
}
},
eventType: 'mousedown',
});
const endTime = useMemo(
() => Math.floor(Number(selectedSpan.timestamp) / 1000) * 1000,
[selectedSpan.timestamp],
);
const startTime = useMemo(
() =>
dayjs(selectedSpan.timestamp)
.subtract(Number(selectedTimeRange), 'hour')
.unix() * 1000,
[selectedSpan.timestamp, selectedTimeRange],
);
const { mutate: updateUserPreferenceMutation } = useMutation(
updateUserPreference,
);
const {
data: userSelectedResourceAttributes,
isError: isErrorUserSelectedResourceAttributes,
} = useQuery({
queryFn: () =>
getUserPreference({
name: USER_PREFERENCES.SPAN_PERCENTILE_RESOURCE_ATTRIBUTES,
}),
queryKey: [
'getUserPreferenceByPreferenceName',
USER_PREFERENCES.SPAN_PERCENTILE_RESOURCE_ATTRIBUTES,
selectedSpan.spanId,
],
enabled: selectedSpan.tagMap !== undefined,
});
const {
isLoading: isLoadingData,
isFetching: isFetchingData,
data,
refetch: refetchData,
isError: isErrorData,
} = useQuery({
queryFn: () =>
getSpanPercentiles({
start: startTime || 0,
end: endTime || 0,
spanDuration: selectedSpan.durationNano || 0,
serviceName: selectedSpan.serviceName || '',
name: selectedSpan.name || '',
resourceAttributes: selectedResourceAttributes,
}),
queryKey: [
REACT_QUERY_KEY.GET_SPAN_PERCENTILES,
selectedSpan.spanId,
startTime,
endTime,
],
enabled:
shouldFetchData && !showResourceAttributesSelector && initialWaitCompleted,
onSuccess: (response) => {
if (response.httpStatusCode !== 200) {
return;
}
if (shouldUpdateUserPreference) {
updateUserPreferenceMutation({
name: USER_PREFERENCES.SPAN_PERCENTILE_RESOURCE_ATTRIBUTES,
value: [...Object.keys(selectedResourceAttributes)],
});
setShouldUpdateUserPreference(false);
}
},
keepPreviousData: false,
cacheTime: 0,
});
// 2-second delay before initial fetch
useEffect(() => {
setSpanPercentileData(null);
setIsOpen(false);
setInitialWaitCompleted(false);
const timer = setTimeout(() => {
setInitialWaitCompleted(true);
}, 2000);
return (): void => {
clearTimeout(timer);
};
}, [selectedSpan.spanId]);
useEffect(() => {
if (data?.httpStatusCode !== 200) {
setSpanPercentileData(null);
return;
}
if (data) {
setSpanPercentileData({
percentile: data.data?.position?.percentile || 0,
description: data.data?.position?.description || '',
percentiles: data.data?.percentiles || {},
});
}
}, [data]);
useEffect(() => {
if (userSelectedResourceAttributes) {
const userList = (userSelectedResourceAttributes?.data
?.value as string[]).map((attr: string) => attr);
let selectedMap: Record<string, string> = {};
userList.forEach((attr: string) => {
selectedMap[attr] = selectedSpan.tagMap?.[attr] || '';
});
selectedMap = Object.fromEntries(
Object.entries(selectedMap).filter(
([key]) => selectedSpan.tagMap?.[key] !== undefined,
),
);
const resourceAttrs = Object.entries(selectedSpan.tagMap || {}).map(
([key, value]) => ({
key,
value,
isSelected:
key === DEFAULT_RESOURCE_ATTRIBUTES.serviceName ||
key === DEFAULT_RESOURCE_ATTRIBUTES.name ||
(key in selectedMap &&
selectedMap[key] !== '' &&
selectedMap[key] !== undefined),
}),
);
const selected = resourceAttrs.filter((a) => a.isSelected);
const unselected = resourceAttrs.filter((a) => !a.isSelected);
updateSpanResourceAttributes([...selected, ...unselected]);
setSelectedResourceAttributes(selectedMap);
setShouldFetchData(true);
}
if (isErrorUserSelectedResourceAttributes) {
const resourceAttrs = Object.entries(selectedSpan.tagMap || {}).map(
([key, value]) => ({
key,
value,
isSelected:
key === DEFAULT_RESOURCE_ATTRIBUTES.serviceName ||
key === DEFAULT_RESOURCE_ATTRIBUTES.name,
}),
);
updateSpanResourceAttributes(resourceAttrs);
setShouldFetchData(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
userSelectedResourceAttributes,
isErrorUserSelectedResourceAttributes,
selectedSpan.tagMap,
]);
const handleResourceAttributeChange = useCallback(
(key: string, value: string, isSelected: boolean): void => {
updateSpanResourceAttributes((prev) =>
prev.map((attr) => (attr.key === key ? { ...attr, isSelected } : attr)),
);
const newSelected = { ...selectedResourceAttributes };
if (isSelected) {
newSelected[key] = value;
} else {
delete newSelected[key];
}
setSelectedResourceAttributes(newSelected);
setShouldFetchData(true);
setShouldUpdateUserPreference(true);
},
[selectedResourceAttributes],
);
useEffect(() => {
if (
shouldFetchData &&
!showResourceAttributesSelector &&
initialWaitCompleted
) {
refetchData();
setShouldFetchData(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shouldFetchData, showResourceAttributesSelector, initialWaitCompleted]);
const loading = isLoadingData || isFetchingData;
const percentileValue = Math.floor(spanPercentileData?.percentile || 0);
const duration = getYAxisFormattedValue(
`${selectedSpan.durationNano / 1000000}`,
'ms',
);
const toggleOpen = useCallback(() => setIsOpen((prev) => !prev), []);
const handleTimeRangeChange = useCallback((range: number): void => {
setShouldFetchData(true);
setSelectedTimeRange(range);
}, []);
return {
isOpen,
setIsOpen,
toggleOpen,
loading,
percentileValue,
duration,
spanPercentileData,
isError: isErrorData,
selectedTimeRange,
setSelectedTimeRange: handleTimeRangeChange,
showResourceAttributesSelector,
setShowResourceAttributesSelector,
resourceAttributesSearchQuery,
setResourceAttributesSearchQuery,
spanResourceAttributes,
handleResourceAttributeChange,
resourceAttributesSelectorRef,
isLoadingData,
isFetchingData,
};
}
export default useSpanPercentile;

View File

@@ -1,54 +0,0 @@
import { ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { Badge } from '@signozhq/badge';
import { Span } from 'types/api/trace/getTraceV2';
interface HighlightedOption {
key: string;
label: string;
render: (span: Span) => ReactNode | null;
}
export const HIGHLIGHTED_OPTIONS: HighlightedOption[] = [
{
key: 'service',
label: 'SERVICE',
render: (span): ReactNode | null =>
span.serviceName ? (
<Badge color="vanilla">
<span className="span-details-panel__service-dot" />
{span.serviceName}
</Badge>
) : null,
},
{
key: 'statusCodeString',
label: 'STATUS CODE STRING',
render: (span): ReactNode | null =>
span.statusCodeString ? (
<Badge color="vanilla">{span.statusCodeString}</Badge>
) : null,
},
{
key: 'traceId',
label: 'TRACE ID',
render: (span): ReactNode | null =>
span.traceId ? (
<Link
to={{
pathname: `/trace/${span.traceId}`,
search: window.location.search,
}}
className="span-details-panel__trace-id"
>
{span.traceId}
</Link>
) : null,
},
{
key: 'spanKind',
label: 'SPAN KIND',
render: (span): ReactNode | null =>
span.spanKind ? <Badge color="vanilla">{span.spanKind}</Badge> : null,
},
];

View File

@@ -1,25 +0,0 @@
export enum SpanDetailVariant {
DRAWER = 'drawer',
DIALOG = 'dialog',
DOCKED = 'docked',
}
export const KEY_ATTRIBUTE_KEYS: Record<string, string[]> = {
traces: [
'service.name',
'service.namespace',
'deployment.environment',
'datetime',
'duration',
'span.kind',
'status_code_string',
'http_method',
'http_url',
'http_host',
'db_name',
'db_operation',
'external_http_method',
'external_http_url',
'response_status_code',
],
};

View File

@@ -1,60 +0,0 @@
.event-tooltip-content {
font-family: Inter, sans-serif;
font-size: 12px;
color: #fff;
max-width: 300px;
&__header {
display: inline-flex;
align-items: center;
gap: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
padding: 2px 6px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 6px;
}
&__name {
font-weight: 600;
margin-bottom: 2px;
color: rgb(14, 165, 233);
&.error {
color: rgb(239, 68, 68);
}
}
&__time {
font-size: 11px;
opacity: 0.8;
margin-bottom: 4px;
}
&__divider {
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin: 6px 0;
}
&__attributes {
font-size: 11px;
}
&__kv {
margin-bottom: 2px;
line-height: 1.4;
word-break: break-all;
}
&__key {
opacity: 0.6;
}
&__value {
opacity: 0.9;
}
}

View File

@@ -1,49 +0,0 @@
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import { Diamond } from 'lucide-react';
import { toFixed } from 'utils/toFixed';
import './EventTooltipContent.styles.scss';
export interface EventTooltipContentProps {
eventName: string;
timeOffsetMs: number;
isError: boolean;
attributeMap: Record<string, string>;
}
export function EventTooltipContent({
eventName,
timeOffsetMs,
isError,
attributeMap,
}: EventTooltipContentProps): JSX.Element {
const { time, timeUnitName } = convertTimeToRelevantUnit(timeOffsetMs);
return (
<div className="event-tooltip-content">
<div className="event-tooltip-content__header">
<Diamond size={10} />
<span>EVENT DETAILS</span>
</div>
<div className={`event-tooltip-content__name ${isError ? 'error' : ''}`}>
{eventName}
</div>
<div className="event-tooltip-content__time">
{toFixed(time, 2)} {timeUnitName} from start
</div>
{Object.keys(attributeMap).length > 0 && (
<>
<div className="event-tooltip-content__divider" />
<div className="event-tooltip-content__attributes">
{Object.entries(attributeMap).map(([key, value]) => (
<div key={key} className="event-tooltip-content__kv">
<span className="event-tooltip-content__key">{key}:</span>{' '}
<span className="event-tooltip-content__value">{value}</span>
</div>
))}
</div>
</>
)}
</div>
);
}

View File

@@ -1,28 +0,0 @@
.span-hover-card-popover {
.ant-popover-inner {
background-color: rgba(30, 30, 30, 0.95);
padding: 8px 12px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: none;
}
.ant-popover-inner-content {
padding: 0;
}
}
.span-hover-card-content {
font-family: Inter, sans-serif;
font-size: 12px;
color: #fff;
&__name {
font-weight: 600;
margin-bottom: 4px;
}
&__row {
line-height: 1.5;
}
}

View File

@@ -1,94 +0,0 @@
import { ReactNode } from 'react';
import { Popover } from 'antd';
import { themeColors } from 'constants/theme';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { Span } from 'types/api/trace/getTraceV2';
import { toFixed } from 'utils/toFixed';
import './SpanHoverCard.styles.scss';
interface ITraceMetadata {
startTime: number;
endTime: number;
}
export interface SpanTooltipContentProps {
spanName: string;
color: string;
hasError: boolean;
relativeStartMs: number;
durationMs: number;
}
export function SpanTooltipContent({
spanName,
color,
hasError,
relativeStartMs,
durationMs,
}: SpanTooltipContentProps): JSX.Element {
const { time: formattedDuration, timeUnitName } = convertTimeToRelevantUnit(
durationMs,
);
return (
<div className="span-hover-card-content">
<div className="span-hover-card-content__name" style={{ color }}>
{spanName}
</div>
<div className="span-hover-card-content__row">
Status: {hasError ? 'error' : 'ok'}
</div>
<div className="span-hover-card-content__row">
Start: {toFixed(relativeStartMs, 2)} ms
</div>
<div className="span-hover-card-content__row">
Duration: {toFixed(formattedDuration, 2)} {timeUnitName}
</div>
</div>
);
}
interface SpanHoverCardProps {
span: Span;
traceMetadata: ITraceMetadata;
children: ReactNode;
}
function SpanHoverCard({
span,
traceMetadata,
children,
}: SpanHoverCardProps): JSX.Element {
const durationMs = span.durationNano / 1e6;
const relativeStartMs = span.timestamp - traceMetadata.startTime;
let color = generateColor(span.serviceName, themeColors.traceDetailColorsV3);
if (span.hasError) {
color = 'var(--bg-cherry-500)';
}
return (
<Popover
mouseEnterDelay={0.2}
content={
<SpanTooltipContent
spanName={span.name}
color={color}
hasError={span.hasError}
relativeStartMs={relativeStartMs}
durationMs={durationMs}
/>
}
trigger="hover"
rootClassName="span-hover-card-popover"
autoAdjustOverflow
arrow={false}
>
{children}
</Popover>
);
}
export default SpanHoverCard;

View File

@@ -1,91 +0,0 @@
.trace-details-header {
display: flex;
align-items: center;
padding: 0px 16px;
.previous-btn {
display: flex;
height: 30px;
padding: 6px 8px;
align-items: center;
gap: 4px;
border: 1px solid var(--bg-slate-300);
background: var(--bg-slate-500);
border-radius: 4px;
box-shadow: none;
}
.trace-name {
display: flex;
padding: 6px 8px;
margin-left: 6px;
align-items: center;
gap: 4px;
border: 1px solid var(--bg-slate-300);
border-radius: 4px 0px 0px 4px;
background: var(--bg-slate-500);
.drafting {
color: white;
}
.trace-id {
color: #fff;
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
}
}
.trace-id-value {
display: flex;
padding: 6px 8px;
justify-content: center;
align-items: center;
gap: 10px;
background: var(--bg-slate-400);
color: var(--Vanilla-400, #c0c1c3);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
border: 1px solid var(--bg-slate-300);
border-left: unset;
border-radius: 0px 4px 4px 0px;
}
}
.lightMode {
.trace-details-header {
.previous-btn {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-200);
}
.trace-name {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-200);
border-right: none;
.drafting {
color: var(--bg-ink-100);
}
.trace-id {
color: var(--bg-ink-100);
}
}
.trace-id-value {
background: var(--bg-vanilla-300);
color: var(--bg-ink-400);
border: 1px solid var(--bg-vanilla-300);
}
}
}
// TODO: move to new css module name system

Some files were not shown because too many files have changed in this diff Show More