mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-13 23:50:21 +01:00
Compare commits
7 Commits
chore/am_c
...
refactor/t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d8c8e3d7d | ||
|
|
5eae936ab4 | ||
|
|
091d61045a | ||
|
|
ed1217e5d0 | ||
|
|
0389b46836 | ||
|
|
284d6f72d4 | ||
|
|
66abfa3be4 |
@@ -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"
|
||||
@@ -18,15 +17,11 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/gateway"
|
||||
"github.com/SigNoz/signoz/pkg/gateway/noopgateway"
|
||||
"github.com/SigNoz/signoz/pkg/global"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/licensing/nooplicensing"
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
@@ -98,15 +93,9 @@ 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)
|
||||
},
|
||||
func(_ sqlstore.SQLStore, _ global.Global, _ zeus.Zeus, _ gateway.Gateway, _ licensing.Licensing, _ serviceaccount.Module, _ cloudintegration.Config) (cloudintegration.Module, error) {
|
||||
return implcloudintegration.NewModule(), nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, "failed to create signoz", errors.Attr(err))
|
||||
|
||||
@@ -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"
|
||||
@@ -17,8 +16,6 @@ import (
|
||||
"github.com/SigNoz/signoz/ee/gateway/httpgateway"
|
||||
enterpriselicensing "github.com/SigNoz/signoz/ee/licensing"
|
||||
"github.com/SigNoz/signoz/ee/licensing/httplicensing"
|
||||
"github.com/SigNoz/signoz/ee/modules/cloudintegration/implcloudintegration"
|
||||
"github.com/SigNoz/signoz/ee/modules/cloudintegration/implcloudintegration/implcloudprovider"
|
||||
"github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard"
|
||||
eequerier "github.com/SigNoz/signoz/ee/querier"
|
||||
enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app"
|
||||
@@ -27,20 +24,15 @@ 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"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/gateway"
|
||||
"github.com/SigNoz/signoz/pkg/global"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
pkgcloudintegration "github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
@@ -48,7 +40,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstorehook"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
"github.com/SigNoz/signoz/pkg/zeus"
|
||||
)
|
||||
@@ -134,6 +125,7 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
return nil, err
|
||||
}
|
||||
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, licensing, dashboardModule), nil
|
||||
|
||||
},
|
||||
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
|
||||
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing)
|
||||
@@ -141,32 +133,12 @@ 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)
|
||||
},
|
||||
func(sqlStore sqlstore.SQLStore, global global.Global, zeus zeus.Zeus, gateway gateway.Gateway, licensing licensing.Licensing, serviceAccount serviceaccount.Module, config cloudintegration.Config) (cloudintegration.Module, error) {
|
||||
defStore := pkgcloudintegration.NewServiceDefinitionStore()
|
||||
awsCloudProviderModule, err := implcloudprovider.NewAWSCloudProvider(defStore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
azureCloudProviderModule := implcloudprovider.NewAzureCloudProvider()
|
||||
cloudProvidersMap := map[cloudintegrationtypes.CloudProviderType]cloudintegration.CloudProviderModule{
|
||||
cloudintegrationtypes.CloudProviderTypeAWS: awsCloudProviderModule,
|
||||
cloudintegrationtypes.CloudProviderTypeAzure: azureCloudProviderModule,
|
||||
}
|
||||
|
||||
return implcloudintegration.NewModule(pkgcloudintegration.NewStore(sqlStore), global, zeus, gateway, licensing, serviceAccount, cloudProvidersMap, config)
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, "failed to create signoz", errors.Attr(err))
|
||||
return err
|
||||
|
||||
@@ -82,9 +82,6 @@ sqlstore:
|
||||
provider: sqlite
|
||||
# The maximum number of open connections to the database.
|
||||
max_open_conns: 100
|
||||
# The maximum amount of time a connection may be reused.
|
||||
# If max_conn_lifetime == 0, connections are not closed due to a connection's age.
|
||||
max_conn_lifetime: 0
|
||||
sqlite:
|
||||
# The path to the SQLite database file.
|
||||
path: /var/lib/signoz/signoz.db
|
||||
@@ -367,41 +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
|
||||
|
||||
##################### Cloud Integration #####################
|
||||
cloudintegration:
|
||||
# cloud integration agent configuration
|
||||
agent:
|
||||
# The version of the cloud integration agent.
|
||||
version: v0.0.8
|
||||
|
||||
@@ -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:
|
||||
@@ -3198,7 +3081,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesPostableAgentCheckIn'
|
||||
$ref: '#/components/schemas/CloudintegrationtypesPostableAgentCheckInRequest'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
@@ -3206,7 +3089,7 @@ paths:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGettableAgentCheckIn'
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGettableAgentCheckInResponse'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
@@ -3307,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:
|
||||
@@ -3511,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
|
||||
@@ -3581,7 +3409,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesPostableAgentCheckIn'
|
||||
$ref: '#/components/schemas/CloudintegrationtypesPostableAgentCheckInRequest'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
@@ -3589,7 +3417,7 @@ paths:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGettableAgentCheckIn'
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGettableAgentCheckInResponse'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
@@ -3623,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
|
||||
@@ -3683,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
|
||||
@@ -3740,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
|
||||
@@ -3796,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
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
package implcloudprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
)
|
||||
|
||||
type awscloudprovider struct {
|
||||
serviceDefinitions cloudintegrationtypes.ServiceDefinitionStore
|
||||
}
|
||||
|
||||
func NewAWSCloudProvider(defStore cloudintegrationtypes.ServiceDefinitionStore) (cloudintegration.CloudProviderModule, error) {
|
||||
return &awscloudprovider{serviceDefinitions: defStore}, nil
|
||||
}
|
||||
|
||||
func (provider *awscloudprovider) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
|
||||
baseURL := fmt.Sprintf(cloudintegrationtypes.CloudFormationQuickCreateBaseURL.StringValue(), req.Config.Aws.DeploymentRegion)
|
||||
u, _ := url.Parse(baseURL)
|
||||
|
||||
q := u.Query()
|
||||
q.Set("region", req.Config.Aws.DeploymentRegion)
|
||||
u.Fragment = "/stacks/quickcreate"
|
||||
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
q = u.Query()
|
||||
q.Set("stackName", cloudintegrationtypes.AgentCloudFormationBaseStackName.StringValue())
|
||||
q.Set("templateURL", fmt.Sprintf(cloudintegrationtypes.AgentCloudFormationTemplateS3Path.StringValue(), req.Config.AgentVersion))
|
||||
q.Set("param_SigNozIntegrationAgentVersion", req.Config.AgentVersion)
|
||||
q.Set("param_SigNozApiUrl", req.Credentials.SigNozAPIURL)
|
||||
q.Set("param_SigNozApiKey", req.Credentials.SigNozAPIKey)
|
||||
q.Set("param_SigNozAccountId", account.ID.StringValue())
|
||||
q.Set("param_IngestionUrl", req.Credentials.IngestionURL)
|
||||
q.Set("param_IngestionKey", req.Credentials.IngestionKey)
|
||||
|
||||
return &cloudintegrationtypes.ConnectionArtifact{
|
||||
Aws: &cloudintegrationtypes.AWSConnectionArtifact{
|
||||
ConnectionURL: u.String() + "?&" + q.Encode(), // this format is required by AWS
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (provider *awscloudprovider) ListServiceDefinitions(ctx context.Context) ([]*cloudintegrationtypes.ServiceDefinition, error) {
|
||||
return provider.serviceDefinitions.List(ctx, cloudintegrationtypes.CloudProviderTypeAWS)
|
||||
}
|
||||
|
||||
func (provider *awscloudprovider) GetServiceDefinition(ctx context.Context, serviceID cloudintegrationtypes.ServiceID) (*cloudintegrationtypes.ServiceDefinition, error) {
|
||||
serviceDef, err := provider.serviceDefinitions.Get(ctx, cloudintegrationtypes.CloudProviderTypeAWS, serviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// override cloud integration dashboard id
|
||||
for index, dashboard := range serviceDef.Assets.Dashboards {
|
||||
serviceDef.Assets.Dashboards[index].ID = cloudintegrationtypes.GetCloudIntegrationDashboardID(cloudintegrationtypes.CloudProviderTypeAWS, serviceID.StringValue(), dashboard.ID)
|
||||
}
|
||||
|
||||
return serviceDef, nil
|
||||
}
|
||||
|
||||
func (provider *awscloudprovider) BuildIntegrationConfig(
|
||||
ctx context.Context,
|
||||
account *cloudintegrationtypes.Account,
|
||||
services []*cloudintegrationtypes.StorableCloudIntegrationService,
|
||||
) (*cloudintegrationtypes.ProviderIntegrationConfig, error) {
|
||||
// Sort services for deterministic output
|
||||
sort.Slice(services, func(i, j int) bool {
|
||||
return services[i].Type.StringValue() < services[j].Type.StringValue()
|
||||
})
|
||||
|
||||
compiledMetrics := new(cloudintegrationtypes.AWSMetricsCollectionStrategy)
|
||||
compiledLogs := new(cloudintegrationtypes.AWSLogsCollectionStrategy)
|
||||
var compiledS3Buckets map[string][]string
|
||||
|
||||
for _, storedSvc := range services {
|
||||
svcCfg, err := cloudintegrationtypes.NewServiceConfigFromJSON(cloudintegrationtypes.CloudProviderTypeAWS, storedSvc.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
svcDef, err := provider.GetServiceDefinition(ctx, storedSvc.Type)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
strategy := svcDef.TelemetryCollectionStrategy.AWS
|
||||
logsEnabled := svcCfg.IsLogsEnabled(cloudintegrationtypes.CloudProviderTypeAWS)
|
||||
|
||||
// S3Sync: logs come directly from configured S3 buckets, not CloudWatch subscriptions
|
||||
if storedSvc.Type == cloudintegrationtypes.AWSServiceS3Sync {
|
||||
if logsEnabled && svcCfg.AWS.Logs.S3Buckets != nil {
|
||||
compiledS3Buckets = svcCfg.AWS.Logs.S3Buckets
|
||||
}
|
||||
// no need to go ahead as the code block specifically checks for the S3Sync service
|
||||
continue
|
||||
}
|
||||
|
||||
if logsEnabled && strategy.Logs != nil {
|
||||
compiledLogs.Subscriptions = append(compiledLogs.Subscriptions, strategy.Logs.Subscriptions...)
|
||||
}
|
||||
|
||||
metricsEnabled := svcCfg.IsMetricsEnabled(cloudintegrationtypes.CloudProviderTypeAWS)
|
||||
|
||||
if metricsEnabled && strategy.Metrics != nil {
|
||||
compiledMetrics.StreamFilters = append(compiledMetrics.StreamFilters, strategy.Metrics.StreamFilters...)
|
||||
}
|
||||
}
|
||||
|
||||
collectionStrategy := new(cloudintegrationtypes.AWSTelemetryCollectionStrategy)
|
||||
|
||||
if len(compiledMetrics.StreamFilters) > 0 {
|
||||
collectionStrategy.Metrics = compiledMetrics
|
||||
}
|
||||
if len(compiledLogs.Subscriptions) > 0 {
|
||||
collectionStrategy.Logs = compiledLogs
|
||||
}
|
||||
if compiledS3Buckets != nil {
|
||||
collectionStrategy.S3Buckets = compiledS3Buckets
|
||||
}
|
||||
|
||||
return &cloudintegrationtypes.ProviderIntegrationConfig{
|
||||
AWS: &cloudintegrationtypes.AWSIntegrationConfig{
|
||||
EnabledRegions: account.Config.AWS.Regions,
|
||||
TelemetryCollectionStrategy: collectionStrategy,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package implcloudprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
)
|
||||
|
||||
type azurecloudprovider struct{}
|
||||
|
||||
func NewAzureCloudProvider() cloudintegration.CloudProviderModule {
|
||||
return &azurecloudprovider{}
|
||||
}
|
||||
|
||||
func (provider *azurecloudprovider) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (provider *azurecloudprovider) ListServiceDefinitions(ctx context.Context) ([]*cloudintegrationtypes.ServiceDefinition, error) {
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (provider *azurecloudprovider) GetServiceDefinition(ctx context.Context, serviceID cloudintegrationtypes.ServiceID) (*cloudintegrationtypes.ServiceDefinition, error) {
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (provider *azurecloudprovider) BuildIntegrationConfig(
|
||||
ctx context.Context,
|
||||
account *cloudintegrationtypes.Account,
|
||||
services []*cloudintegrationtypes.StorableCloudIntegrationService,
|
||||
) (*cloudintegrationtypes.ProviderIntegrationConfig, error) {
|
||||
panic("implement me")
|
||||
}
|
||||
@@ -1,540 +0,0 @@
|
||||
package implcloudintegration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/gateway"
|
||||
"github.com/SigNoz/signoz/pkg/global"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/zeustypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/SigNoz/signoz/pkg/zeus"
|
||||
)
|
||||
|
||||
type module struct {
|
||||
store cloudintegrationtypes.Store
|
||||
gateway gateway.Gateway
|
||||
zeus zeus.Zeus
|
||||
licensing licensing.Licensing
|
||||
global global.Global
|
||||
serviceAccount serviceaccount.Module
|
||||
cloudProvidersMap map[cloudintegrationtypes.CloudProviderType]cloudintegration.CloudProviderModule
|
||||
config cloudintegration.Config
|
||||
}
|
||||
|
||||
func NewModule(
|
||||
store cloudintegrationtypes.Store,
|
||||
global global.Global,
|
||||
zeus zeus.Zeus,
|
||||
gateway gateway.Gateway,
|
||||
licensing licensing.Licensing,
|
||||
serviceAccount serviceaccount.Module,
|
||||
cloudProvidersMap map[cloudintegrationtypes.CloudProviderType]cloudintegration.CloudProviderModule,
|
||||
config cloudintegration.Config,
|
||||
) (cloudintegration.Module, error) {
|
||||
return &module{
|
||||
store: store,
|
||||
global: global,
|
||||
zeus: zeus,
|
||||
gateway: gateway,
|
||||
licensing: licensing,
|
||||
serviceAccount: serviceAccount,
|
||||
cloudProvidersMap: cloudProvidersMap,
|
||||
config: config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetConnectionCredentials returns credentials required to generate connection artifact. eg. apiKey, ingestionKey etc.
|
||||
// It will return creds it can deduce and return empty value for others.
|
||||
func (module *module) GetConnectionCredentials(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) (*cloudintegrationtypes.Credentials, error) {
|
||||
// get license to get the deployment details
|
||||
license, err := module.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
// get deployment details from zeus, ignore error
|
||||
respBytes, _ := module.zeus.GetDeployment(ctx, license.Key)
|
||||
|
||||
var signozAPIURL string
|
||||
|
||||
if len(respBytes) > 0 {
|
||||
// parse deployment details, ignore error, if client is asking api url every time check for possible error
|
||||
deployment, _ := zeustypes.NewGettableDeployment(respBytes)
|
||||
if deployment != nil {
|
||||
signozAPIURL, _ = cloudintegrationtypes.GetSigNozAPIURLFromDeployment(deployment)
|
||||
}
|
||||
}
|
||||
|
||||
// ignore error
|
||||
apiKey, _ := module.getOrCreateAPIKey(ctx, orgID, provider)
|
||||
|
||||
// ignore error
|
||||
ingestionKey, _ := module.getOrCreateIngestionKey(ctx, orgID, provider)
|
||||
|
||||
return cloudintegrationtypes.NewCredentials(
|
||||
signozAPIURL,
|
||||
apiKey,
|
||||
module.global.GetConfig(ctx).IngestionURL,
|
||||
ingestionKey,
|
||||
), nil
|
||||
}
|
||||
|
||||
func (module *module) CreateAccount(ctx context.Context, account *cloudintegrationtypes.Account) error {
|
||||
_, err := module.licensing.GetActive(ctx, account.OrgID)
|
||||
if err != nil {
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
storableCloudIntegration, err := cloudintegrationtypes.NewStorableCloudIntegration(account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return module.store.CreateAccount(ctx, storableCloudIntegration)
|
||||
}
|
||||
|
||||
func (module *module) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
|
||||
_, err := module.licensing.GetActive(ctx, account.OrgID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
cloudProviderModule, err := module.getCloudProvider(account.Provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Config.SetAgentVersion(module.config.Agent.Version)
|
||||
return cloudProviderModule.GetConnectionArtifact(ctx, account, req)
|
||||
}
|
||||
|
||||
func (module *module) GetAccount(ctx context.Context, orgID valuer.UUID, accountID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) (*cloudintegrationtypes.Account, error) {
|
||||
_, err := module.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
storableAccount, err := module.store.GetAccountByID(ctx, orgID, accountID, provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cloudintegrationtypes.NewAccountFromStorable(storableAccount)
|
||||
}
|
||||
|
||||
func (module *module) GetConnectedAccount(ctx context.Context, orgID, accountID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) (*cloudintegrationtypes.Account, error) {
|
||||
_, err := module.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
storableAccount, err := module.store.GetConnectedAccount(ctx, orgID, accountID, provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cloudintegrationtypes.NewAccountFromStorable(storableAccount)
|
||||
}
|
||||
|
||||
// ListAccounts return only agent connected accounts.
|
||||
func (module *module) ListAccounts(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) ([]*cloudintegrationtypes.Account, error) {
|
||||
_, err := module.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
storableAccounts, err := module.store.ListConnectedAccounts(ctx, orgID, provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cloudintegrationtypes.NewAccountsFromStorables(storableAccounts)
|
||||
}
|
||||
|
||||
func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, req *cloudintegrationtypes.AgentCheckInRequest) (*cloudintegrationtypes.AgentCheckInResponse, error) {
|
||||
_, err := module.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
connectedAccount, err := module.store.GetConnectedAccountByProviderAccountID(ctx, orgID, req.ProviderAccountID, provider)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If a different integration is already connected to this provider account ID, reject the check-in.
|
||||
// Allow re-check-in from the same integration (e.g. agent restarting).
|
||||
if connectedAccount != nil && connectedAccount.ID != req.CloudIntegrationID {
|
||||
errMessage := fmt.Sprintf("provider account id %s is already connected to cloud integration id %s", req.ProviderAccountID, connectedAccount.ID)
|
||||
return nil, errors.New(errors.TypeAlreadyExists, cloudintegrationtypes.ErrCodeCloudIntegrationAlreadyConnected, errMessage)
|
||||
}
|
||||
|
||||
account, err := module.store.GetAccountByID(ctx, orgID, req.CloudIntegrationID, provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If account has been removed (disconnected), return a minimal response with empty integration config.
|
||||
// The agent uses this response to clean up resources
|
||||
if account.RemovedAt != nil {
|
||||
return cloudintegrationtypes.NewAgentCheckInResponse(
|
||||
req.ProviderAccountID,
|
||||
account.ID.StringValue(),
|
||||
new(cloudintegrationtypes.ProviderIntegrationConfig),
|
||||
account.RemovedAt,
|
||||
), nil
|
||||
}
|
||||
|
||||
// update account with cloud provider account id and agent report (heartbeat)
|
||||
account.Update(&req.ProviderAccountID, cloudintegrationtypes.NewAgentReport(req.Data))
|
||||
|
||||
err = module.store.UpdateAccount(ctx, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get account as domain object for config access (enabled regions, etc.)
|
||||
domainAccount, err := cloudintegrationtypes.NewAccountFromStorable(account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cloudProvider, err := module.getCloudProvider(provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storedServices, err := module.store.ListServices(ctx, req.CloudIntegrationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Delegate integration config building entirely to the provider module
|
||||
integrationConfig, err := cloudProvider.BuildIntegrationConfig(ctx, domainAccount, storedServices)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cloudintegrationtypes.NewAgentCheckInResponse(
|
||||
req.ProviderAccountID,
|
||||
account.ID.StringValue(),
|
||||
integrationConfig,
|
||||
account.RemovedAt,
|
||||
), nil
|
||||
}
|
||||
|
||||
func (module *module) UpdateAccount(ctx context.Context, account *cloudintegrationtypes.Account) error {
|
||||
_, err := module.licensing.GetActive(ctx, account.OrgID)
|
||||
if err != nil {
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
storableAccount, err := cloudintegrationtypes.NewStorableCloudIntegration(account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return module.store.UpdateAccount(ctx, storableAccount)
|
||||
}
|
||||
|
||||
func (module *module) DisconnectAccount(ctx context.Context, orgID valuer.UUID, accountID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) error {
|
||||
_, err := module.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
return module.store.RemoveAccount(ctx, orgID, accountID, provider)
|
||||
}
|
||||
|
||||
func (module *module) ListServicesMetadata(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, integrationID valuer.UUID) ([]*cloudintegrationtypes.ServiceMetadata, error) {
|
||||
_, err := module.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
cloudProvider, err := module.getCloudProvider(provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
serviceDefinitions, err := cloudProvider.ListServiceDefinitions(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
enabledServiceIDs := map[string]bool{}
|
||||
if !integrationID.IsZero() {
|
||||
storedServices, err := module.store.ListServices(ctx, integrationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, svc := range storedServices {
|
||||
serviceConfig, err := cloudintegrationtypes.NewServiceConfigFromJSON(provider, svc.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if serviceConfig.IsServiceEnabled(provider) {
|
||||
enabledServiceIDs[svc.Type.StringValue()] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resp := make([]*cloudintegrationtypes.ServiceMetadata, 0, len(serviceDefinitions))
|
||||
for _, serviceDefinition := range serviceDefinitions {
|
||||
resp = append(resp, cloudintegrationtypes.NewServiceMetadata(*serviceDefinition, enabledServiceIDs[serviceDefinition.ID]))
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (module *module) GetService(ctx context.Context, orgID valuer.UUID, serviceID cloudintegrationtypes.ServiceID, provider cloudintegrationtypes.CloudProviderType, cloudIntegrationID valuer.UUID) (*cloudintegrationtypes.Service, error) {
|
||||
_, err := module.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
cloudProvider, err := module.getCloudProvider(provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
serviceDefinition, err := cloudProvider.GetServiceDefinition(ctx, serviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var integrationService *cloudintegrationtypes.CloudIntegrationService
|
||||
|
||||
if !cloudIntegrationID.IsZero() {
|
||||
storedService, err := module.store.GetServiceByServiceID(ctx, cloudIntegrationID, serviceID)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if storedService != nil {
|
||||
serviceConfig, err := cloudintegrationtypes.NewServiceConfigFromJSON(provider, storedService.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
integrationService = cloudintegrationtypes.NewCloudIntegrationServiceFromStorable(storedService, serviceConfig)
|
||||
}
|
||||
}
|
||||
|
||||
return cloudintegrationtypes.NewService(*serviceDefinition, integrationService), nil
|
||||
}
|
||||
|
||||
func (module *module) CreateService(ctx context.Context, orgID valuer.UUID, service *cloudintegrationtypes.CloudIntegrationService, provider cloudintegrationtypes.CloudProviderType) error {
|
||||
_, err := module.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
cloudProvider, err := module.getCloudProvider(provider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
serviceDefinition, err := cloudProvider.GetServiceDefinition(ctx, service.Type)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
configJSON, err := service.Config.ToJSON(provider, service.Type, &serviceDefinition.SupportedSignals)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return module.store.CreateService(ctx, cloudintegrationtypes.NewStorableCloudIntegrationService(service, string(configJSON)))
|
||||
}
|
||||
|
||||
func (module *module) UpdateService(ctx context.Context, orgID valuer.UUID, integrationService *cloudintegrationtypes.CloudIntegrationService, provider cloudintegrationtypes.CloudProviderType) error {
|
||||
_, err := module.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
cloudProvider, err := module.getCloudProvider(provider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
serviceDefinition, err := cloudProvider.GetServiceDefinition(ctx, integrationService.Type)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
configJSON, err := integrationService.Config.ToJSON(provider, integrationService.Type, &serviceDefinition.SupportedSignals)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
storableService := cloudintegrationtypes.NewStorableCloudIntegrationService(integrationService, string(configJSON))
|
||||
|
||||
return module.store.UpdateService(ctx, storableService)
|
||||
}
|
||||
|
||||
func (module *module) GetDashboardByID(ctx context.Context, orgID valuer.UUID, id string) (*dashboardtypes.Dashboard, error) {
|
||||
_, err := module.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
_, _, _, err = cloudintegrationtypes.ParseCloudIntegrationDashboardID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allDashboards, err := module.listDashboards(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, d := range allDashboards {
|
||||
if d.ID == id {
|
||||
return d, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.New(errors.TypeNotFound, cloudintegrationtypes.ErrCodeCloudIntegrationNotFound, "cloud integration dashboard not found")
|
||||
}
|
||||
|
||||
func (module *module) ListDashboards(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error) {
|
||||
_, err := module.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
return module.listDashboards(ctx, orgID)
|
||||
}
|
||||
|
||||
func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
|
||||
stats := make(map[string]any)
|
||||
|
||||
// get connected accounts for AWS
|
||||
awsAccountsCount, err := module.store.CountConnectedAccounts(ctx, orgID, cloudintegrationtypes.CloudProviderTypeAWS)
|
||||
if err == nil {
|
||||
stats["cloudintegration.aws.connectedaccounts.count"] = awsAccountsCount
|
||||
}
|
||||
|
||||
// NOTE: not adding stats for services for now.
|
||||
|
||||
// TODO: add more cloud providers when supported
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (module *module) getCloudProvider(provider cloudintegrationtypes.CloudProviderType) (cloudintegration.CloudProviderModule, error) {
|
||||
if cloudProviderModule, ok := module.cloudProvidersMap[provider]; ok {
|
||||
return cloudProviderModule, nil
|
||||
}
|
||||
|
||||
return nil, errors.NewInvalidInputf(cloudintegrationtypes.ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
|
||||
}
|
||||
|
||||
func (module *module) getOrCreateIngestionKey(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) (string, error) {
|
||||
keyName := cloudintegrationtypes.NewIngestionKeyName(provider)
|
||||
|
||||
result, err := module.gateway.SearchIngestionKeysByName(ctx, orgID, keyName, 1, 10)
|
||||
if err != nil {
|
||||
return "", errors.WrapInternalf(err, errors.CodeInternal, "couldn't search ingestion keys")
|
||||
}
|
||||
|
||||
// ideally there should be only one key per cloud integration provider
|
||||
if len(result.Keys) > 0 {
|
||||
return result.Keys[0].Value, nil
|
||||
}
|
||||
|
||||
createdIngestionKey, err := module.gateway.CreateIngestionKey(ctx, orgID, keyName, []string{"integration"}, time.Time{})
|
||||
if err != nil {
|
||||
return "", errors.WrapInternalf(err, errors.CodeInternal, "couldn't create ingestion key")
|
||||
}
|
||||
|
||||
return createdIngestionKey.Value, nil
|
||||
}
|
||||
|
||||
func (module *module) getOrCreateAPIKey(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) (string, error) {
|
||||
domain := module.serviceAccount.Config().Email.Domain
|
||||
serviceAccount := serviceaccounttypes.NewServiceAccount("integration", domain, serviceaccounttypes.ServiceAccountStatusActive, orgID)
|
||||
serviceAccount, err := module.serviceAccount.GetOrCreate(ctx, orgID, serviceAccount)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
err = module.serviceAccount.SetRoleByName(ctx, orgID, serviceAccount.ID, authtypes.SigNozViewerRoleName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
factorAPIKey, err := serviceAccount.NewFactorAPIKey(provider.StringValue(), 0)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
factorAPIKey, err = module.serviceAccount.GetOrCreateFactorAPIKey(ctx, factorAPIKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return factorAPIKey.Key, nil
|
||||
}
|
||||
|
||||
func (module *module) listDashboards(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error) {
|
||||
var allDashboards []*dashboardtypes.Dashboard
|
||||
|
||||
for provider := range module.cloudProvidersMap {
|
||||
cloudProvider, err := module.getCloudProvider(provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
connectedAccounts, err := module.store.ListConnectedAccounts(ctx, orgID, provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, storableAccount := range connectedAccounts {
|
||||
storedServices, err := module.store.ListServices(ctx, storableAccount.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, storedSvc := range storedServices {
|
||||
serviceConfig, err := cloudintegrationtypes.NewServiceConfigFromJSON(provider, storedSvc.Config)
|
||||
if err != nil || !serviceConfig.IsMetricsEnabled(provider) {
|
||||
continue
|
||||
}
|
||||
|
||||
svcDef, err := cloudProvider.GetServiceDefinition(ctx, storedSvc.Type)
|
||||
if err != nil || svcDef == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
dashboards := cloudintegrationtypes.GetDashboardsFromAssets(
|
||||
storedSvc.Type.StringValue(),
|
||||
orgID,
|
||||
provider,
|
||||
storableAccount.CreatedAt,
|
||||
svcDef.Assets,
|
||||
)
|
||||
allDashboards = append(allDashboards, dashboards...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(allDashboards, func(i, j int) bool {
|
||||
return allDashboards[i].ID < allDashboards[j].ID
|
||||
})
|
||||
|
||||
return allDashboards, nil
|
||||
}
|
||||
@@ -7,10 +7,6 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/ee/query-service/constants"
|
||||
"github.com/SigNoz/signoz/ee/query-service/model"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type DayWiseBreakdown struct {
|
||||
@@ -49,17 +45,15 @@ type details struct {
|
||||
BillTotal float64 `json:"billTotal"`
|
||||
}
|
||||
|
||||
type billingData struct {
|
||||
BillingPeriodStart int64 `json:"billingPeriodStart"`
|
||||
BillingPeriodEnd int64 `json:"billingPeriodEnd"`
|
||||
Details details `json:"details"`
|
||||
Discount float64 `json:"discount"`
|
||||
SubscriptionStatus string `json:"subscriptionStatus"`
|
||||
}
|
||||
|
||||
type billingDetails struct {
|
||||
Status string `json:"status"`
|
||||
Data billingData `json:"data"`
|
||||
Status string `json:"status"`
|
||||
Data struct {
|
||||
BillingPeriodStart int64 `json:"billingPeriodStart"`
|
||||
BillingPeriodEnd int64 `json:"billingPeriodEnd"`
|
||||
Details details `json:"details"`
|
||||
Discount float64 `json:"discount"`
|
||||
SubscriptionStatus string `json:"subscriptionStatus"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func (ah *APIHandler) getBilling(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -70,33 +64,6 @@ func (ah *APIHandler) getBilling(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
RespondError(w, model.InternalError(err), nil)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
evalCtx := featuretypes.NewFlaggerEvaluationContext(orgID)
|
||||
useZeus := ah.Signoz.Flagger.BooleanOrEmpty(r.Context(), flagger.FeatureGetMetersFromZeus, evalCtx)
|
||||
|
||||
if useZeus {
|
||||
data, err := ah.Signoz.Zeus.GetMeters(r.Context(), licenseKey)
|
||||
if err != nil {
|
||||
RespondError(w, model.InternalError(err), nil)
|
||||
return
|
||||
}
|
||||
|
||||
var billing billingData
|
||||
if err := json.Unmarshal(data, &billing); err != nil {
|
||||
RespondError(w, model.InternalError(err), nil)
|
||||
return
|
||||
}
|
||||
|
||||
ah.Respond(w, billing)
|
||||
return
|
||||
}
|
||||
|
||||
billingURL := fmt.Sprintf("%s/usage?licenseKey=%s", constants.LicenseSignozIo, licenseKey)
|
||||
|
||||
hClient := &http.Client{}
|
||||
@@ -112,11 +79,13 @@ func (ah *APIHandler) getBilling(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// decode response body
|
||||
var billingResponse billingDetails
|
||||
if err := json.NewDecoder(billingResp.Body).Decode(&billingResponse); err != nil {
|
||||
RespondError(w, model.InternalError(err), nil)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO(srikanthccv):Fetch the current day usage and add it to the response
|
||||
ah.Respond(w, billingResponse.Data)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -54,7 +54,6 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
|
||||
|
||||
// Set the maximum number of open connections
|
||||
pgConfig.MaxConns = int32(config.Connection.MaxOpenConns)
|
||||
pgConfig.MaxConnLifetime = config.Connection.MaxConnLifetime
|
||||
|
||||
// Use pgxpool to create a connection pool
|
||||
pool, err := pgxpool.NewWithConfig(ctx, pgConfig)
|
||||
|
||||
@@ -109,21 +109,6 @@ func (provider *Provider) GetDeployment(ctx context.Context, key string) ([]byte
|
||||
return []byte(gjson.GetBytes(response, "data").String()), nil
|
||||
}
|
||||
|
||||
func (provider *Provider) GetMeters(ctx context.Context, key string) ([]byte, error) {
|
||||
response, err := provider.do(
|
||||
ctx,
|
||||
provider.config.URL.JoinPath("/v1/meters"),
|
||||
http.MethodGet,
|
||||
key,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []byte(gjson.GetBytes(response, "data").String()), nil
|
||||
}
|
||||
|
||||
func (provider *Provider) PutMeters(ctx context.Context, key string, data []byte) error {
|
||||
_, err := provider.do(
|
||||
ctx,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export default 'test-file-stub';
|
||||
@@ -11,9 +11,6 @@ const config: Config.InitialOptions = {
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'json'],
|
||||
modulePathIgnorePatterns: ['dist'],
|
||||
moduleNameMapper: {
|
||||
'\\.(png|jpg|jpeg|gif|svg|webp|avif|ico|bmp|tiff)$':
|
||||
'<rootDir>/__mocks__/fileMock.ts',
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
'\\.(css|less|scss)$': '<rootDir>/__mocks__/cssMock.ts',
|
||||
'\\.md$': '<rootDir>/__mocks__/cssMock.ts',
|
||||
'^uplot$': '<rootDir>/__mocks__/uplotMock.ts',
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -3568,7 +3450,7 @@ export type AgentCheckInDeprecatedPathParameters = {
|
||||
cloudProvider: string;
|
||||
};
|
||||
export type AgentCheckInDeprecated200 = {
|
||||
data: CloudintegrationtypesGettableAgentCheckInDTO;
|
||||
data: CloudintegrationtypesGettableAgentCheckInResponseDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -3589,8 +3471,8 @@ export type ListAccounts200 = {
|
||||
export type CreateAccountPathParameters = {
|
||||
cloudProvider: string;
|
||||
};
|
||||
export type CreateAccount201 = {
|
||||
data: CloudintegrationtypesGettableAccountWithConnectionArtifactDTO;
|
||||
export type CreateAccount200 = {
|
||||
data: CloudintegrationtypesGettableAccountWithArtifactDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -3617,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
|
||||
*/
|
||||
@@ -3647,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;
|
||||
/**
|
||||
@@ -3667,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;
|
||||
/**
|
||||
@@ -3683,6 +3533,10 @@ export type GetService200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UpdateServicePathParameters = {
|
||||
cloudProvider: string;
|
||||
serviceId: string;
|
||||
};
|
||||
export type CreateSessionByGoogleCallback303 = {
|
||||
data: AuthtypesGettableTokenDTO;
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
.log-state-indicator {
|
||||
padding-left: 8px;
|
||||
|
||||
.line {
|
||||
margin: 0 8px;
|
||||
min-height: 24px;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
117
frontend/src/components/Logs/TableView/useLogsTableColumns.tsx
Normal file
117
frontend/src/components/Logs/TableView/useLogsTableColumns.tsx
Normal 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]);
|
||||
}
|
||||
@@ -150,7 +150,6 @@ export const MetricsSelect = memo(function MetricsSelect({
|
||||
options={SOURCE_OPTIONS}
|
||||
value={source}
|
||||
defaultValue="metrics"
|
||||
data-testid={`metrics-source-selector-${index}`}
|
||||
onChange={handleSignalSourceChange}
|
||||
/>
|
||||
)}
|
||||
@@ -158,7 +157,6 @@ export const MetricsSelect = memo(function MetricsSelect({
|
||||
<MetricNameSelector
|
||||
onChange={handleChangeAggregatorAttribute}
|
||||
query={query}
|
||||
data-testid={`metric-name-selector-${index}`}
|
||||
signalSource={signalSource || ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
101
frontend/src/components/TanStackTableView/RowHoverContext.tsx
Normal file
101
frontend/src/components/TanStackTableView/RowHoverContext.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
105
frontend/src/components/TanStackTableView/TanStackRow.tsx
Normal file
105
frontend/src/components/TanStackTableView/TanStackRow.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
618
frontend/src/components/TanStackTableView/index.tsx
Normal file
618
frontend/src/components/TanStackTableView/index.tsx
Normal 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;
|
||||
112
frontend/src/components/TanStackTableView/types.ts
Normal file
112
frontend/src/components/TanStackTableView/types.ts
Normal 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;
|
||||
};
|
||||
200
frontend/src/components/TanStackTableView/useTableColumns.ts
Normal file
200
frontend/src/components/TanStackTableView/useTableColumns.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
72
frontend/src/components/TanStackTableView/useTableParams.ts
Normal file
72
frontend/src/components/TanStackTableView/useTableParams.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
67
frontend/src/components/TanStackTableView/utils.ts
Normal file
67
frontend/src/components/TanStackTableView/utils.ts
Normal 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();
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -55,12 +55,7 @@ function PanelTypeSelectionModal(): JSX.Element {
|
||||
>
|
||||
<div className="panel-selection">
|
||||
{PanelTypesWithData.map(({ name, icon, display }) => (
|
||||
<Card
|
||||
onClick={(): void => handleCardClick(name)}
|
||||
id={name}
|
||||
key={name}
|
||||
data-testid={`panel-type-${name}`}
|
||||
>
|
||||
<Card onClick={(): void => handleCardClick(name)} id={name} key={name}>
|
||||
{icon}
|
||||
<Typography className="panel-type-text">{display}</Typography>
|
||||
</Card>
|
||||
|
||||
@@ -560,7 +560,6 @@ function DashboardsList(): JSX.Element {
|
||||
label: (
|
||||
<div
|
||||
className="create-dashboard-menu-item"
|
||||
data-testid="import-json-menu-cta"
|
||||
onClick={(): void => onModalHandler(false)}
|
||||
>
|
||||
<Radius size={14} /> Import JSON
|
||||
@@ -574,7 +573,6 @@ function DashboardsList(): JSX.Element {
|
||||
href="https://signoz.io/docs/dashboards/dashboard-templates/overview/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="view-templates-menu-cta"
|
||||
>
|
||||
<Flex
|
||||
justify="space-between"
|
||||
@@ -598,7 +596,6 @@ function DashboardsList(): JSX.Element {
|
||||
label: (
|
||||
<div
|
||||
className="create-dashboard-menu-item"
|
||||
data-testid="create-dashboard-menu-cta"
|
||||
onClick={(): void => {
|
||||
onNewDashboardHandler();
|
||||
}}
|
||||
@@ -762,7 +759,6 @@ function DashboardsList(): JSX.Element {
|
||||
placeholder="Search by name, description, or tags..."
|
||||
prefix={<Search size={12} color={Color.BG_VANILLA_400} />}
|
||||
value={searchString}
|
||||
data-testid="dashboards-list-search"
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
{createNewDashboard && (
|
||||
@@ -776,7 +772,6 @@ function DashboardsList(): JSX.Element {
|
||||
type="primary"
|
||||
className="periscope-btn primary btn"
|
||||
icon={<Plus size={14} />}
|
||||
data-testid="new-dashboard-cta"
|
||||
onClick={(): void => {
|
||||
logEvent('Dashboard List: New dashboard clicked', {});
|
||||
}}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ interface ColumnUnitSelectorProps {
|
||||
columnUnits: ColumnUnit;
|
||||
setColumnUnits: Dispatch<SetStateAction<ColumnUnit>>;
|
||||
isNewDashboard: boolean;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
export function ColumnUnitSelector(
|
||||
@@ -84,7 +83,6 @@ export function ColumnUnitSelector(
|
||||
}
|
||||
fieldLabel={label}
|
||||
key={value}
|
||||
data-testid={props['data-testid']}
|
||||
selectedQueryName={baseQueryName}
|
||||
// Update the column unit value automatically only in create mode
|
||||
shouldUpdateYAxisUnit={isNewDashboard}
|
||||
|
||||
@@ -138,7 +138,6 @@ function ContextLinks({
|
||||
<Button
|
||||
type="default"
|
||||
className="add-context-link-button"
|
||||
data-testid="add-context-link-cta"
|
||||
icon={<Plus size={12} />}
|
||||
style={{ width: '100%' }}
|
||||
onClick={handleAddContextLink}
|
||||
|
||||
@@ -15,14 +15,12 @@ function DashboardYAxisUnitSelectorWrapper({
|
||||
fieldLabel,
|
||||
shouldUpdateYAxisUnit,
|
||||
selectedQueryName,
|
||||
'data-testid': dataTestId,
|
||||
}: {
|
||||
value: string;
|
||||
onSelect: OnSelectType;
|
||||
fieldLabel: string;
|
||||
shouldUpdateYAxisUnit: boolean;
|
||||
selectedQueryName?: string;
|
||||
'data-testid'?: string;
|
||||
}): JSX.Element {
|
||||
const { yAxisUnit: initialYAxisUnit, isLoading } = useGetYAxisUnit(
|
||||
selectedQueryName,
|
||||
@@ -44,7 +42,6 @@ function DashboardYAxisUnitSelectorWrapper({
|
||||
initialValue={initialYAxisUnit}
|
||||
source={YAxisSource.DASHBOARDS}
|
||||
loading={isLoading}
|
||||
data-testid={dataTestId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -66,7 +66,6 @@ export default function FormattingUnitsSection({
|
||||
options={decimapPrecisionOptions}
|
||||
value={decimalPrecision}
|
||||
className="panel-type-select"
|
||||
data-testid="decimal-precision-selector"
|
||||
defaultValue={decimapPrecisionOptions[0]?.value}
|
||||
onChange={(val: PrecisionOption): void => setDecimalPrecision(val)}
|
||||
/>
|
||||
@@ -78,7 +77,6 @@ export default function FormattingUnitsSection({
|
||||
columnUnits={columnUnits}
|
||||
setColumnUnits={setColumnUnits}
|
||||
isNewDashboard={isNewDashboard}
|
||||
data-testid="column-unit-selector"
|
||||
/>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
@@ -135,7 +135,6 @@ export default function GeneralSettingsSection({
|
||||
rootClassName="general-settings__name-input"
|
||||
ref={inputRef}
|
||||
onSelect={handleInputCursor}
|
||||
data-testid="panel-name-input"
|
||||
onClick={handleInputCursor}
|
||||
onBlur={(): void => setAutoCompleteOpen(false)}
|
||||
/>
|
||||
@@ -146,7 +145,6 @@ export default function GeneralSettingsSection({
|
||||
bordered
|
||||
allowClear
|
||||
value={description}
|
||||
data-testid="panel-description-input"
|
||||
onChange={(event): void =>
|
||||
onChangeHandler(setDescription, event.target.value)
|
||||
}
|
||||
|
||||
@@ -249,7 +249,6 @@ function Threshold({
|
||||
<Input
|
||||
defaultValue={label}
|
||||
onChange={handleLabelChange}
|
||||
data-testid="threshold-label-input"
|
||||
bordered={!isDarkMode}
|
||||
className="label-input"
|
||||
/>
|
||||
@@ -276,7 +275,6 @@ function Threshold({
|
||||
onChange={handleTableOptionsChange}
|
||||
rootClassName="operator-input-root"
|
||||
className="operator-input"
|
||||
data-testid="table-operator-input-selector"
|
||||
/>
|
||||
<Typography.Text className="typography">is</Typography.Text>
|
||||
</Space>
|
||||
@@ -289,7 +287,6 @@ function Threshold({
|
||||
style={{ marginLeft: '10px' }}
|
||||
rootClassName="operator-input-root"
|
||||
className="operator-input"
|
||||
data-testid="operator-input-selector"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -324,7 +321,6 @@ function Threshold({
|
||||
defaultValue={value}
|
||||
onChange={handleValueChange}
|
||||
className="unit-input"
|
||||
data-testid="threshold-value-input"
|
||||
/>
|
||||
) : (
|
||||
<ShowCaseValue value={value} className="unit-input" />
|
||||
@@ -336,7 +332,6 @@ function Threshold({
|
||||
placeholder="Select unit"
|
||||
source={YAxisSource.DASHBOARDS}
|
||||
initialValue={unit}
|
||||
data-testid="threshold-unit-input"
|
||||
categoriesOverride={unitSelectCategories}
|
||||
containerClassName="unit-selection"
|
||||
/>
|
||||
@@ -354,7 +349,6 @@ function Threshold({
|
||||
defaultValue={format}
|
||||
options={showAsOptions}
|
||||
onChange={handlerFormatChange}
|
||||
data-testid="threshold-color-selector"
|
||||
rootClassName="color-format"
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -72,7 +72,6 @@ function ThresholdSelector({
|
||||
type="default"
|
||||
icon={<Plus size={14} />}
|
||||
style={{ width: '100%' }}
|
||||
data-testid="add-threshold-cta"
|
||||
onClick={addThresholdHandler}
|
||||
>
|
||||
Add Threshold
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,5 +4,4 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
export type QueryLabelProps = {
|
||||
onChange: (value: DataSource) => void;
|
||||
isListViewPanel?: boolean;
|
||||
'data-testid'?: string;
|
||||
} & Omit<SelectProps, 'onChange'>;
|
||||
|
||||
@@ -32,7 +32,6 @@ export const DataSourceDropdown = memo(function DataSourceDropdown(
|
||||
defaultValue={dataSourceOptions[0].value}
|
||||
options={dataSourceOptions}
|
||||
onChange={onChange}
|
||||
data-testid={props['data-testid']}
|
||||
value={value}
|
||||
style={style}
|
||||
/>
|
||||
|
||||
@@ -136,7 +136,6 @@ export default function QBEntityOptions({
|
||||
onChangeDataSource(value);
|
||||
}
|
||||
}}
|
||||
data-testid={`query-data-source-selector-${index}`}
|
||||
value={query?.dataSource || DataSource.METRICS}
|
||||
isListViewPanel={isListViewPanel}
|
||||
className="query-data-source-dropdown"
|
||||
|
||||
@@ -27,7 +27,6 @@ export type MetricNameSelectorProps = {
|
||||
defaultValue?: string;
|
||||
onSelect?: (value: BaseAutocompleteData) => void;
|
||||
signalSource?: 'meter' | '';
|
||||
'data-testid'?: string;
|
||||
};
|
||||
|
||||
function getAttributeType(
|
||||
@@ -82,7 +81,6 @@ export const MetricNameSelector = memo(function MetricNameSelector({
|
||||
defaultValue,
|
||||
onSelect,
|
||||
signalSource,
|
||||
'data-testid': dataTestId,
|
||||
}: MetricNameSelectorProps): JSX.Element {
|
||||
const currentMetricName =
|
||||
(query.aggregations?.[0] as MetricAggregation)?.metricName ||
|
||||
@@ -281,7 +279,6 @@ export const MetricNameSelector = memo(function MetricNameSelector({
|
||||
</Typography.Text>
|
||||
) : null
|
||||
}
|
||||
data-testid={dataTestId}
|
||||
options={optionsData}
|
||||
value={inputValue}
|
||||
onBlur={handleBlur}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -12,11 +12,7 @@
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"moduleResolution": "bundler",
|
||||
@@ -25,15 +21,8 @@
|
||||
"noEmit": true,
|
||||
"baseUrl": "./src",
|
||||
"paths": {
|
||||
"*": [
|
||||
"./*"
|
||||
],
|
||||
"@constants/*": [
|
||||
"./container/OnboardingContainer/constants/*"
|
||||
],
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
"*": ["./*"],
|
||||
"@constants/*": ["./container/OnboardingContainer/constants/*"]
|
||||
},
|
||||
"downlevelIteration": true,
|
||||
"plugins": [
|
||||
@@ -41,18 +30,9 @@
|
||||
"name": "typescript-plugin-css-modules"
|
||||
}
|
||||
],
|
||||
"types": [
|
||||
"vite/client",
|
||||
"node",
|
||||
"jest"
|
||||
]
|
||||
"types": ["vite/client", "node", "jest"]
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"src/parser/*.ts",
|
||||
"src/parser/TraceOperatorParser/*.ts",
|
||||
"orval.config.ts"
|
||||
],
|
||||
"exclude": ["node_modules", "src/parser/*.ts", "src/parser/TraceOperatorParser/*.ts", "orval.config.ts"],
|
||||
"include": [
|
||||
"./src",
|
||||
"./src/**/*.ts",
|
||||
|
||||
@@ -83,7 +83,6 @@ export default defineConfig(
|
||||
plugins,
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, './src'),
|
||||
utils: resolve(__dirname, './src/utils'),
|
||||
types: resolve(__dirname, './src/types'),
|
||||
constants: resolve(__dirname, './src/constants'),
|
||||
|
||||
4
go.mod
4
go.mod
@@ -15,7 +15,6 @@ require (
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/dgraph-io/ristretto/v2 v2.3.0
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/emersion/go-smtp v0.24.0
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/go-co-op/gocron v1.30.1
|
||||
github.com/go-openapi/runtime v0.29.2
|
||||
@@ -112,7 +111,6 @@ require (
|
||||
github.com/bytedance/sonic v1.14.1 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/go-openapi/swag/cmdutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.4 // indirect
|
||||
@@ -166,7 +164,7 @@ require (
|
||||
github.com/ClickHouse/ch-go v0.67.0 // indirect
|
||||
github.com/Masterminds/squirrel v1.5.4 // indirect
|
||||
github.com/Yiling-J/theine-go v0.6.2 // indirect
|
||||
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b
|
||||
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/armon/go-metrics v0.4.1 // indirect
|
||||
github.com/beevik/etree v1.1.0 // indirect
|
||||
|
||||
@@ -1,440 +0,0 @@
|
||||
// Copyright 2019 Prometheus Team
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package email
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"net/textproto"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
const (
|
||||
Integration = "email"
|
||||
)
|
||||
|
||||
// Email implements a Notifier for email notifications.
|
||||
type Email struct {
|
||||
conf *config.EmailConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
hostname string
|
||||
}
|
||||
|
||||
var errNoAuthUsernameConfigured = errors.NewInternalf(errors.CodeInternal, "no auth username configured")
|
||||
|
||||
// New returns a new Email notifier.
|
||||
func New(c *config.EmailConfig, t *template.Template, l *slog.Logger) *Email {
|
||||
if _, ok := c.Headers["Subject"]; !ok {
|
||||
c.Headers["Subject"] = config.DefaultEmailSubject
|
||||
}
|
||||
if _, ok := c.Headers["To"]; !ok {
|
||||
c.Headers["To"] = c.To
|
||||
}
|
||||
if _, ok := c.Headers["From"]; !ok {
|
||||
c.Headers["From"] = c.From
|
||||
}
|
||||
|
||||
h, err := os.Hostname()
|
||||
// If we can't get the hostname, we'll use localhost
|
||||
if err != nil {
|
||||
h = "localhost.localdomain"
|
||||
}
|
||||
return &Email{conf: c, tmpl: t, logger: l, hostname: h}
|
||||
}
|
||||
|
||||
// auth resolves a string of authentication mechanisms.
|
||||
func (n *Email) auth(mechs string) (smtp.Auth, error) {
|
||||
username := n.conf.AuthUsername
|
||||
|
||||
// If no username is set, return custom error which can be ignored if needed.
|
||||
if strings.TrimSpace(username) == "" {
|
||||
return nil, errNoAuthUsernameConfigured
|
||||
}
|
||||
|
||||
var errs error
|
||||
for mech := range strings.SplitSeq(mechs, " ") {
|
||||
switch mech {
|
||||
case "CRAM-MD5":
|
||||
secret, secretErr := n.getAuthSecret()
|
||||
if secretErr != nil {
|
||||
errs = errors.Join(errs, secretErr)
|
||||
continue
|
||||
}
|
||||
if secret == "" {
|
||||
errs = errors.Join(errs, errors.NewInternalf(errors.CodeInternal, "missing secret for CRAM-MD5 auth mechanism"))
|
||||
continue
|
||||
}
|
||||
return smtp.CRAMMD5Auth(username, secret), nil
|
||||
|
||||
case "PLAIN":
|
||||
password, passwordErr := n.getPassword()
|
||||
if passwordErr != nil {
|
||||
errs = errors.Join(errs, passwordErr)
|
||||
continue
|
||||
}
|
||||
if password == "" {
|
||||
errs = errors.Join(errs, errors.NewInternalf(errors.CodeInternal, "missing password for PLAIN auth mechanism"))
|
||||
continue
|
||||
}
|
||||
return smtp.PlainAuth(n.conf.AuthIdentity, username, password, n.conf.Smarthost.Host), nil
|
||||
case "LOGIN":
|
||||
password, passwordErr := n.getPassword()
|
||||
if passwordErr != nil {
|
||||
errs = errors.Join(errs, passwordErr)
|
||||
continue
|
||||
}
|
||||
if password == "" {
|
||||
errs = errors.Join(errs, errors.NewInternalf(errors.CodeInternal, "missing password for LOGIN auth mechanism"))
|
||||
continue
|
||||
}
|
||||
return LoginAuth(username, password), nil
|
||||
default:
|
||||
errs = errors.Join(errs, errors.NewInternalf(errors.CodeUnsupported, "unknown auth mechanism: %s", mech))
|
||||
}
|
||||
}
|
||||
return nil, errs
|
||||
}
|
||||
|
||||
// Notify implements the Notifier interface.
|
||||
func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
||||
var (
|
||||
c *smtp.Client
|
||||
conn net.Conn
|
||||
err error
|
||||
success = false
|
||||
)
|
||||
// Determine whether to use Implicit TLS
|
||||
var useImplicitTLS bool
|
||||
if n.conf.ForceImplicitTLS != nil {
|
||||
useImplicitTLS = *n.conf.ForceImplicitTLS
|
||||
} else {
|
||||
// Default logic: port 465 uses implicit TLS (backward compatibility)
|
||||
useImplicitTLS = n.conf.Smarthost.Port == "465"
|
||||
}
|
||||
|
||||
if useImplicitTLS {
|
||||
tlsConfig, err := commoncfg.NewTLSConfig(n.conf.TLSConfig)
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "parse TLS configuration")
|
||||
}
|
||||
if tlsConfig.ServerName == "" {
|
||||
tlsConfig.ServerName = n.conf.Smarthost.Host
|
||||
}
|
||||
|
||||
conn, err = tls.Dial("tcp", n.conf.Smarthost.String(), tlsConfig)
|
||||
if err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "establish TLS connection to server")
|
||||
}
|
||||
} else {
|
||||
var (
|
||||
d = net.Dialer{}
|
||||
err error
|
||||
)
|
||||
conn, err = d.DialContext(ctx, "tcp", n.conf.Smarthost.String())
|
||||
if err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "establish connection to server")
|
||||
}
|
||||
}
|
||||
c, err = smtp.NewClient(conn, n.conf.Smarthost.Host)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "create SMTP client")
|
||||
}
|
||||
defer func() {
|
||||
// Try to clean up after ourselves but don't log anything if something has failed.
|
||||
if err := c.Quit(); success && err != nil {
|
||||
n.logger.WarnContext(ctx, "failed to close SMTP connection", slog.Any("err", err))
|
||||
}
|
||||
}()
|
||||
|
||||
if n.conf.Hello != "" {
|
||||
err = c.Hello(n.conf.Hello)
|
||||
if err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "send EHLO command")
|
||||
}
|
||||
}
|
||||
|
||||
// Global Config guarantees RequireTLS is not nil.
|
||||
if *n.conf.RequireTLS && !useImplicitTLS {
|
||||
if ok, _ := c.Extension("STARTTLS"); !ok {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "'require_tls' is true (default) but %q does not advertise the STARTTLS extension", n.conf.Smarthost)
|
||||
}
|
||||
|
||||
tlsConf, err := commoncfg.NewTLSConfig(n.conf.TLSConfig)
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "parse TLS configuration")
|
||||
}
|
||||
if tlsConf.ServerName == "" {
|
||||
tlsConf.ServerName = n.conf.Smarthost.Host
|
||||
}
|
||||
|
||||
if err := c.StartTLS(tlsConf); err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "send STARTTLS command")
|
||||
}
|
||||
}
|
||||
|
||||
if ok, mech := c.Extension("AUTH"); ok {
|
||||
auth, err := n.auth(mech)
|
||||
if err != nil && err != errNoAuthUsernameConfigured {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "find auth mechanism")
|
||||
} else if err == errNoAuthUsernameConfigured {
|
||||
n.logger.DebugContext(ctx, "no auth username configured. Attempting to send email without authenticating")
|
||||
}
|
||||
if auth != nil {
|
||||
if err := c.Auth(auth); err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "%T auth", auth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
tmplErr error
|
||||
data = notify.GetTemplateData(ctx, n.tmpl, as, n.logger)
|
||||
tmpl = notify.TmplText(n.tmpl, data, &tmplErr)
|
||||
)
|
||||
from := tmpl(n.conf.From)
|
||||
if tmplErr != nil {
|
||||
return false, errors.WrapInternalf(tmplErr, errors.CodeInternal, "execute 'from' template")
|
||||
}
|
||||
to := tmpl(n.conf.To)
|
||||
if tmplErr != nil {
|
||||
return false, errors.WrapInternalf(tmplErr, errors.CodeInternal, "execute 'to' template")
|
||||
}
|
||||
|
||||
addrs, err := mail.ParseAddressList(from)
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "parse 'from' addresses")
|
||||
}
|
||||
if len(addrs) != 1 {
|
||||
return false, errors.NewInternalf(errors.CodeInternal, "must be exactly one 'from' address (got: %d)", len(addrs))
|
||||
}
|
||||
if err = c.Mail(addrs[0].Address); err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "send MAIL command")
|
||||
}
|
||||
addrs, err = mail.ParseAddressList(to)
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "parse 'to' addresses")
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
if err = c.Rcpt(addr.Address); err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "send RCPT command")
|
||||
}
|
||||
}
|
||||
|
||||
// Send the email headers and body.
|
||||
message, err := c.Data()
|
||||
if err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "send DATA command")
|
||||
}
|
||||
closeOnce := sync.OnceValue(func() error {
|
||||
return message.Close()
|
||||
})
|
||||
// Close the message when this method exits in order to not leak resources. Even though we're calling this explicitly
|
||||
// further down, the method may exit before then.
|
||||
defer func() {
|
||||
// If we try close an already-closed writer, it'll send a subsequent request to the server which is invalid.
|
||||
_ = closeOnce()
|
||||
}()
|
||||
|
||||
buffer := &bytes.Buffer{}
|
||||
for header, t := range n.conf.Headers {
|
||||
value, err := n.tmpl.ExecuteTextString(t, data)
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "execute %q header template", header)
|
||||
}
|
||||
fmt.Fprintf(buffer, "%s: %s\r\n", header, mime.QEncoding.Encode("utf-8", value))
|
||||
}
|
||||
|
||||
if _, ok := n.conf.Headers["Message-Id"]; !ok {
|
||||
fmt.Fprintf(buffer, "Message-Id: %s\r\n", fmt.Sprintf("<%d.%d@%s>", time.Now().UnixNano(), rand.Uint64(), n.hostname))
|
||||
}
|
||||
|
||||
if n.conf.Threading.Enabled {
|
||||
key, err := notify.ExtractGroupKey(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
// Add threading headers. All notifications for the same alert group
|
||||
// (identified by key hash) are threaded together.
|
||||
threadBy := ""
|
||||
if n.conf.Threading.ThreadByDate != "none" {
|
||||
// ThreadByDate is 'daily':
|
||||
// Use current date so all mails for this alert today thread together.
|
||||
threadBy = time.Now().Format("2006-01-02")
|
||||
}
|
||||
keyHash := key.Hash()
|
||||
if len(keyHash) > 16 {
|
||||
keyHash = keyHash[:16]
|
||||
}
|
||||
// The thread root ID is a Message-ID that doesn't correspond to
|
||||
// any actual email. Email clients following the (commonly used) JWZ
|
||||
// algorithm will create a dummy container to group these messages.
|
||||
threadRootID := fmt.Sprintf("<alert-%s-%s@alertmanager>", keyHash, threadBy)
|
||||
fmt.Fprintf(buffer, "References: %s\r\n", threadRootID)
|
||||
fmt.Fprintf(buffer, "In-Reply-To: %s\r\n", threadRootID)
|
||||
}
|
||||
|
||||
multipartBuffer := &bytes.Buffer{}
|
||||
multipartWriter := multipart.NewWriter(multipartBuffer)
|
||||
|
||||
fmt.Fprintf(buffer, "Date: %s\r\n", time.Now().Format(time.RFC1123Z))
|
||||
fmt.Fprintf(buffer, "Content-Type: multipart/alternative; boundary=%s\r\n", multipartWriter.Boundary())
|
||||
fmt.Fprintf(buffer, "MIME-Version: 1.0\r\n\r\n")
|
||||
|
||||
// TODO: Add some useful headers here, such as URL of the alertmanager
|
||||
// and active/resolved.
|
||||
_, err = message.Write(buffer.Bytes())
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "write headers")
|
||||
}
|
||||
|
||||
if len(n.conf.Text) > 0 {
|
||||
// Text template
|
||||
w, err := multipartWriter.CreatePart(textproto.MIMEHeader{
|
||||
"Content-Transfer-Encoding": {"quoted-printable"},
|
||||
"Content-Type": {"text/plain; charset=UTF-8"},
|
||||
})
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "create part for text template")
|
||||
}
|
||||
body, err := n.tmpl.ExecuteTextString(n.conf.Text, data)
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "execute text template")
|
||||
}
|
||||
qw := quotedprintable.NewWriter(w)
|
||||
_, err = qw.Write([]byte(body))
|
||||
if err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "write text part")
|
||||
}
|
||||
err = qw.Close()
|
||||
if err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "close text part")
|
||||
}
|
||||
}
|
||||
|
||||
if len(n.conf.HTML) > 0 {
|
||||
// Html template
|
||||
// Preferred alternative placed last per section 5.1.4 of RFC 2046
|
||||
// https://www.ietf.org/rfc/rfc2046.txt
|
||||
w, err := multipartWriter.CreatePart(textproto.MIMEHeader{
|
||||
"Content-Transfer-Encoding": {"quoted-printable"},
|
||||
"Content-Type": {"text/html; charset=UTF-8"},
|
||||
})
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "create part for html template")
|
||||
}
|
||||
body, err := n.tmpl.ExecuteHTMLString(n.conf.HTML, data)
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "execute html template")
|
||||
}
|
||||
qw := quotedprintable.NewWriter(w)
|
||||
_, err = qw.Write([]byte(body))
|
||||
if err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "write HTML part")
|
||||
}
|
||||
err = qw.Close()
|
||||
if err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "close HTML part")
|
||||
}
|
||||
}
|
||||
|
||||
err = multipartWriter.Close()
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "close multipartWriter")
|
||||
}
|
||||
|
||||
_, err = message.Write(multipartBuffer.Bytes())
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "write body buffer")
|
||||
}
|
||||
|
||||
// Complete the message and await response.
|
||||
if err = closeOnce(); err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "delivery failure")
|
||||
}
|
||||
|
||||
success = true
|
||||
return false, nil
|
||||
}
|
||||
|
||||
type loginAuth struct {
|
||||
username, password string
|
||||
}
|
||||
|
||||
func LoginAuth(username, password string) smtp.Auth {
|
||||
return &loginAuth{username, password}
|
||||
}
|
||||
|
||||
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
|
||||
return "LOGIN", []byte{}, nil
|
||||
}
|
||||
|
||||
// Used for AUTH LOGIN. (Maybe password should be encrypted).
|
||||
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
if more {
|
||||
switch strings.ToLower(string(fromServer)) {
|
||||
case "username:":
|
||||
return []byte(a.username), nil
|
||||
case "password:":
|
||||
return []byte(a.password), nil
|
||||
default:
|
||||
return nil, errors.NewInternalf(errors.CodeInternal, "unexpected server challenge")
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (n *Email) getPassword() (string, error) {
|
||||
if len(n.conf.AuthPasswordFile) > 0 {
|
||||
content, err := os.ReadFile(n.conf.AuthPasswordFile)
|
||||
if err != nil {
|
||||
return "", errors.NewInternalf(errors.CodeInternal, "could not read %s: %v", n.conf.AuthPasswordFile, err)
|
||||
}
|
||||
return strings.TrimSpace(string(content)), nil
|
||||
}
|
||||
return string(n.conf.AuthPassword), nil
|
||||
}
|
||||
|
||||
func (n *Email) getAuthSecret() (string, error) {
|
||||
if len(n.conf.AuthSecretFile) > 0 {
|
||||
content, err := os.ReadFile(n.conf.AuthSecretFile)
|
||||
if err != nil {
|
||||
return "", errors.NewInternalf(errors.CodeInternal, "could not read %s: %v", n.conf.AuthSecretFile, err)
|
||||
}
|
||||
return string(content), nil
|
||||
}
|
||||
return string(n.conf.AuthSecret), nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +0,0 @@
|
||||
smarthost: 127.0.0.1:1026
|
||||
server: http://127.0.0.1:1081/
|
||||
username: user
|
||||
password: pass
|
||||
@@ -1,4 +0,0 @@
|
||||
smarthost: maildev-auth:1025
|
||||
server: http://maildev-auth:1080/
|
||||
username: user
|
||||
password: pass
|
||||
@@ -1,2 +0,0 @@
|
||||
smarthost: 127.0.0.1:1025
|
||||
server: http://127.0.0.1:1080/
|
||||
@@ -1,2 +0,0 @@
|
||||
smarthost: maildev-noauth:1025
|
||||
server: http://maildev-noauth:1080/
|
||||
@@ -1,16 +1,3 @@
|
||||
// Copyright 2024 Prometheus Team
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package msteamsv2
|
||||
|
||||
import (
|
||||
@@ -40,10 +27,6 @@ const (
|
||||
colorGrey = "Warning"
|
||||
)
|
||||
|
||||
const (
|
||||
Integration = "msteamsv2"
|
||||
)
|
||||
|
||||
type Notifier struct {
|
||||
conf *config.MSTeamsV2Config
|
||||
titleLink string
|
||||
@@ -104,7 +87,7 @@ type teamsMessage struct {
|
||||
|
||||
// New returns a new notifier that uses the Microsoft Teams Power Platform connector.
|
||||
func New(c *config.MSTeamsV2Config, t *template.Template, titleLink string, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
|
||||
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
|
||||
client, err := commoncfg.NewClientFromConfig(*c.HTTPConfig, "msteamsv2", httpOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,16 +1,3 @@
|
||||
// Copyright 2024 Prometheus Team
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package msteamsv2
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
my_secret_api_key
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
// Copyright 2019 Prometheus Team
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package opsgenie
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
const (
|
||||
Integration = "opsgenie"
|
||||
)
|
||||
|
||||
// https://docs.opsgenie.com/docs/alert-api - 130 characters meaning runes.
|
||||
const maxMessageLenRunes = 130
|
||||
|
||||
// Notifier implements a Notifier for OpsGenie notifications.
|
||||
type Notifier struct {
|
||||
conf *config.OpsGenieConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
client *http.Client
|
||||
retrier *notify.Retrier
|
||||
}
|
||||
|
||||
// New returns a new OpsGenie notifier.
|
||||
func New(c *config.OpsGenieConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
|
||||
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Notifier{
|
||||
conf: c,
|
||||
tmpl: t,
|
||||
logger: l,
|
||||
client: client,
|
||||
retrier: ¬ify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type opsGenieCreateMessage struct {
|
||||
Alias string `json:"alias"`
|
||||
Message string `json:"message"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Details map[string]string `json:"details"`
|
||||
Source string `json:"source"`
|
||||
Responders []opsGenieCreateMessageResponder `json:"responders,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
Priority string `json:"priority,omitempty"`
|
||||
Entity string `json:"entity,omitempty"`
|
||||
Actions []string `json:"actions,omitempty"`
|
||||
}
|
||||
|
||||
type opsGenieCreateMessageResponder struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Type string `json:"type"` // team, user, escalation, schedule etc.
|
||||
}
|
||||
|
||||
type opsGenieCloseMessage struct {
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type opsGenieUpdateMessageMessage struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type opsGenieUpdateDescriptionMessage struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// Notify implements the Notifier interface.
|
||||
func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
||||
requests, retry, err := n.createRequests(ctx, as...)
|
||||
if err != nil {
|
||||
return retry, err
|
||||
}
|
||||
|
||||
for _, req := range requests {
|
||||
req.Header.Set("User-Agent", notify.UserAgentHeader)
|
||||
resp, err := n.client.Do(req) //nolint:bodyclose
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
shouldRetry, err := n.retrier.Check(resp.StatusCode, resp.Body)
|
||||
notify.Drain(resp)
|
||||
if err != nil {
|
||||
return shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Like Split but filter out empty strings.
|
||||
func safeSplit(s, sep string) []string {
|
||||
a := strings.Split(strings.TrimSpace(s), sep)
|
||||
b := a[:0]
|
||||
for _, x := range a {
|
||||
if x != "" {
|
||||
b = append(b, x)
|
||||
}
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Create requests for a list of alerts.
|
||||
func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*http.Request, bool, error) {
|
||||
key, err := notify.ExtractGroupKey(ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
logger := n.logger.With(slog.Any("group_key", key))
|
||||
logger.DebugContext(ctx, "extracted group key")
|
||||
|
||||
data := notify.GetTemplateData(ctx, n.tmpl, as, logger)
|
||||
|
||||
tmpl := notify.TmplText(n.tmpl, data, &err)
|
||||
|
||||
details := make(map[string]string)
|
||||
|
||||
maps.Copy(details, data.CommonLabels)
|
||||
|
||||
for k, v := range n.conf.Details {
|
||||
details[k] = tmpl(v)
|
||||
}
|
||||
|
||||
requests := []*http.Request{}
|
||||
|
||||
var (
|
||||
alias = key.Hash()
|
||||
alerts = types.Alerts(as...)
|
||||
)
|
||||
switch alerts.Status() {
|
||||
case model.AlertResolved:
|
||||
resolvedEndpointURL := n.conf.APIURL.Copy()
|
||||
resolvedEndpointURL.Path += fmt.Sprintf("v2/alerts/%s/close", alias)
|
||||
q := resolvedEndpointURL.Query()
|
||||
q.Set("identifierType", "alias")
|
||||
resolvedEndpointURL.RawQuery = q.Encode()
|
||||
msg := &opsGenieCloseMessage{Source: tmpl(n.conf.Source)}
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(msg); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
req, err := http.NewRequest("POST", resolvedEndpointURL.String(), &buf)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
requests = append(requests, req.WithContext(ctx))
|
||||
default:
|
||||
message, truncated := notify.TruncateInRunes(tmpl(n.conf.Message), maxMessageLenRunes)
|
||||
if truncated {
|
||||
logger.WarnContext(ctx, "Truncated message", slog.Any("alert", key), slog.Int("max_runes", maxMessageLenRunes))
|
||||
}
|
||||
|
||||
createEndpointURL := n.conf.APIURL.Copy()
|
||||
createEndpointURL.Path += "v2/alerts"
|
||||
|
||||
var responders []opsGenieCreateMessageResponder
|
||||
for _, r := range n.conf.Responders {
|
||||
responder := opsGenieCreateMessageResponder{
|
||||
ID: tmpl(r.ID),
|
||||
Name: tmpl(r.Name),
|
||||
Username: tmpl(r.Username),
|
||||
Type: tmpl(r.Type),
|
||||
}
|
||||
|
||||
if responder == (opsGenieCreateMessageResponder{}) {
|
||||
// Filter out empty responders. This is useful if you want to fill
|
||||
// responders dynamically from alert's common labels.
|
||||
continue
|
||||
}
|
||||
|
||||
if responder.Type == "teams" {
|
||||
teams := safeSplit(responder.Name, ",")
|
||||
for _, team := range teams {
|
||||
newResponder := opsGenieCreateMessageResponder{
|
||||
Name: tmpl(team),
|
||||
Type: tmpl("team"),
|
||||
}
|
||||
responders = append(responders, newResponder)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
responders = append(responders, responder)
|
||||
}
|
||||
|
||||
msg := &opsGenieCreateMessage{
|
||||
Alias: alias,
|
||||
Message: message,
|
||||
Description: tmpl(n.conf.Description),
|
||||
Details: details,
|
||||
Source: tmpl(n.conf.Source),
|
||||
Responders: responders,
|
||||
Tags: safeSplit(tmpl(n.conf.Tags), ","),
|
||||
Note: tmpl(n.conf.Note),
|
||||
Priority: tmpl(n.conf.Priority),
|
||||
Entity: tmpl(n.conf.Entity),
|
||||
Actions: safeSplit(tmpl(n.conf.Actions), ","),
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(msg); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
req, err := http.NewRequest("POST", createEndpointURL.String(), &buf)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
requests = append(requests, req.WithContext(ctx))
|
||||
|
||||
if n.conf.UpdateAlerts {
|
||||
updateMessageEndpointURL := n.conf.APIURL.Copy()
|
||||
updateMessageEndpointURL.Path += fmt.Sprintf("v2/alerts/%s/message", alias)
|
||||
q := updateMessageEndpointURL.Query()
|
||||
q.Set("identifierType", "alias")
|
||||
updateMessageEndpointURL.RawQuery = q.Encode()
|
||||
updateMsgMsg := &opsGenieUpdateMessageMessage{
|
||||
Message: msg.Message,
|
||||
}
|
||||
var updateMessageBuf bytes.Buffer
|
||||
if err := json.NewEncoder(&updateMessageBuf).Encode(updateMsgMsg); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
req, err := http.NewRequest("PUT", updateMessageEndpointURL.String(), &updateMessageBuf)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
requests = append(requests, req)
|
||||
|
||||
updateDescriptionEndpointURL := n.conf.APIURL.Copy()
|
||||
updateDescriptionEndpointURL.Path += fmt.Sprintf("v2/alerts/%s/description", alias)
|
||||
q = updateDescriptionEndpointURL.Query()
|
||||
q.Set("identifierType", "alias")
|
||||
updateDescriptionEndpointURL.RawQuery = q.Encode()
|
||||
updateDescMsg := &opsGenieUpdateDescriptionMessage{
|
||||
Description: msg.Description,
|
||||
}
|
||||
|
||||
var updateDescriptionBuf bytes.Buffer
|
||||
if err := json.NewEncoder(&updateDescriptionBuf).Encode(updateDescMsg); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
req, err = http.NewRequest("PUT", updateDescriptionEndpointURL.String(), &updateDescriptionBuf)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
requests = append(requests, req.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
var apiKey string
|
||||
if n.conf.APIKey != "" {
|
||||
apiKey = tmpl(string(n.conf.APIKey))
|
||||
} else {
|
||||
content, err := os.ReadFile(n.conf.APIKeyFile)
|
||||
if err != nil {
|
||||
return nil, false, errors.WrapInternalf(err, errors.CodeInternal, "read key_file error")
|
||||
}
|
||||
apiKey = tmpl(string(content))
|
||||
apiKey = strings.TrimSpace(string(apiKey))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, false, errors.WrapInternalf(err, errors.CodeInternal, "templating error")
|
||||
}
|
||||
|
||||
for _, req := range requests {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", fmt.Sprintf("GenieKey %s", apiKey))
|
||||
}
|
||||
|
||||
return requests, true, nil
|
||||
}
|
||||
@@ -1,346 +0,0 @@
|
||||
// Copyright 2019 Prometheus Team
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package opsgenie
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/prometheus/common/promslog"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/notify/test"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
func TestOpsGenieRetry(t *testing.T) {
|
||||
notifier, err := New(
|
||||
&config.OpsGenieConfig{
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
retryCodes := append(test.DefaultRetryCodes(), http.StatusTooManyRequests)
|
||||
for statusCode, expected := range test.RetryTests(retryCodes) {
|
||||
actual, _ := notifier.retrier.Check(statusCode, nil)
|
||||
require.Equal(t, expected, actual, "error on status %d", statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsGenieRedactedURL(t *testing.T) {
|
||||
ctx, u, fn := test.GetContextWithCancelingURL()
|
||||
defer fn()
|
||||
|
||||
key := "key"
|
||||
notifier, err := New(
|
||||
&config.OpsGenieConfig{
|
||||
APIURL: &config.URL{URL: u},
|
||||
APIKey: config.Secret(key),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, key)
|
||||
}
|
||||
|
||||
func TestGettingOpsGegineApikeyFromFile(t *testing.T) {
|
||||
ctx, u, fn := test.GetContextWithCancelingURL()
|
||||
defer fn()
|
||||
|
||||
key := "key"
|
||||
|
||||
f, err := os.CreateTemp(t.TempDir(), "opsgenie_test")
|
||||
require.NoError(t, err, "creating temp file failed")
|
||||
_, err = f.WriteString(key)
|
||||
require.NoError(t, err, "writing to temp file failed")
|
||||
|
||||
notifier, err := New(
|
||||
&config.OpsGenieConfig{
|
||||
APIURL: &config.URL{URL: u},
|
||||
APIKeyFile: f.Name(),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, key)
|
||||
}
|
||||
|
||||
func TestOpsGenie(t *testing.T) {
|
||||
u, err := url.Parse("https://opsgenie/api")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse URL: %v", err)
|
||||
}
|
||||
logger := promslog.NewNopLogger()
|
||||
tmpl := test.CreateTmpl(t)
|
||||
|
||||
for _, tc := range []struct {
|
||||
title string
|
||||
cfg *config.OpsGenieConfig
|
||||
|
||||
expectedEmptyAlertBody string
|
||||
expectedBody string
|
||||
}{
|
||||
{
|
||||
title: "config without details",
|
||||
cfg: &config.OpsGenieConfig{
|
||||
NotifierConfig: config.NotifierConfig{
|
||||
VSendResolved: true,
|
||||
},
|
||||
Message: `{{ .CommonLabels.Message }}`,
|
||||
Description: `{{ .CommonLabels.Description }}`,
|
||||
Source: `{{ .CommonLabels.Source }}`,
|
||||
Responders: []config.OpsGenieConfigResponder{
|
||||
{
|
||||
Name: `{{ .CommonLabels.ResponderName1 }}`,
|
||||
Type: `{{ .CommonLabels.ResponderType1 }}`,
|
||||
},
|
||||
{
|
||||
Name: `{{ .CommonLabels.ResponderName2 }}`,
|
||||
Type: `{{ .CommonLabels.ResponderType2 }}`,
|
||||
},
|
||||
},
|
||||
Tags: `{{ .CommonLabels.Tags }}`,
|
||||
Note: `{{ .CommonLabels.Note }}`,
|
||||
Priority: `{{ .CommonLabels.Priority }}`,
|
||||
Entity: `{{ .CommonLabels.Entity }}`,
|
||||
Actions: `{{ .CommonLabels.Actions }}`,
|
||||
APIKey: `{{ .ExternalURL }}`,
|
||||
APIURL: &config.URL{URL: u},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
expectedEmptyAlertBody: `{"alias":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b","message":"","details":{},"source":""}
|
||||
`,
|
||||
expectedBody: `{"alias":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b","message":"message","description":"description","details":{"Actions":"doThis,doThat","Description":"description","Entity":"test-domain","Message":"message","Note":"this is a note","Priority":"P1","ResponderName1":"TeamA","ResponderName2":"EscalationA","ResponderName3":"TeamA,TeamB","ResponderType1":"team","ResponderType2":"escalation","ResponderType3":"teams","Source":"http://prometheus","Tags":"tag1,tag2"},"source":"http://prometheus","responders":[{"name":"TeamA","type":"team"},{"name":"EscalationA","type":"escalation"}],"tags":["tag1","tag2"],"note":"this is a note","priority":"P1","entity":"test-domain","actions":["doThis","doThat"]}
|
||||
`,
|
||||
},
|
||||
{
|
||||
title: "config with details",
|
||||
cfg: &config.OpsGenieConfig{
|
||||
NotifierConfig: config.NotifierConfig{
|
||||
VSendResolved: true,
|
||||
},
|
||||
Message: `{{ .CommonLabels.Message }}`,
|
||||
Description: `{{ .CommonLabels.Description }}`,
|
||||
Source: `{{ .CommonLabels.Source }}`,
|
||||
Details: map[string]string{
|
||||
"Description": `adjusted {{ .CommonLabels.Description }}`,
|
||||
},
|
||||
Responders: []config.OpsGenieConfigResponder{
|
||||
{
|
||||
Name: `{{ .CommonLabels.ResponderName1 }}`,
|
||||
Type: `{{ .CommonLabels.ResponderType1 }}`,
|
||||
},
|
||||
{
|
||||
Name: `{{ .CommonLabels.ResponderName2 }}`,
|
||||
Type: `{{ .CommonLabels.ResponderType2 }}`,
|
||||
},
|
||||
},
|
||||
Tags: `{{ .CommonLabels.Tags }}`,
|
||||
Note: `{{ .CommonLabels.Note }}`,
|
||||
Priority: `{{ .CommonLabels.Priority }}`,
|
||||
Entity: `{{ .CommonLabels.Entity }}`,
|
||||
Actions: `{{ .CommonLabels.Actions }}`,
|
||||
APIKey: `{{ .ExternalURL }}`,
|
||||
APIURL: &config.URL{URL: u},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
expectedEmptyAlertBody: `{"alias":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b","message":"","details":{"Description":"adjusted "},"source":""}
|
||||
`,
|
||||
expectedBody: `{"alias":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b","message":"message","description":"description","details":{"Actions":"doThis,doThat","Description":"adjusted description","Entity":"test-domain","Message":"message","Note":"this is a note","Priority":"P1","ResponderName1":"TeamA","ResponderName2":"EscalationA","ResponderName3":"TeamA,TeamB","ResponderType1":"team","ResponderType2":"escalation","ResponderType3":"teams","Source":"http://prometheus","Tags":"tag1,tag2"},"source":"http://prometheus","responders":[{"name":"TeamA","type":"team"},{"name":"EscalationA","type":"escalation"}],"tags":["tag1","tag2"],"note":"this is a note","priority":"P1","entity":"test-domain","actions":["doThis","doThat"]}
|
||||
`,
|
||||
},
|
||||
{
|
||||
title: "config with multiple teams",
|
||||
cfg: &config.OpsGenieConfig{
|
||||
NotifierConfig: config.NotifierConfig{
|
||||
VSendResolved: true,
|
||||
},
|
||||
Message: `{{ .CommonLabels.Message }}`,
|
||||
Description: `{{ .CommonLabels.Description }}`,
|
||||
Source: `{{ .CommonLabels.Source }}`,
|
||||
Details: map[string]string{
|
||||
"Description": `adjusted {{ .CommonLabels.Description }}`,
|
||||
},
|
||||
Responders: []config.OpsGenieConfigResponder{
|
||||
{
|
||||
Name: `{{ .CommonLabels.ResponderName3 }}`,
|
||||
Type: `{{ .CommonLabels.ResponderType3 }}`,
|
||||
},
|
||||
},
|
||||
Tags: `{{ .CommonLabels.Tags }}`,
|
||||
Note: `{{ .CommonLabels.Note }}`,
|
||||
Priority: `{{ .CommonLabels.Priority }}`,
|
||||
APIKey: `{{ .ExternalURL }}`,
|
||||
APIURL: &config.URL{URL: u},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
expectedEmptyAlertBody: `{"alias":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b","message":"","details":{"Description":"adjusted "},"source":""}
|
||||
`,
|
||||
expectedBody: `{"alias":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b","message":"message","description":"description","details":{"Actions":"doThis,doThat","Description":"adjusted description","Entity":"test-domain","Message":"message","Note":"this is a note","Priority":"P1","ResponderName1":"TeamA","ResponderName2":"EscalationA","ResponderName3":"TeamA,TeamB","ResponderType1":"team","ResponderType2":"escalation","ResponderType3":"teams","Source":"http://prometheus","Tags":"tag1,tag2"},"source":"http://prometheus","responders":[{"name":"TeamA","type":"team"},{"name":"TeamB","type":"team"}],"tags":["tag1","tag2"],"note":"this is a note","priority":"P1"}
|
||||
`,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.title, func(t *testing.T) {
|
||||
notifier, err := New(tc.cfg, tmpl, logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "1")
|
||||
|
||||
expectedURL, _ := url.Parse("https://opsgenie/apiv2/alerts")
|
||||
|
||||
// Empty alert.
|
||||
alert1 := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
}
|
||||
|
||||
req, retry, err := notifier.createRequests(ctx, alert1)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, req, 1)
|
||||
require.True(t, retry)
|
||||
require.Equal(t, expectedURL, req[0].URL)
|
||||
require.Equal(t, "GenieKey http://am", req[0].Header.Get("Authorization"))
|
||||
require.Equal(t, tc.expectedEmptyAlertBody, readBody(t, req[0]))
|
||||
|
||||
// Fully defined alert.
|
||||
alert2 := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{
|
||||
"Message": "message",
|
||||
"Description": "description",
|
||||
"Source": "http://prometheus",
|
||||
"ResponderName1": "TeamA",
|
||||
"ResponderType1": "team",
|
||||
"ResponderName2": "EscalationA",
|
||||
"ResponderType2": "escalation",
|
||||
"ResponderName3": "TeamA,TeamB",
|
||||
"ResponderType3": "teams",
|
||||
"Tags": "tag1,tag2",
|
||||
"Note": "this is a note",
|
||||
"Priority": "P1",
|
||||
"Entity": "test-domain",
|
||||
"Actions": "doThis,doThat",
|
||||
},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
}
|
||||
req, retry, err = notifier.createRequests(ctx, alert2)
|
||||
require.NoError(t, err)
|
||||
require.True(t, retry)
|
||||
require.Len(t, req, 1)
|
||||
require.Equal(t, tc.expectedBody, readBody(t, req[0]))
|
||||
|
||||
// Broken API Key Template.
|
||||
tc.cfg.APIKey = "{{ kaput "
|
||||
_, _, err = notifier.createRequests(ctx, alert2)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "template: :1: function \"kaput\" not defined", err.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsGenieWithUpdate(t *testing.T) {
|
||||
u, err := url.Parse("https://test-opsgenie-url")
|
||||
require.NoError(t, err)
|
||||
tmpl := test.CreateTmpl(t)
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "1")
|
||||
opsGenieConfigWithUpdate := config.OpsGenieConfig{
|
||||
Message: `{{ .CommonLabels.Message }}`,
|
||||
Description: `{{ .CommonLabels.Description }}`,
|
||||
UpdateAlerts: true,
|
||||
APIKey: "test-api-key",
|
||||
APIURL: &config.URL{URL: u},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
}
|
||||
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger())
|
||||
alert := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
Labels: model.LabelSet{
|
||||
"Message": "new message",
|
||||
"Description": "new description",
|
||||
},
|
||||
},
|
||||
}
|
||||
require.NoError(t, err)
|
||||
requests, retry, err := notifierWithUpdate.createRequests(ctx, alert)
|
||||
require.NoError(t, err)
|
||||
require.True(t, retry)
|
||||
require.Len(t, requests, 3)
|
||||
|
||||
body0 := readBody(t, requests[0])
|
||||
body1 := readBody(t, requests[1])
|
||||
body2 := readBody(t, requests[2])
|
||||
key, _ := notify.ExtractGroupKey(ctx)
|
||||
alias := key.Hash()
|
||||
|
||||
require.Equal(t, "https://test-opsgenie-url/v2/alerts", requests[0].URL.String())
|
||||
require.NotEmpty(t, body0)
|
||||
|
||||
require.Equal(t, requests[1].URL.String(), fmt.Sprintf("https://test-opsgenie-url/v2/alerts/%s/message?identifierType=alias", alias))
|
||||
require.JSONEq(t, `{"message":"new message"}`, body1)
|
||||
require.Equal(t, requests[2].URL.String(), fmt.Sprintf("https://test-opsgenie-url/v2/alerts/%s/description?identifierType=alias", alias))
|
||||
require.JSONEq(t, `{"description":"new description"}`, body2)
|
||||
}
|
||||
|
||||
func TestOpsGenieApiKeyFile(t *testing.T) {
|
||||
u, err := url.Parse("https://test-opsgenie-url")
|
||||
require.NoError(t, err)
|
||||
tmpl := test.CreateTmpl(t)
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "1")
|
||||
opsGenieConfigWithUpdate := config.OpsGenieConfig{
|
||||
APIKeyFile: `./api_key_file`,
|
||||
APIURL: &config.URL{URL: u},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
}
|
||||
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger())
|
||||
|
||||
require.NoError(t, err)
|
||||
requests, _, err := notifierWithUpdate.createRequests(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "GenieKey my_secret_api_key", requests[0].Header.Get("Authorization"))
|
||||
}
|
||||
|
||||
func readBody(t *testing.T, r *http.Request) string {
|
||||
t.Helper()
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
return string(body)
|
||||
}
|
||||
@@ -1,387 +0,0 @@
|
||||
// Copyright 2019 Prometheus Team
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package pagerduty
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/alecthomas/units"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
const (
|
||||
Integration = "pagerduty"
|
||||
)
|
||||
|
||||
const (
|
||||
maxEventSize int = 512000
|
||||
// https://developer.pagerduty.com/docs/ZG9jOjExMDI5NTc4-send-a-v1-event - 1024 characters or runes.
|
||||
maxV1DescriptionLenRunes = 1024
|
||||
// https://developer.pagerduty.com/docs/ZG9jOjExMDI5NTgx-send-an-alert-event - 1024 characters or runes.
|
||||
maxV2SummaryLenRunes = 1024
|
||||
)
|
||||
|
||||
// Notifier implements a Notifier for PagerDuty notifications.
|
||||
type Notifier struct {
|
||||
conf *config.PagerdutyConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
apiV1 string // for tests.
|
||||
client *http.Client
|
||||
retrier *notify.Retrier
|
||||
}
|
||||
|
||||
// New returns a new PagerDuty notifier.
|
||||
func New(c *config.PagerdutyConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
|
||||
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := &Notifier{conf: c, tmpl: t, logger: l, client: client}
|
||||
if c.ServiceKey != "" || c.ServiceKeyFile != "" {
|
||||
n.apiV1 = "https://events.pagerduty.com/generic/2010-04-15/create_event.json"
|
||||
// Retrying can solve the issue on 403 (rate limiting) and 5xx response codes.
|
||||
// https://developer.pagerduty.com/docs/events-api-v1-overview#api-response-codes--retry-logic
|
||||
n.retrier = ¬ify.Retrier{RetryCodes: []int{http.StatusForbidden}, CustomDetailsFunc: errDetails}
|
||||
} else {
|
||||
// Retrying can solve the issue on 429 (rate limiting) and 5xx response codes.
|
||||
// https://developer.pagerduty.com/docs/events-api-v2-overview#response-codes--retry-logic
|
||||
n.retrier = ¬ify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}, CustomDetailsFunc: errDetails}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
const (
|
||||
pagerDutyEventTrigger = "trigger"
|
||||
pagerDutyEventResolve = "resolve"
|
||||
)
|
||||
|
||||
type pagerDutyMessage struct {
|
||||
RoutingKey string `json:"routing_key,omitempty"`
|
||||
ServiceKey string `json:"service_key,omitempty"`
|
||||
DedupKey string `json:"dedup_key,omitempty"`
|
||||
IncidentKey string `json:"incident_key,omitempty"`
|
||||
EventType string `json:"event_type,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
EventAction string `json:"event_action"`
|
||||
Payload *pagerDutyPayload `json:"payload"`
|
||||
Client string `json:"client,omitempty"`
|
||||
ClientURL string `json:"client_url,omitempty"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
Images []pagerDutyImage `json:"images,omitempty"`
|
||||
Links []pagerDutyLink `json:"links,omitempty"`
|
||||
}
|
||||
|
||||
type pagerDutyLink struct {
|
||||
HRef string `json:"href"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type pagerDutyImage struct {
|
||||
Src string `json:"src"`
|
||||
Alt string `json:"alt"`
|
||||
Href string `json:"href"`
|
||||
}
|
||||
|
||||
type pagerDutyPayload struct {
|
||||
Summary string `json:"summary"`
|
||||
Source string `json:"source"`
|
||||
Severity string `json:"severity"`
|
||||
Timestamp string `json:"timestamp,omitempty"`
|
||||
Class string `json:"class,omitempty"`
|
||||
Component string `json:"component,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
CustomDetails map[string]any `json:"custom_details,omitempty"`
|
||||
}
|
||||
|
||||
func (n *Notifier) encodeMessage(ctx context.Context, msg *pagerDutyMessage) (bytes.Buffer, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(msg); err != nil {
|
||||
return buf, errors.WrapInternalf(err, errors.CodeInternal, "failed to encode PagerDuty message")
|
||||
}
|
||||
|
||||
if buf.Len() > maxEventSize {
|
||||
truncatedMsg := fmt.Sprintf("Custom details have been removed because the original event exceeds the maximum size of %s", units.MetricBytes(maxEventSize).String())
|
||||
|
||||
if n.apiV1 != "" {
|
||||
msg.Details = map[string]any{"error": truncatedMsg}
|
||||
} else {
|
||||
msg.Payload.CustomDetails = map[string]any{"error": truncatedMsg}
|
||||
}
|
||||
|
||||
n.logger.WarnContext(ctx, "Truncated Details because message of size exceeds limit", slog.String("message_size", units.MetricBytes(buf.Len()).String()), slog.String("max_size", units.MetricBytes(maxEventSize).String()))
|
||||
|
||||
buf.Reset()
|
||||
if err := json.NewEncoder(&buf).Encode(msg); err != nil {
|
||||
return buf, errors.WrapInternalf(err, errors.CodeInternal, "failed to encode PagerDuty message")
|
||||
}
|
||||
}
|
||||
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
func (n *Notifier) notifyV1(
|
||||
ctx context.Context,
|
||||
eventType string,
|
||||
key notify.Key,
|
||||
data *template.Data,
|
||||
details map[string]any,
|
||||
) (bool, error) {
|
||||
var tmplErr error
|
||||
tmpl := notify.TmplText(n.tmpl, data, &tmplErr)
|
||||
|
||||
description, truncated := notify.TruncateInRunes(tmpl(n.conf.Description), maxV1DescriptionLenRunes)
|
||||
if truncated {
|
||||
n.logger.WarnContext(ctx, "Truncated description", slog.Any("key", key), slog.Int("max_runes", maxV1DescriptionLenRunes))
|
||||
}
|
||||
|
||||
serviceKey := string(n.conf.ServiceKey)
|
||||
if serviceKey == "" {
|
||||
content, fileErr := os.ReadFile(n.conf.ServiceKeyFile)
|
||||
if fileErr != nil {
|
||||
return false, errors.WrapInternalf(fileErr, errors.CodeInternal, "failed to read service key from file")
|
||||
}
|
||||
serviceKey = strings.TrimSpace(string(content))
|
||||
}
|
||||
|
||||
msg := &pagerDutyMessage{
|
||||
ServiceKey: tmpl(serviceKey),
|
||||
EventType: eventType,
|
||||
IncidentKey: key.Hash(),
|
||||
Description: description,
|
||||
Details: details,
|
||||
}
|
||||
|
||||
if eventType == pagerDutyEventTrigger {
|
||||
msg.Client = tmpl(n.conf.Client)
|
||||
msg.ClientURL = tmpl(n.conf.ClientURL)
|
||||
}
|
||||
|
||||
if tmplErr != nil {
|
||||
return false, errors.WrapInternalf(tmplErr, errors.CodeInternal, "failed to template PagerDuty v1 message")
|
||||
}
|
||||
|
||||
// Ensure that the service key isn't empty after templating.
|
||||
if msg.ServiceKey == "" {
|
||||
return false, errors.NewInternalf(errors.CodeInternal, "service key cannot be empty")
|
||||
}
|
||||
|
||||
encodedMsg, err := n.encodeMessage(ctx, msg)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
resp, err := notify.PostJSON(ctx, n.client, n.apiV1, &encodedMsg) //nolint:bodyclose
|
||||
if err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "failed to post message to PagerDuty v1")
|
||||
}
|
||||
defer notify.Drain(resp)
|
||||
|
||||
return n.retrier.Check(resp.StatusCode, resp.Body)
|
||||
}
|
||||
|
||||
func (n *Notifier) notifyV2(
|
||||
ctx context.Context,
|
||||
eventType string,
|
||||
key notify.Key,
|
||||
data *template.Data,
|
||||
details map[string]any,
|
||||
) (bool, error) {
|
||||
var tmplErr error
|
||||
tmpl := notify.TmplText(n.tmpl, data, &tmplErr)
|
||||
|
||||
if n.conf.Severity == "" {
|
||||
n.conf.Severity = "error"
|
||||
}
|
||||
|
||||
summary, truncated := notify.TruncateInRunes(tmpl(n.conf.Description), maxV2SummaryLenRunes)
|
||||
if truncated {
|
||||
n.logger.WarnContext(ctx, "Truncated summary", slog.Any("key", key), slog.Int("max_runes", maxV2SummaryLenRunes))
|
||||
}
|
||||
|
||||
routingKey := string(n.conf.RoutingKey)
|
||||
if routingKey == "" {
|
||||
content, fileErr := os.ReadFile(n.conf.RoutingKeyFile)
|
||||
if fileErr != nil {
|
||||
return false, errors.WrapInternalf(fileErr, errors.CodeInternal, "failed to read routing key from file")
|
||||
}
|
||||
routingKey = strings.TrimSpace(string(content))
|
||||
}
|
||||
|
||||
msg := &pagerDutyMessage{
|
||||
Client: tmpl(n.conf.Client),
|
||||
ClientURL: tmpl(n.conf.ClientURL),
|
||||
RoutingKey: tmpl(routingKey),
|
||||
EventAction: eventType,
|
||||
DedupKey: key.Hash(),
|
||||
Images: make([]pagerDutyImage, 0, len(n.conf.Images)),
|
||||
Links: make([]pagerDutyLink, 0, len(n.conf.Links)),
|
||||
Payload: &pagerDutyPayload{
|
||||
Summary: summary,
|
||||
Source: tmpl(n.conf.Source),
|
||||
Severity: tmpl(n.conf.Severity),
|
||||
CustomDetails: details,
|
||||
Class: tmpl(n.conf.Class),
|
||||
Component: tmpl(n.conf.Component),
|
||||
Group: tmpl(n.conf.Group),
|
||||
},
|
||||
}
|
||||
|
||||
for _, item := range n.conf.Images {
|
||||
image := pagerDutyImage{
|
||||
Src: tmpl(item.Src),
|
||||
Alt: tmpl(item.Alt),
|
||||
Href: tmpl(item.Href),
|
||||
}
|
||||
|
||||
if image.Src != "" {
|
||||
msg.Images = append(msg.Images, image)
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range n.conf.Links {
|
||||
link := pagerDutyLink{
|
||||
HRef: tmpl(item.Href),
|
||||
Text: tmpl(item.Text),
|
||||
}
|
||||
|
||||
if link.HRef != "" {
|
||||
msg.Links = append(msg.Links, link)
|
||||
}
|
||||
}
|
||||
|
||||
if tmplErr != nil {
|
||||
return false, errors.WrapInternalf(tmplErr, errors.CodeInternal, "failed to template PagerDuty v2 message")
|
||||
}
|
||||
|
||||
// Ensure that the routing key isn't empty after templating.
|
||||
if msg.RoutingKey == "" {
|
||||
return false, errors.NewInternalf(errors.CodeInternal, "routing key cannot be empty")
|
||||
}
|
||||
|
||||
encodedMsg, err := n.encodeMessage(ctx, msg)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
resp, err := notify.PostJSON(ctx, n.client, n.conf.URL.String(), &encodedMsg) //nolint:bodyclose
|
||||
if err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "failed to post message to PagerDuty")
|
||||
}
|
||||
defer notify.Drain(resp)
|
||||
|
||||
retry, err := n.retrier.Check(resp.StatusCode, resp.Body)
|
||||
if err != nil {
|
||||
return retry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err)
|
||||
}
|
||||
return retry, err
|
||||
}
|
||||
|
||||
// Notify implements the Notifier interface.
|
||||
func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
||||
key, err := notify.ExtractGroupKey(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
logger := n.logger.With(slog.Any("group_key", key))
|
||||
|
||||
var (
|
||||
alerts = types.Alerts(as...)
|
||||
data = notify.GetTemplateData(ctx, n.tmpl, as, logger)
|
||||
eventType = pagerDutyEventTrigger
|
||||
)
|
||||
|
||||
if alerts.Status() == model.AlertResolved {
|
||||
eventType = pagerDutyEventResolve
|
||||
}
|
||||
|
||||
logger.DebugContext(ctx, "extracted group key", slog.String("event_type", eventType))
|
||||
|
||||
details, err := n.renderDetails(data)
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "failed to render details: %v", err)
|
||||
}
|
||||
|
||||
if n.conf.Timeout > 0 {
|
||||
nfCtx, cancel := context.WithTimeoutCause(ctx, n.conf.Timeout, errors.NewInternalf(errors.CodeTimeout, "configured pagerduty timeout reached (%s)", n.conf.Timeout))
|
||||
defer cancel()
|
||||
ctx = nfCtx
|
||||
}
|
||||
|
||||
nf := n.notifyV2
|
||||
if n.apiV1 != "" {
|
||||
nf = n.notifyV1
|
||||
}
|
||||
retry, err := nf(ctx, eventType, key, data, details)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
err = errors.WrapInternalf(err, errors.CodeInternal, "failed to notify PagerDuty: %v", context.Cause(ctx))
|
||||
}
|
||||
return retry, err
|
||||
}
|
||||
return retry, nil
|
||||
}
|
||||
|
||||
func errDetails(status int, body io.Reader) string {
|
||||
// See https://v2.developer.pagerduty.com/docs/trigger-events for the v1 events API.
|
||||
// See https://v2.developer.pagerduty.com/docs/send-an-event-events-api-v2 for the v2 events API.
|
||||
if status != http.StatusBadRequest || body == nil {
|
||||
return ""
|
||||
}
|
||||
var pgr struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
if err := json.NewDecoder(body).Decode(&pgr); err != nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", pgr.Message, strings.Join(pgr.Errors, ","))
|
||||
}
|
||||
|
||||
func (n *Notifier) renderDetails(
|
||||
data *template.Data,
|
||||
) (map[string]any, error) {
|
||||
var (
|
||||
tmplTextErr error
|
||||
tmplText = notify.TmplText(n.tmpl, data, &tmplTextErr)
|
||||
tmplTextFunc = func(tmpl string) (string, error) {
|
||||
return tmplText(tmpl), tmplTextErr
|
||||
}
|
||||
)
|
||||
var err error
|
||||
rendered := make(map[string]any, len(n.conf.Details))
|
||||
for k, v := range n.conf.Details {
|
||||
rendered[k], err = template.DeepCopyWithTemplate(v, tmplTextFunc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return rendered, nil
|
||||
}
|
||||
@@ -1,892 +0,0 @@
|
||||
// Copyright 2019 Prometheus Team
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package pagerduty
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/prometheus/common/promslog"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/notify/test"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
func TestPagerDutyRetryV1(t *testing.T) {
|
||||
notifier, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
ServiceKey: config.Secret("01234567890123456789012345678901"),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
retryCodes := append(test.DefaultRetryCodes(), http.StatusForbidden)
|
||||
for statusCode, expected := range test.RetryTests(retryCodes) {
|
||||
actual, _ := notifier.retrier.Check(statusCode, nil)
|
||||
require.Equal(t, expected, actual, "retryv1 - error on status %d", statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagerDutyRetryV2(t *testing.T) {
|
||||
notifier, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
RoutingKey: config.Secret("01234567890123456789012345678901"),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
retryCodes := append(test.DefaultRetryCodes(), http.StatusTooManyRequests)
|
||||
for statusCode, expected := range test.RetryTests(retryCodes) {
|
||||
actual, _ := notifier.retrier.Check(statusCode, nil)
|
||||
require.Equal(t, expected, actual, "retryv2 - error on status %d", statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagerDutyRedactedURLV1(t *testing.T) {
|
||||
ctx, u, fn := test.GetContextWithCancelingURL()
|
||||
defer fn()
|
||||
|
||||
key := "01234567890123456789012345678901"
|
||||
notifier, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
ServiceKey: config.Secret(key),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
notifier.apiV1 = u.String()
|
||||
|
||||
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, key)
|
||||
}
|
||||
|
||||
func TestPagerDutyRedactedURLV2(t *testing.T) {
|
||||
ctx, u, fn := test.GetContextWithCancelingURL()
|
||||
defer fn()
|
||||
|
||||
key := "01234567890123456789012345678901"
|
||||
notifier, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
URL: &config.URL{URL: u},
|
||||
RoutingKey: config.Secret(key),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, key)
|
||||
}
|
||||
|
||||
func TestPagerDutyV1ServiceKeyFromFile(t *testing.T) {
|
||||
key := "01234567890123456789012345678901"
|
||||
f, err := os.CreateTemp(t.TempDir(), "pagerduty_test")
|
||||
require.NoError(t, err, "creating temp file failed")
|
||||
_, err = f.WriteString(key)
|
||||
require.NoError(t, err, "writing to temp file failed")
|
||||
|
||||
ctx, u, fn := test.GetContextWithCancelingURL()
|
||||
defer fn()
|
||||
|
||||
notifier, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
ServiceKeyFile: f.Name(),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
notifier.apiV1 = u.String()
|
||||
|
||||
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, key)
|
||||
}
|
||||
|
||||
func TestPagerDutyV2RoutingKeyFromFile(t *testing.T) {
|
||||
key := "01234567890123456789012345678901"
|
||||
f, err := os.CreateTemp(t.TempDir(), "pagerduty_test")
|
||||
require.NoError(t, err, "creating temp file failed")
|
||||
_, err = f.WriteString(key)
|
||||
require.NoError(t, err, "writing to temp file failed")
|
||||
|
||||
ctx, u, fn := test.GetContextWithCancelingURL()
|
||||
defer fn()
|
||||
|
||||
notifier, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
URL: &config.URL{URL: u},
|
||||
RoutingKeyFile: f.Name(),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, key)
|
||||
}
|
||||
|
||||
func TestPagerDutyTemplating(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
out := make(map[string]any)
|
||||
err := dec.Decode(&out)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
u, _ := url.Parse(srv.URL)
|
||||
|
||||
for _, tc := range []struct {
|
||||
title string
|
||||
cfg *config.PagerdutyConfig
|
||||
|
||||
retry bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
title: "full-blown legacy message",
|
||||
cfg: &config.PagerdutyConfig{
|
||||
RoutingKey: config.Secret("01234567890123456789012345678901"),
|
||||
Images: []config.PagerdutyImage{
|
||||
{
|
||||
Src: "{{ .Status }}",
|
||||
Alt: "{{ .Status }}",
|
||||
Href: "{{ .Status }}",
|
||||
},
|
||||
},
|
||||
Links: []config.PagerdutyLink{
|
||||
{
|
||||
Href: "{{ .Status }}",
|
||||
Text: "{{ .Status }}",
|
||||
},
|
||||
},
|
||||
Details: map[string]any{
|
||||
"firing": `{{ .Alerts.Firing | toJson }}`,
|
||||
"resolved": `{{ .Alerts.Resolved | toJson }}`,
|
||||
"num_firing": `{{ .Alerts.Firing | len }}`,
|
||||
"num_resolved": `{{ .Alerts.Resolved | len }}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "full-blown legacy message",
|
||||
cfg: &config.PagerdutyConfig{
|
||||
RoutingKey: config.Secret("01234567890123456789012345678901"),
|
||||
Images: []config.PagerdutyImage{
|
||||
{
|
||||
Src: "{{ .Status }}",
|
||||
Alt: "{{ .Status }}",
|
||||
Href: "{{ .Status }}",
|
||||
},
|
||||
},
|
||||
Links: []config.PagerdutyLink{
|
||||
{
|
||||
Href: "{{ .Status }}",
|
||||
Text: "{{ .Status }}",
|
||||
},
|
||||
},
|
||||
Details: map[string]any{
|
||||
"firing": `{{ template "pagerduty.default.instances" .Alerts.Firing }}`,
|
||||
"resolved": `{{ template "pagerduty.default.instances" .Alerts.Resolved }}`,
|
||||
"num_firing": `{{ .Alerts.Firing | len }}`,
|
||||
"num_resolved": `{{ .Alerts.Resolved | len }}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "nested details",
|
||||
cfg: &config.PagerdutyConfig{
|
||||
RoutingKey: config.Secret("01234567890123456789012345678901"),
|
||||
Details: map[string]any{
|
||||
"a": map[string]any{
|
||||
"b": map[string]any{
|
||||
"c": map[string]any{
|
||||
"firing": `{{ .Alerts.Firing | toJson }}`,
|
||||
"resolved": `{{ .Alerts.Resolved | toJson }}`,
|
||||
"num_firing": `{{ .Alerts.Firing | len }}`,
|
||||
"num_resolved": `{{ .Alerts.Resolved | len }}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "nested details with template error",
|
||||
cfg: &config.PagerdutyConfig{
|
||||
RoutingKey: config.Secret("01234567890123456789012345678901"),
|
||||
Details: map[string]any{
|
||||
"a": map[string]any{
|
||||
"b": map[string]any{
|
||||
"c": map[string]any{
|
||||
"firing": `{{ template "pagerduty.default.instances" .Alerts.Firing`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
errMsg: "failed to render details: template: :1: unclosed action",
|
||||
},
|
||||
{
|
||||
title: "details with templating errors",
|
||||
cfg: &config.PagerdutyConfig{
|
||||
RoutingKey: config.Secret("01234567890123456789012345678901"),
|
||||
Details: map[string]any{
|
||||
"firing": `{{ .Alerts.Firing | toJson`,
|
||||
"resolved": `{{ .Alerts.Resolved | toJson }}`,
|
||||
"num_firing": `{{ .Alerts.Firing | len }}`,
|
||||
"num_resolved": `{{ .Alerts.Resolved | len }}`,
|
||||
},
|
||||
},
|
||||
errMsg: "failed to render details: template: :1: unclosed action",
|
||||
},
|
||||
{
|
||||
title: "v2 message with templating errors",
|
||||
cfg: &config.PagerdutyConfig{
|
||||
RoutingKey: config.Secret("01234567890123456789012345678901"),
|
||||
Severity: "{{ ",
|
||||
},
|
||||
errMsg: "failed to template",
|
||||
},
|
||||
{
|
||||
title: "v1 message with templating errors",
|
||||
cfg: &config.PagerdutyConfig{
|
||||
ServiceKey: config.Secret("01234567890123456789012345678901"),
|
||||
Client: "{{ ",
|
||||
},
|
||||
errMsg: "failed to template",
|
||||
},
|
||||
{
|
||||
title: "routing key cannot be empty",
|
||||
cfg: &config.PagerdutyConfig{
|
||||
RoutingKey: config.Secret(`{{ "" }}`),
|
||||
},
|
||||
errMsg: "routing key cannot be empty",
|
||||
},
|
||||
{
|
||||
title: "service_key cannot be empty",
|
||||
cfg: &config.PagerdutyConfig{
|
||||
ServiceKey: config.Secret(`{{ "" }}`),
|
||||
},
|
||||
errMsg: "service key cannot be empty",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.title, func(t *testing.T) {
|
||||
tc.cfg.URL = &config.URL{URL: u}
|
||||
tc.cfg.HTTPConfig = &commoncfg.HTTPClientConfig{}
|
||||
pd, err := New(tc.cfg, test.CreateTmpl(t), promslog.NewNopLogger())
|
||||
require.NoError(t, err)
|
||||
if pd.apiV1 != "" {
|
||||
pd.apiV1 = u.String()
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "1")
|
||||
|
||||
ok, err := pd.Notify(ctx, []*types.Alert{
|
||||
{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{
|
||||
"lbl1": "val1",
|
||||
},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
},
|
||||
}...)
|
||||
if tc.errMsg == "" {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
if errors.Asc(err, errors.CodeInternal) {
|
||||
_, _, errMsg, _, _, _ := errors.Unwrapb(err)
|
||||
require.Contains(t, errMsg, tc.errMsg)
|
||||
} else {
|
||||
require.Contains(t, err.Error(), tc.errMsg)
|
||||
}
|
||||
}
|
||||
require.Equal(t, tc.retry, ok)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrDetails(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
status int
|
||||
body io.Reader
|
||||
|
||||
exp string
|
||||
}{
|
||||
{
|
||||
status: http.StatusBadRequest,
|
||||
body: bytes.NewBuffer([]byte(
|
||||
`{"status":"invalid event","message":"Event object is invalid","errors":["Length of 'routing_key' is incorrect (should be 32 characters)"]}`,
|
||||
)),
|
||||
|
||||
exp: "Length of 'routing_key' is incorrect",
|
||||
},
|
||||
{
|
||||
status: http.StatusBadRequest,
|
||||
body: bytes.NewBuffer([]byte(`{"status"}`)),
|
||||
|
||||
exp: "",
|
||||
},
|
||||
{
|
||||
status: http.StatusBadRequest,
|
||||
|
||||
exp: "",
|
||||
},
|
||||
{
|
||||
status: http.StatusTooManyRequests,
|
||||
|
||||
exp: "",
|
||||
},
|
||||
} {
|
||||
t.Run("", func(t *testing.T) {
|
||||
err := errDetails(tc.status, tc.body)
|
||||
require.Contains(t, err, tc.exp)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventSizeEnforcement(t *testing.T) {
|
||||
bigDetailsV1 := map[string]any{
|
||||
"firing": strings.Repeat("a", 513000),
|
||||
}
|
||||
bigDetailsV2 := map[string]any{
|
||||
"firing": strings.Repeat("a", 513000),
|
||||
}
|
||||
|
||||
// V1 Messages
|
||||
msgV1 := &pagerDutyMessage{
|
||||
ServiceKey: "01234567890123456789012345678901",
|
||||
EventType: "trigger",
|
||||
Details: bigDetailsV1,
|
||||
}
|
||||
|
||||
notifierV1, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
ServiceKey: config.Secret("01234567890123456789012345678901"),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
encodedV1, err := notifierV1.encodeMessage(context.Background(), msgV1)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, encodedV1.String(), `"details":{"error":"Custom details have been removed because the original event exceeds the maximum size of 512KB"}`)
|
||||
|
||||
// V2 Messages
|
||||
msgV2 := &pagerDutyMessage{
|
||||
RoutingKey: "01234567890123456789012345678901",
|
||||
EventAction: "trigger",
|
||||
Payload: &pagerDutyPayload{
|
||||
CustomDetails: bigDetailsV2,
|
||||
},
|
||||
}
|
||||
|
||||
notifierV2, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
RoutingKey: config.Secret("01234567890123456789012345678901"),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
encodedV2, err := notifierV2.encodeMessage(context.Background(), msgV2)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, encodedV2.String(), `"custom_details":{"error":"Custom details have been removed because the original event exceeds the maximum size of 512KB"}`)
|
||||
}
|
||||
|
||||
func TestPagerDutyEmptySrcHref(t *testing.T) {
|
||||
type pagerDutyEvent struct {
|
||||
RoutingKey string `json:"routing_key"`
|
||||
EventAction string `json:"event_action"`
|
||||
DedupKey string `json:"dedup_key"`
|
||||
Payload pagerDutyPayload `json:"payload"`
|
||||
Images []pagerDutyImage
|
||||
Links []pagerDutyLink
|
||||
}
|
||||
|
||||
images := []config.PagerdutyImage{
|
||||
{
|
||||
Src: "",
|
||||
Alt: "Empty src",
|
||||
Href: "https://example.com/",
|
||||
},
|
||||
{
|
||||
Src: "https://example.com/cat.jpg",
|
||||
Alt: "Empty href",
|
||||
Href: "",
|
||||
},
|
||||
{
|
||||
Src: "https://example.com/cat.jpg",
|
||||
Alt: "",
|
||||
Href: "https://example.com/",
|
||||
},
|
||||
}
|
||||
|
||||
links := []config.PagerdutyLink{
|
||||
{
|
||||
Href: "",
|
||||
Text: "Empty href",
|
||||
},
|
||||
{
|
||||
Href: "https://example.com/",
|
||||
Text: "",
|
||||
},
|
||||
}
|
||||
|
||||
expectedImages := make([]pagerDutyImage, 0, len(images))
|
||||
for _, image := range images {
|
||||
if image.Src == "" {
|
||||
continue
|
||||
}
|
||||
expectedImages = append(expectedImages, pagerDutyImage{
|
||||
Src: image.Src,
|
||||
Alt: image.Alt,
|
||||
Href: image.Href,
|
||||
})
|
||||
}
|
||||
|
||||
expectedLinks := make([]pagerDutyLink, 0, len(links))
|
||||
for _, link := range links {
|
||||
if link.Href == "" {
|
||||
continue
|
||||
}
|
||||
expectedLinks = append(expectedLinks, pagerDutyLink{
|
||||
HRef: link.Href,
|
||||
Text: link.Text,
|
||||
})
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
var event pagerDutyEvent
|
||||
if err := decoder.Decode(&event); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if event.RoutingKey == "" || event.EventAction == "" {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
for _, image := range event.Images {
|
||||
if image.Src == "" {
|
||||
http.Error(w, "Event object is invalid: 'image src' is missing or blank", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for _, link := range event.Links {
|
||||
if link.HRef == "" {
|
||||
http.Error(w, "Event object is invalid: 'link href' is missing or blank", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
require.Equal(t, expectedImages, event.Images)
|
||||
require.Equal(t, expectedLinks, event.Links)
|
||||
},
|
||||
))
|
||||
defer server.Close()
|
||||
|
||||
url, err := url.Parse(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
pagerDutyConfig := config.PagerdutyConfig{
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
RoutingKey: config.Secret("01234567890123456789012345678901"),
|
||||
URL: &config.URL{URL: url},
|
||||
Images: images,
|
||||
Links: links,
|
||||
}
|
||||
|
||||
pagerDuty, err := New(&pagerDutyConfig, test.CreateTmpl(t), promslog.NewNopLogger())
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "1")
|
||||
|
||||
_, err = pagerDuty.Notify(ctx, []*types.Alert{
|
||||
{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{
|
||||
"lbl1": "val1",
|
||||
},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
},
|
||||
}...)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestPagerDutyTimeout(t *testing.T) {
|
||||
type pagerDutyEvent struct {
|
||||
RoutingKey string `json:"routing_key"`
|
||||
EventAction string `json:"event_action"`
|
||||
DedupKey string `json:"dedup_key"`
|
||||
Payload pagerDutyPayload `json:"payload"`
|
||||
Images []pagerDutyImage
|
||||
Links []pagerDutyLink
|
||||
}
|
||||
|
||||
tests := map[string]struct {
|
||||
latency time.Duration
|
||||
timeout time.Duration
|
||||
wantErr bool
|
||||
}{
|
||||
"success": {latency: 100 * time.Millisecond, timeout: 120 * time.Millisecond, wantErr: false},
|
||||
"error": {latency: 100 * time.Millisecond, timeout: 80 * time.Millisecond, wantErr: true},
|
||||
}
|
||||
|
||||
for name, tt := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
var event pagerDutyEvent
|
||||
if err := decoder.Decode(&event); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if event.RoutingKey == "" || event.EventAction == "" {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
time.Sleep(tt.latency)
|
||||
},
|
||||
))
|
||||
defer srv.Close()
|
||||
u, err := url.Parse(srv.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := config.PagerdutyConfig{
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
RoutingKey: config.Secret("01234567890123456789012345678901"),
|
||||
URL: &config.URL{URL: u},
|
||||
Timeout: tt.timeout,
|
||||
}
|
||||
|
||||
pd, err := New(&cfg, test.CreateTmpl(t), promslog.NewNopLogger())
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "1")
|
||||
alert := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{
|
||||
"lbl1": "val1",
|
||||
},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
}
|
||||
_, err = pd.Notify(ctx, alert)
|
||||
require.Equal(t, tt.wantErr, err != nil)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderDetails(t *testing.T) {
|
||||
type args struct {
|
||||
details map[string]any
|
||||
data *template.Data
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want map[string]any
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "flat",
|
||||
args: args{
|
||||
details: map[string]any{
|
||||
"a": "{{ .Status }}",
|
||||
"b": "String",
|
||||
},
|
||||
data: &template.Data{
|
||||
Status: "Flat",
|
||||
},
|
||||
},
|
||||
want: map[string]any{
|
||||
"a": "Flat",
|
||||
"b": "String",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "flat error",
|
||||
args: args{
|
||||
details: map[string]any{
|
||||
"a": "{{ .Status",
|
||||
},
|
||||
data: &template.Data{
|
||||
Status: "Error",
|
||||
},
|
||||
},
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "nested",
|
||||
args: args{
|
||||
details: map[string]any{
|
||||
"a": map[string]any{
|
||||
"b": map[string]any{
|
||||
"c": "{{ .Status }}",
|
||||
"d": "String",
|
||||
},
|
||||
},
|
||||
},
|
||||
data: &template.Data{
|
||||
Status: "Nested",
|
||||
},
|
||||
},
|
||||
want: map[string]any{
|
||||
"a": map[string]any{
|
||||
"b": map[string]any{
|
||||
"c": "Nested",
|
||||
"d": "String",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "nested error",
|
||||
args: args{
|
||||
details: map[string]any{
|
||||
"a": map[string]any{
|
||||
"b": map[string]any{
|
||||
"c": "{{ .Status",
|
||||
},
|
||||
},
|
||||
},
|
||||
data: &template.Data{
|
||||
Status: "Error",
|
||||
},
|
||||
},
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "alerts",
|
||||
args: args{
|
||||
details: map[string]any{
|
||||
"alerts": map[string]any{
|
||||
"firing": "{{ .Alerts.Firing | toJson }}",
|
||||
"resolved": "{{ .Alerts.Resolved | toJson }}",
|
||||
"num_firing": "{{ len .Alerts.Firing }}",
|
||||
"num_resolved": "{{ len .Alerts.Resolved }}",
|
||||
},
|
||||
},
|
||||
data: &template.Data{
|
||||
Alerts: template.Alerts{
|
||||
{
|
||||
Status: "firing",
|
||||
Annotations: template.KV{
|
||||
"annotation1": "value1",
|
||||
"annotation2": "value2",
|
||||
},
|
||||
Labels: template.KV{
|
||||
"alertname": "Firing1",
|
||||
"label1": "value1",
|
||||
"label2": "value2",
|
||||
},
|
||||
Fingerprint: "fingerprint1",
|
||||
GeneratorURL: "http://generator1",
|
||||
StartsAt: time.Date(2001, time.January, 1, 0, 0, 0, 0, time.UTC),
|
||||
EndsAt: time.Date(2001, time.January, 1, 1, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
Status: "firing",
|
||||
Annotations: template.KV{
|
||||
"annotation1": "value1",
|
||||
"annotation2": "value2",
|
||||
},
|
||||
Labels: template.KV{
|
||||
"alertname": "Firing2",
|
||||
"label1": "value1",
|
||||
"label2": "value2",
|
||||
},
|
||||
Fingerprint: "fingerprint2",
|
||||
GeneratorURL: "http://generator2",
|
||||
StartsAt: time.Date(2002, time.January, 1, 0, 0, 0, 0, time.UTC),
|
||||
EndsAt: time.Date(2002, time.January, 1, 1, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
Status: "resolved",
|
||||
Annotations: template.KV{
|
||||
"annotation1": "value1",
|
||||
"annotation2": "value2",
|
||||
},
|
||||
Labels: template.KV{
|
||||
"alertname": "Resolved1",
|
||||
"label1": "value1",
|
||||
"label2": "value2",
|
||||
},
|
||||
Fingerprint: "fingerprint3",
|
||||
GeneratorURL: "http://generator3",
|
||||
StartsAt: time.Date(2001, time.January, 1, 0, 0, 0, 0, time.UTC),
|
||||
EndsAt: time.Date(2001, time.January, 1, 1, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
Status: "resolved",
|
||||
Annotations: template.KV{
|
||||
"annotation1": "value1",
|
||||
"annotation2": "value2",
|
||||
},
|
||||
Labels: template.KV{
|
||||
"alertname": "Resolved2",
|
||||
"label1": "value1",
|
||||
"label2": "value2",
|
||||
},
|
||||
Fingerprint: "fingerprint4",
|
||||
GeneratorURL: "http://generator4",
|
||||
StartsAt: time.Date(2002, time.January, 1, 0, 0, 0, 0, time.UTC),
|
||||
EndsAt: time.Date(2002, time.January, 1, 1, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: map[string]any{
|
||||
"alerts": map[string]any{
|
||||
"firing": []any{
|
||||
map[string]any{
|
||||
"status": "firing",
|
||||
"labels": map[string]any{
|
||||
"alertname": "Firing1",
|
||||
"label1": "value1",
|
||||
"label2": "value2",
|
||||
},
|
||||
"annotations": map[string]any{
|
||||
"annotation1": "value1",
|
||||
"annotation2": "value2",
|
||||
},
|
||||
"startsAt": time.Date(2001, time.January, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339),
|
||||
"endsAt": time.Date(2001, time.January, 1, 1, 0, 0, 0, time.UTC).Format(time.RFC3339),
|
||||
"fingerprint": "fingerprint1",
|
||||
"generatorURL": "http://generator1",
|
||||
},
|
||||
map[string]any{
|
||||
"status": "firing",
|
||||
"labels": map[string]any{
|
||||
"alertname": "Firing2",
|
||||
"label1": "value1",
|
||||
"label2": "value2",
|
||||
},
|
||||
"annotations": map[string]any{
|
||||
"annotation1": "value1",
|
||||
"annotation2": "value2",
|
||||
},
|
||||
"startsAt": time.Date(2002, time.January, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339),
|
||||
"endsAt": time.Date(2002, time.January, 1, 1, 0, 0, 0, time.UTC).Format(time.RFC3339),
|
||||
"fingerprint": "fingerprint2",
|
||||
"generatorURL": "http://generator2",
|
||||
},
|
||||
},
|
||||
"resolved": []any{
|
||||
map[string]any{
|
||||
"status": "resolved",
|
||||
"labels": map[string]any{
|
||||
"alertname": "Resolved1",
|
||||
"label1": "value1",
|
||||
"label2": "value2",
|
||||
},
|
||||
"annotations": map[string]any{
|
||||
"annotation1": "value1",
|
||||
"annotation2": "value2",
|
||||
},
|
||||
"startsAt": time.Date(2001, time.January, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339),
|
||||
"endsAt": time.Date(2001, time.January, 1, 1, 0, 0, 0, time.UTC).Format(time.RFC3339),
|
||||
"fingerprint": "fingerprint3",
|
||||
"generatorURL": "http://generator3",
|
||||
},
|
||||
map[string]any{
|
||||
"status": "resolved",
|
||||
"labels": map[string]any{
|
||||
"alertname": "Resolved2",
|
||||
"label1": "value1",
|
||||
"label2": "value2",
|
||||
},
|
||||
"annotations": map[string]any{
|
||||
"annotation1": "value1",
|
||||
"annotation2": "value2",
|
||||
},
|
||||
"startsAt": time.Date(2002, time.January, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339),
|
||||
"endsAt": time.Date(2002, time.January, 1, 1, 0, 0, 0, time.UTC).Format(time.RFC3339),
|
||||
"fingerprint": "fingerprint4",
|
||||
"generatorURL": "http://generator4",
|
||||
},
|
||||
},
|
||||
"num_firing": 2,
|
||||
"num_resolved": 2,
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
n := &Notifier{
|
||||
conf: &config.PagerdutyConfig{
|
||||
Details: tt.args.details,
|
||||
},
|
||||
tmpl: test.CreateTmpl(t),
|
||||
}
|
||||
got, err := n.renderDetails(tt.args.data)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("renderDetails() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,8 @@ package alertmanagernotify
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/email"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/msteamsv2"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/opsgenie"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/pagerduty"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/slack"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/webhook"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/prometheus/alertmanager/config/receiver"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
@@ -17,15 +11,6 @@ import (
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
var customNotifierIntegrations = []string{
|
||||
webhook.Integration,
|
||||
email.Integration,
|
||||
pagerduty.Integration,
|
||||
opsgenie.Integration,
|
||||
slack.Integration,
|
||||
msteamsv2.Integration,
|
||||
}
|
||||
|
||||
func NewReceiverIntegrations(nc alertmanagertypes.Receiver, tmpl *template.Template, logger *slog.Logger) ([]notify.Integration, error) {
|
||||
upstreamIntegrations, err := receiver.BuildReceiverIntegrations(nc, tmpl, logger)
|
||||
if err != nil {
|
||||
@@ -46,29 +31,14 @@ func NewReceiverIntegrations(nc alertmanagertypes.Receiver, tmpl *template.Templ
|
||||
)
|
||||
|
||||
for _, integration := range upstreamIntegrations {
|
||||
// skip upstream integration if we support custom integration for it
|
||||
if !slices.Contains(customNotifierIntegrations, integration.Name()) {
|
||||
// skip upstream msteamsv2 integration
|
||||
if integration.Name() != "msteamsv2" {
|
||||
integrations = append(integrations, integration)
|
||||
}
|
||||
}
|
||||
|
||||
for i, c := range nc.WebhookConfigs {
|
||||
add(webhook.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return webhook.New(c, tmpl, l) })
|
||||
}
|
||||
for i, c := range nc.EmailConfigs {
|
||||
add(email.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return email.New(c, tmpl, l), nil })
|
||||
}
|
||||
for i, c := range nc.PagerdutyConfigs {
|
||||
add(pagerduty.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return pagerduty.New(c, tmpl, l) })
|
||||
}
|
||||
for i, c := range nc.OpsGenieConfigs {
|
||||
add(opsgenie.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return opsgenie.New(c, tmpl, l) })
|
||||
}
|
||||
for i, c := range nc.SlackConfigs {
|
||||
add(slack.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return slack.New(c, tmpl, l) })
|
||||
}
|
||||
for i, c := range nc.MSTeamsV2Configs {
|
||||
add(msteamsv2.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) {
|
||||
add("msteamsv2", i, c, func(l *slog.Logger) (notify.Notifier, error) {
|
||||
return msteamsv2.New(c, tmpl, `{{ template "msteamsv2.default.titleLink" . }}`, l)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
// Copyright 2019 Prometheus Team
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package slack
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
const (
|
||||
Integration = "slack"
|
||||
)
|
||||
|
||||
// https://api.slack.com/reference/messaging/attachments#legacy_fields - 1024, no units given, assuming runes or characters.
|
||||
const maxTitleLenRunes = 1024
|
||||
|
||||
// Notifier implements a Notifier for Slack notifications.
|
||||
type Notifier struct {
|
||||
conf *config.SlackConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
client *http.Client
|
||||
retrier *notify.Retrier
|
||||
|
||||
postJSONFunc func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error)
|
||||
}
|
||||
|
||||
// New returns a new Slack notification handler.
|
||||
func New(c *config.SlackConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
|
||||
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Notifier{
|
||||
conf: c,
|
||||
tmpl: t,
|
||||
logger: l,
|
||||
client: client,
|
||||
retrier: ¬ify.Retrier{},
|
||||
postJSONFunc: notify.PostJSON,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// request is the request for sending a slack notification.
|
||||
type request struct {
|
||||
Channel string `json:"channel,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
IconEmoji string `json:"icon_emoji,omitempty"`
|
||||
IconURL string `json:"icon_url,omitempty"`
|
||||
LinkNames bool `json:"link_names,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Attachments []attachment `json:"attachments"`
|
||||
}
|
||||
|
||||
// attachment is used to display a richly-formatted message block.
|
||||
type attachment struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
TitleLink string `json:"title_link,omitempty"`
|
||||
Pretext string `json:"pretext,omitempty"`
|
||||
Text string `json:"text"`
|
||||
Fallback string `json:"fallback"`
|
||||
CallbackID string `json:"callback_id"`
|
||||
Fields []config.SlackField `json:"fields,omitempty"`
|
||||
Actions []config.SlackAction `json:"actions,omitempty"`
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
ThumbURL string `json:"thumb_url,omitempty"`
|
||||
Footer string `json:"footer"`
|
||||
Color string `json:"color,omitempty"`
|
||||
MrkdwnIn []string `json:"mrkdwn_in,omitempty"`
|
||||
}
|
||||
|
||||
// Notify implements the Notifier interface.
|
||||
func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
||||
|
||||
key, err := notify.ExtractGroupKey(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
logger := n.logger.With(slog.Any("group_key", key))
|
||||
logger.DebugContext(ctx, "extracted group key")
|
||||
|
||||
var (
|
||||
data = notify.GetTemplateData(ctx, n.tmpl, as, logger)
|
||||
tmplText = notify.TmplText(n.tmpl, data, &err)
|
||||
)
|
||||
var markdownIn []string
|
||||
|
||||
if len(n.conf.MrkdwnIn) == 0 {
|
||||
markdownIn = []string{"fallback", "pretext", "text"}
|
||||
} else {
|
||||
markdownIn = n.conf.MrkdwnIn
|
||||
}
|
||||
|
||||
title, truncated := notify.TruncateInRunes(tmplText(n.conf.Title), maxTitleLenRunes)
|
||||
if truncated {
|
||||
logger.WarnContext(ctx, "Truncated title", slog.Int("max_runes", maxTitleLenRunes))
|
||||
}
|
||||
att := &attachment{
|
||||
Title: title,
|
||||
TitleLink: tmplText(n.conf.TitleLink),
|
||||
Pretext: tmplText(n.conf.Pretext),
|
||||
Text: tmplText(n.conf.Text),
|
||||
Fallback: tmplText(n.conf.Fallback),
|
||||
CallbackID: tmplText(n.conf.CallbackID),
|
||||
ImageURL: tmplText(n.conf.ImageURL),
|
||||
ThumbURL: tmplText(n.conf.ThumbURL),
|
||||
Footer: tmplText(n.conf.Footer),
|
||||
Color: tmplText(n.conf.Color),
|
||||
MrkdwnIn: markdownIn,
|
||||
}
|
||||
|
||||
numFields := len(n.conf.Fields)
|
||||
if numFields > 0 {
|
||||
fields := make([]config.SlackField, numFields)
|
||||
for index, field := range n.conf.Fields {
|
||||
// Check if short was defined for the field otherwise fallback to the global setting
|
||||
var short bool
|
||||
if field.Short != nil {
|
||||
short = *field.Short
|
||||
} else {
|
||||
short = n.conf.ShortFields
|
||||
}
|
||||
|
||||
// Rebuild the field by executing any templates and setting the new value for short
|
||||
fields[index] = config.SlackField{
|
||||
Title: tmplText(field.Title),
|
||||
Value: tmplText(field.Value),
|
||||
Short: &short,
|
||||
}
|
||||
}
|
||||
att.Fields = fields
|
||||
}
|
||||
|
||||
numActions := len(n.conf.Actions)
|
||||
if numActions > 0 {
|
||||
actions := make([]config.SlackAction, numActions)
|
||||
for index, action := range n.conf.Actions {
|
||||
slackAction := config.SlackAction{
|
||||
Type: tmplText(action.Type),
|
||||
Text: tmplText(action.Text),
|
||||
URL: tmplText(action.URL),
|
||||
Style: tmplText(action.Style),
|
||||
Name: tmplText(action.Name),
|
||||
Value: tmplText(action.Value),
|
||||
}
|
||||
|
||||
if action.ConfirmField != nil {
|
||||
slackAction.ConfirmField = &config.SlackConfirmationField{
|
||||
Title: tmplText(action.ConfirmField.Title),
|
||||
Text: tmplText(action.ConfirmField.Text),
|
||||
OkText: tmplText(action.ConfirmField.OkText),
|
||||
DismissText: tmplText(action.ConfirmField.DismissText),
|
||||
}
|
||||
}
|
||||
|
||||
actions[index] = slackAction
|
||||
}
|
||||
att.Actions = actions
|
||||
}
|
||||
|
||||
req := &request{
|
||||
Channel: tmplText(n.conf.Channel),
|
||||
Username: tmplText(n.conf.Username),
|
||||
IconEmoji: tmplText(n.conf.IconEmoji),
|
||||
IconURL: tmplText(n.conf.IconURL),
|
||||
LinkNames: n.conf.LinkNames,
|
||||
Text: tmplText(n.conf.MessageText),
|
||||
Attachments: []attachment{*att},
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(req); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
var u string
|
||||
if n.conf.APIURL != nil {
|
||||
u = n.conf.APIURL.String()
|
||||
} else {
|
||||
content, err := os.ReadFile(n.conf.APIURLFile)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
u = strings.TrimSpace(string(content))
|
||||
}
|
||||
|
||||
if n.conf.Timeout > 0 {
|
||||
postCtx, cancel := context.WithTimeoutCause(ctx, n.conf.Timeout, errors.NewInternalf(errors.CodeTimeout, "configured slack timeout reached (%s)", n.conf.Timeout))
|
||||
defer cancel()
|
||||
ctx = postCtx
|
||||
}
|
||||
|
||||
resp, err := n.postJSONFunc(ctx, n.client, u, &buf) //nolint:bodyclose
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
err = errors.NewInternalf(errors.CodeInternal, "failed to post JSON to slack: %v", context.Cause(ctx))
|
||||
}
|
||||
return true, notify.RedactURL(err)
|
||||
}
|
||||
defer notify.Drain(resp)
|
||||
|
||||
// Use a retrier to generate an error message for non-200 responses and
|
||||
// classify them as retriable or not.
|
||||
retry, err := n.retrier.Check(resp.StatusCode, resp.Body)
|
||||
if err != nil {
|
||||
err = errors.NewInternalf(errors.CodeInternal, "channel %q: %v", req.Channel, err)
|
||||
return retry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err)
|
||||
}
|
||||
|
||||
// Slack web API might return errors with a 200 response code.
|
||||
// https://docs.slack.dev/tools/node-slack-sdk/web-api/#handle-errors
|
||||
retry, err = checkResponseError(resp)
|
||||
if err != nil {
|
||||
err = errors.NewInternalf(errors.CodeInternal, "channel %q: %v", req.Channel, err)
|
||||
return retry, notify.NewErrorWithReason(notify.ClientErrorReason, err)
|
||||
}
|
||||
|
||||
return retry, nil
|
||||
}
|
||||
|
||||
// checkResponseError parses out the error message from Slack API response.
|
||||
func checkResponseError(resp *http.Response) (bool, error) {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "could not read response body")
|
||||
}
|
||||
|
||||
if strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") {
|
||||
return checkJSONResponseError(body)
|
||||
}
|
||||
return checkTextResponseError(body)
|
||||
}
|
||||
|
||||
// checkTextResponseError classifies plaintext responses from Slack.
|
||||
// A plaintext (non-JSON) response is successful if it's a string "ok".
|
||||
// This is typically a response for an Incoming Webhook
|
||||
// (https://api.slack.com/messaging/webhooks#handling_errors)
|
||||
func checkTextResponseError(body []byte) (bool, error) {
|
||||
if !bytes.Equal(body, []byte("ok")) {
|
||||
return false, errors.NewInternalf(errors.CodeInternal, "received an error response from Slack: %s", string(body))
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// checkJSONResponseError classifies JSON responses from Slack.
|
||||
func checkJSONResponseError(body []byte) (bool, error) {
|
||||
// response is for parsing out errors from the JSON response.
|
||||
type response struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
var data response
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return true, errors.NewInternalf(errors.CodeInternal, "could not unmarshal JSON response %q: %v", string(body), err)
|
||||
}
|
||||
if !data.OK {
|
||||
return false, errors.NewInternalf(errors.CodeInternal, "error response from Slack: %s", data.Error)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
@@ -1,352 +0,0 @@
|
||||
// Copyright 2019 Prometheus Team
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package slack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/prometheus/common/promslog"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/notify/test"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
func TestSlackRetry(t *testing.T) {
|
||||
notifier, err := New(
|
||||
&config.SlackConfig{
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
for statusCode, expected := range test.RetryTests(test.DefaultRetryCodes()) {
|
||||
actual, _ := notifier.retrier.Check(statusCode, nil)
|
||||
require.Equal(t, expected, actual, "error on status %d", statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlackRedactedURL(t *testing.T) {
|
||||
ctx, u, fn := test.GetContextWithCancelingURL()
|
||||
defer fn()
|
||||
|
||||
notifier, err := New(
|
||||
&config.SlackConfig{
|
||||
APIURL: &config.SecretURL{URL: u},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, u.String())
|
||||
}
|
||||
|
||||
func TestGettingSlackURLFromFile(t *testing.T) {
|
||||
ctx, u, fn := test.GetContextWithCancelingURL()
|
||||
defer fn()
|
||||
|
||||
f, err := os.CreateTemp(t.TempDir(), "slack_test")
|
||||
require.NoError(t, err, "creating temp file failed")
|
||||
_, err = f.WriteString(u.String())
|
||||
require.NoError(t, err, "writing to temp file failed")
|
||||
|
||||
notifier, err := New(
|
||||
&config.SlackConfig{
|
||||
APIURLFile: f.Name(),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, u.String())
|
||||
}
|
||||
|
||||
func TestTrimmingSlackURLFromFile(t *testing.T) {
|
||||
ctx, u, fn := test.GetContextWithCancelingURL()
|
||||
defer fn()
|
||||
|
||||
f, err := os.CreateTemp(t.TempDir(), "slack_test_newline")
|
||||
require.NoError(t, err, "creating temp file failed")
|
||||
_, err = f.WriteString(u.String() + "\n\n")
|
||||
require.NoError(t, err, "writing to temp file failed")
|
||||
|
||||
notifier, err := New(
|
||||
&config.SlackConfig{
|
||||
APIURLFile: f.Name(),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, u.String())
|
||||
}
|
||||
|
||||
func TestNotifier_Notify_WithReason(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
statusCode int
|
||||
responseBody string
|
||||
expectedReason notify.Reason
|
||||
expectedErr string
|
||||
expectedRetry bool
|
||||
noError bool
|
||||
}{
|
||||
{
|
||||
name: "with a 4xx status code",
|
||||
statusCode: http.StatusUnauthorized,
|
||||
expectedReason: notify.ClientErrorReason,
|
||||
expectedRetry: false,
|
||||
expectedErr: "unexpected status code 401",
|
||||
},
|
||||
{
|
||||
name: "with a 5xx status code",
|
||||
statusCode: http.StatusInternalServerError,
|
||||
expectedReason: notify.ServerErrorReason,
|
||||
expectedRetry: true,
|
||||
expectedErr: "unexpected status code 500",
|
||||
},
|
||||
{
|
||||
name: "with a 3xx status code",
|
||||
statusCode: http.StatusTemporaryRedirect,
|
||||
expectedReason: notify.DefaultReason,
|
||||
expectedRetry: false,
|
||||
expectedErr: "unexpected status code 307",
|
||||
},
|
||||
{
|
||||
name: "with a 1xx status code",
|
||||
statusCode: http.StatusSwitchingProtocols,
|
||||
expectedReason: notify.DefaultReason,
|
||||
expectedRetry: false,
|
||||
expectedErr: "unexpected status code 101",
|
||||
},
|
||||
{
|
||||
name: "2xx response with invalid JSON",
|
||||
statusCode: http.StatusOK,
|
||||
responseBody: `{"not valid json"}`,
|
||||
expectedReason: notify.ClientErrorReason,
|
||||
expectedRetry: true,
|
||||
expectedErr: "could not unmarshal",
|
||||
},
|
||||
{
|
||||
name: "2xx response with a JSON error",
|
||||
statusCode: http.StatusOK,
|
||||
responseBody: `{"ok":false,"error":"error_message"}`,
|
||||
expectedReason: notify.ClientErrorReason,
|
||||
expectedRetry: false,
|
||||
expectedErr: "error response from Slack: error_message",
|
||||
},
|
||||
{
|
||||
name: "2xx response with a plaintext error",
|
||||
statusCode: http.StatusOK,
|
||||
responseBody: "no_channel",
|
||||
expectedReason: notify.ClientErrorReason,
|
||||
expectedRetry: false,
|
||||
expectedErr: "error response from Slack: no_channel",
|
||||
},
|
||||
{
|
||||
name: "successful JSON response",
|
||||
statusCode: http.StatusOK,
|
||||
responseBody: `{"ok":true}`,
|
||||
noError: true,
|
||||
},
|
||||
{
|
||||
name: "successful plaintext response",
|
||||
statusCode: http.StatusOK,
|
||||
responseBody: "ok",
|
||||
noError: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
apiurl, _ := url.Parse("https://slack.com/post.Message")
|
||||
notifier, err := New(
|
||||
&config.SlackConfig{
|
||||
NotifierConfig: config.NotifierConfig{},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
APIURL: &config.SecretURL{URL: apiurl},
|
||||
Channel: "channelname",
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
notifier.postJSONFunc = func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error) {
|
||||
resp := httptest.NewRecorder()
|
||||
if strings.HasPrefix(tt.responseBody, "{") {
|
||||
resp.Header().Add("Content-Type", "application/json; charset=utf-8")
|
||||
}
|
||||
resp.WriteHeader(tt.statusCode)
|
||||
_, _ = resp.WriteString(tt.responseBody)
|
||||
return resp.Result(), nil
|
||||
}
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "1")
|
||||
|
||||
alert1 := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
}
|
||||
retry, err := notifier.Notify(ctx, alert1)
|
||||
require.Equal(t, tt.expectedRetry, retry)
|
||||
if tt.noError {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
var reasonError *notify.ErrorWithReason
|
||||
require.ErrorAs(t, err, &reasonError)
|
||||
require.Equal(t, tt.expectedReason, reasonError.Reason)
|
||||
require.Contains(t, err.Error(), tt.expectedErr)
|
||||
require.Contains(t, err.Error(), "channelname")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlackTimeout(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
latency time.Duration
|
||||
timeout time.Duration
|
||||
wantErr bool
|
||||
}{
|
||||
"success": {latency: 100 * time.Millisecond, timeout: 120 * time.Millisecond, wantErr: false},
|
||||
"error": {latency: 100 * time.Millisecond, timeout: 80 * time.Millisecond, wantErr: true},
|
||||
}
|
||||
|
||||
for name, tt := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
u, _ := url.Parse("https://slack.com/post.Message")
|
||||
notifier, err := New(
|
||||
&config.SlackConfig{
|
||||
NotifierConfig: config.NotifierConfig{},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
APIURL: &config.SecretURL{URL: u},
|
||||
Channel: "channelname",
|
||||
Timeout: tt.timeout,
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
notifier.postJSONFunc = func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(tt.latency):
|
||||
resp := httptest.NewRecorder()
|
||||
resp.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
resp.WriteHeader(http.StatusOK)
|
||||
_, _ = resp.WriteString(`{"ok":true}`)
|
||||
|
||||
return resp.Result(), nil
|
||||
}
|
||||
}
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "1")
|
||||
|
||||
alert := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
}
|
||||
_, err = notifier.Notify(ctx, alert)
|
||||
require.Equal(t, tt.wantErr, err != nil)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlackMessageField(t *testing.T) {
|
||||
// 1. Setup a fake Slack server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 2. VERIFY: Top-level text exists
|
||||
if body["text"] != "My Top Level Message" {
|
||||
t.Errorf("Expected top-level 'text' to be 'My Top Level Message', got %v", body["text"])
|
||||
}
|
||||
|
||||
// 3. VERIFY: Old attachments still exist
|
||||
attachments, ok := body["attachments"].([]any)
|
||||
if !ok || len(attachments) == 0 {
|
||||
t.Errorf("Expected attachments to exist")
|
||||
} else {
|
||||
first := attachments[0].(map[string]any)
|
||||
if first["title"] != "Old Attachment Title" {
|
||||
t.Errorf("Expected attachment title 'Old Attachment Title', got %v", first["title"])
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok": true}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// 4. Configure Notifier with BOTH new and old fields
|
||||
u, _ := url.Parse(server.URL)
|
||||
conf := &config.SlackConfig{
|
||||
APIURL: &config.SecretURL{URL: u},
|
||||
MessageText: "My Top Level Message", // Your NEW field
|
||||
Title: "Old Attachment Title", // An OLD field
|
||||
Channel: "#test-channel",
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
}
|
||||
|
||||
tmpl, err := template.FromGlobs([]string{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmpl.ExternalURL = u
|
||||
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
notifier, err := New(conf, tmpl, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "test-group-key")
|
||||
|
||||
if _, err := notifier.Notify(ctx); err != nil {
|
||||
t.Fatal("Notify failed:", err)
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
// Copyright 2019 Prometheus Team
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
const (
|
||||
Integration = "webhook"
|
||||
)
|
||||
|
||||
// Notifier implements a Notifier for generic webhooks.
|
||||
type Notifier struct {
|
||||
conf *config.WebhookConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
client *http.Client
|
||||
retrier *notify.Retrier
|
||||
}
|
||||
|
||||
// New returns a new Webhook.
|
||||
func New(conf *config.WebhookConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
|
||||
client, err := notify.NewClientWithTracing(*conf.HTTPConfig, Integration, httpOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Notifier{
|
||||
conf: conf,
|
||||
tmpl: t,
|
||||
logger: l,
|
||||
client: client,
|
||||
// Webhooks are assumed to respond with 2xx response codes on a successful
|
||||
// request and 5xx response codes are assumed to be recoverable.
|
||||
retrier: ¬ify.Retrier{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Message defines the JSON object send to webhook endpoints.
|
||||
type Message struct {
|
||||
*template.Data
|
||||
|
||||
// The protocol version.
|
||||
Version string `json:"version"`
|
||||
GroupKey string `json:"groupKey"`
|
||||
TruncatedAlerts uint64 `json:"truncatedAlerts"`
|
||||
}
|
||||
|
||||
func truncateAlerts(maxAlerts uint64, alerts []*types.Alert) ([]*types.Alert, uint64) {
|
||||
if maxAlerts != 0 && uint64(len(alerts)) > maxAlerts {
|
||||
return alerts[:maxAlerts], uint64(len(alerts)) - maxAlerts
|
||||
}
|
||||
|
||||
return alerts, 0
|
||||
}
|
||||
|
||||
// Notify implements the Notifier interface.
|
||||
func (n *Notifier) Notify(ctx context.Context, alerts ...*types.Alert) (bool, error) {
|
||||
alerts, numTruncated := truncateAlerts(n.conf.MaxAlerts, alerts)
|
||||
data := notify.GetTemplateData(ctx, n.tmpl, alerts, n.logger)
|
||||
|
||||
groupKey, err := notify.ExtractGroupKey(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
logger := n.logger.With(slog.Any("group_key", groupKey))
|
||||
logger.DebugContext(ctx, "extracted group key")
|
||||
|
||||
msg := &Message{
|
||||
Version: "4",
|
||||
Data: data,
|
||||
GroupKey: groupKey.String(),
|
||||
TruncatedAlerts: numTruncated,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(msg); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
var url string
|
||||
var tmplErr error
|
||||
tmpl := notify.TmplText(n.tmpl, data, &tmplErr)
|
||||
|
||||
if n.conf.URL != "" {
|
||||
url = tmpl(string(n.conf.URL))
|
||||
} else {
|
||||
content, err := os.ReadFile(n.conf.URLFile)
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "read url_file")
|
||||
}
|
||||
url = tmpl(strings.TrimSpace(string(content)))
|
||||
}
|
||||
|
||||
if tmplErr != nil {
|
||||
return false, errors.NewInternalf(errors.CodeInternal, "failed to template webhook URL: %v", tmplErr)
|
||||
}
|
||||
|
||||
if url == "" {
|
||||
return false, errors.NewInternalf(errors.CodeInternal, "webhook URL is empty after templating")
|
||||
}
|
||||
|
||||
if n.conf.Timeout > 0 {
|
||||
postCtx, cancel := context.WithTimeoutCause(ctx, n.conf.Timeout, errors.NewInternalf(errors.CodeTimeout, "configured webhook timeout reached (%s)", n.conf.Timeout))
|
||||
defer cancel()
|
||||
ctx = postCtx
|
||||
}
|
||||
|
||||
resp, err := notify.PostJSON(ctx, n.client, url, &buf) //nolint:bodyclose
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
err = errors.NewInternalf(errors.CodeInternal, "failed to post JSON to webhook: %v", context.Cause(ctx))
|
||||
}
|
||||
return true, notify.RedactURL(err)
|
||||
}
|
||||
defer notify.Drain(resp)
|
||||
|
||||
shouldRetry, err := n.retrier.Check(resp.StatusCode, resp.Body)
|
||||
if err != nil {
|
||||
return shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err)
|
||||
}
|
||||
return shouldRetry, err
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
// Copyright 2019 Prometheus Team
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/prometheus/common/promslog"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/notify/test"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
func TestWebhookRetry(t *testing.T) {
|
||||
notifier, err := New(
|
||||
&config.WebhookConfig{
|
||||
URL: config.SecretTemplateURL("http://example.com"),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
if err != nil {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
t.Run("test retry status code", func(t *testing.T) {
|
||||
for statusCode, expected := range test.RetryTests(test.DefaultRetryCodes()) {
|
||||
actual, _ := notifier.retrier.Check(statusCode, nil)
|
||||
require.Equal(t, expected, actual, "error on status %d", statusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test retry error details", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
status int
|
||||
body io.Reader
|
||||
|
||||
exp string
|
||||
}{
|
||||
{
|
||||
status: http.StatusBadRequest,
|
||||
body: bytes.NewBuffer([]byte(
|
||||
`{"status":"invalid event"}`,
|
||||
)),
|
||||
|
||||
exp: fmt.Sprintf(`unexpected status code %d: {"status":"invalid event"}`, http.StatusBadRequest),
|
||||
},
|
||||
{
|
||||
status: http.StatusBadRequest,
|
||||
|
||||
exp: fmt.Sprintf(`unexpected status code %d`, http.StatusBadRequest),
|
||||
},
|
||||
} {
|
||||
t.Run("", func(t *testing.T) {
|
||||
_, err = notifier.retrier.Check(tc.status, tc.body)
|
||||
require.Equal(t, tc.exp, err.Error())
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhookTruncateAlerts(t *testing.T) {
|
||||
alerts := make([]*types.Alert, 10)
|
||||
|
||||
truncatedAlerts, numTruncated := truncateAlerts(0, alerts)
|
||||
require.Len(t, truncatedAlerts, 10)
|
||||
require.EqualValues(t, 0, numTruncated)
|
||||
|
||||
truncatedAlerts, numTruncated = truncateAlerts(4, alerts)
|
||||
require.Len(t, truncatedAlerts, 4)
|
||||
require.EqualValues(t, 6, numTruncated)
|
||||
|
||||
truncatedAlerts, numTruncated = truncateAlerts(100, alerts)
|
||||
require.Len(t, truncatedAlerts, 10)
|
||||
require.EqualValues(t, 0, numTruncated)
|
||||
}
|
||||
|
||||
func TestWebhookRedactedURL(t *testing.T) {
|
||||
ctx, u, fn := test.GetContextWithCancelingURL()
|
||||
defer fn()
|
||||
|
||||
secret := "secret"
|
||||
notifier, err := New(
|
||||
&config.WebhookConfig{
|
||||
URL: config.SecretTemplateURL(u.String()),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, secret)
|
||||
}
|
||||
|
||||
func TestWebhookReadingURLFromFile(t *testing.T) {
|
||||
ctx, u, fn := test.GetContextWithCancelingURL()
|
||||
defer fn()
|
||||
|
||||
f, err := os.CreateTemp(t.TempDir(), "webhook_url")
|
||||
require.NoError(t, err, "creating temp file failed")
|
||||
_, err = f.WriteString(u.String() + "\n")
|
||||
require.NoError(t, err, "writing to temp file failed")
|
||||
|
||||
notifier, err := New(
|
||||
&config.WebhookConfig{
|
||||
URLFile: f.Name(),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, u.String())
|
||||
}
|
||||
|
||||
func TestWebhookURLTemplating(t *testing.T) {
|
||||
var calledURL string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledURL = r.URL.Path
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
groupLabels model.LabelSet
|
||||
alertLabels model.LabelSet
|
||||
expectError bool
|
||||
expectedErrMsg string
|
||||
expectedPath string
|
||||
}{
|
||||
{
|
||||
name: "templating with alert labels",
|
||||
url: srv.URL + "/{{ .GroupLabels.alertname }}/{{ .CommonLabels.severity }}",
|
||||
groupLabels: model.LabelSet{"alertname": "TestAlert"},
|
||||
alertLabels: model.LabelSet{"alertname": "TestAlert", "severity": "critical"},
|
||||
expectError: false,
|
||||
expectedPath: "/TestAlert/critical",
|
||||
},
|
||||
{
|
||||
name: "invalid template field",
|
||||
url: srv.URL + "/{{ .InvalidField }}",
|
||||
groupLabels: model.LabelSet{"alertname": "TestAlert"},
|
||||
alertLabels: model.LabelSet{"alertname": "TestAlert"},
|
||||
expectError: true,
|
||||
expectedErrMsg: "failed to template webhook URL",
|
||||
},
|
||||
{
|
||||
name: "template renders to empty string",
|
||||
url: "{{ if .CommonLabels.nonexistent }}http://example.com{{ end }}",
|
||||
groupLabels: model.LabelSet{"alertname": "TestAlert"},
|
||||
alertLabels: model.LabelSet{"alertname": "TestAlert"},
|
||||
expectError: true,
|
||||
expectedErrMsg: "webhook URL is empty after templating",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
calledURL = "" // Reset for each test
|
||||
|
||||
notifier, err := New(
|
||||
&config.WebhookConfig{
|
||||
URL: config.SecretTemplateURL(tc.url),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "test-group")
|
||||
if tc.groupLabels != nil {
|
||||
ctx = notify.WithGroupLabels(ctx, tc.groupLabels)
|
||||
}
|
||||
|
||||
alerts := []*types.Alert{
|
||||
{
|
||||
Alert: model.Alert{
|
||||
Labels: tc.alertLabels,
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err = notifier.Notify(ctx, alerts...)
|
||||
|
||||
if tc.expectError {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tc.expectedErrMsg)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expectedPath, calledURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,26 +10,6 @@ import (
|
||||
)
|
||||
|
||||
func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/credentials", handler.New(
|
||||
provider.authZ.AdminAccess(provider.cloudIntegrationHandler.GetConnectionCredentials),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetConnectionCredentials",
|
||||
Tags: []string{"cloudintegration"},
|
||||
Summary: "Get connection credentials",
|
||||
Description: "This endpoint retrieves the connection credentials required for integration",
|
||||
Request: nil,
|
||||
RequestContentType: "application/json",
|
||||
Response: new(citypes.Credentials),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
},
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts", handler.New(
|
||||
provider.authZ.AdminAccess(provider.cloudIntegrationHandler.CreateAccount),
|
||||
handler.OpenAPIDef{
|
||||
@@ -37,11 +17,11 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
Tags: []string{"cloudintegration"},
|
||||
Summary: "Create account",
|
||||
Description: "This endpoint creates a new cloud integration account for the specified cloud provider",
|
||||
Request: new(citypes.PostableAccount),
|
||||
Request: new(citypes.PostableConnectionArtifact),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(citypes.GettableAccountWithConnectionArtifact),
|
||||
Response: new(citypes.GettableAccountWithArtifact),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
@@ -79,7 +59,7 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
Description: "This endpoint gets an account for the specified cloud provider",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(citypes.Account),
|
||||
Response: new(citypes.GettableAccount),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
@@ -138,7 +118,6 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
Summary: "List services metadata",
|
||||
Description: "This endpoint lists the services metadata for the specified cloud provider",
|
||||
Request: nil,
|
||||
RequestQuery: new(citypes.ListServicesMetadataParams),
|
||||
RequestContentType: "",
|
||||
Response: new(citypes.GettableServicesMetadata),
|
||||
ResponseContentType: "application/json",
|
||||
@@ -159,9 +138,8 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
Summary: "Get service",
|
||||
Description: "This endpoint gets a service for the specified cloud provider",
|
||||
Request: nil,
|
||||
RequestQuery: new(citypes.GetServiceParams),
|
||||
RequestContentType: "",
|
||||
Response: new(citypes.Service),
|
||||
Response: new(citypes.GettableService),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
@@ -172,7 +150,7 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}/services/{service_id}", handler.New(
|
||||
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/services/{service_id}", handler.New(
|
||||
provider.authZ.AdminAccess(provider.cloudIntegrationHandler.UpdateService),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UpdateService",
|
||||
@@ -201,9 +179,9 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
Tags: []string{"cloudintegration"},
|
||||
Summary: "Agent check-in",
|
||||
Description: "[Deprecated] This endpoint is called by the deployed agent to check in",
|
||||
Request: new(citypes.PostableAgentCheckIn),
|
||||
Request: new(citypes.PostableAgentCheckInRequest),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(citypes.GettableAgentCheckIn),
|
||||
Response: new(citypes.GettableAgentCheckInResponse),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
@@ -221,9 +199,9 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
Tags: []string{"cloudintegration"},
|
||||
Summary: "Agent check-in",
|
||||
Description: "This endpoint is called by the deployed agent to check in",
|
||||
Request: new(citypes.PostableAgentCheckIn),
|
||||
Request: new(citypes.PostableAgentCheckInRequest),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(citypes.GettableAgentCheckIn),
|
||||
Response: new(citypes.GettableAgentCheckInResponse),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
|
||||
@@ -63,7 +63,6 @@ type RetryConfig struct {
|
||||
|
||||
func newConfig() factory.Config {
|
||||
return Config{
|
||||
Provider: "noop",
|
||||
BufferSize: 1000,
|
||||
BatchSize: 100,
|
||||
FlushInterval: time.Second,
|
||||
|
||||
@@ -172,7 +172,7 @@ func Ast(cause error, typ typ) bool {
|
||||
return t == typ
|
||||
}
|
||||
|
||||
// Asc checks if the provided error matches the specified custom error code.
|
||||
// Ast checks if the provided error matches the specified custom error code.
|
||||
func Asc(cause error, code Code) bool {
|
||||
_, c, _, _, _, _ := Unwrapb(cause)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user