Compare commits

..

16 Commits

Author SHA1 Message Date
SagarRajput-7
b80af505a3 feat: updated user api to v2 and accordingly update members page and role management 2026-04-01 16:31:38 +05:30
SagarRajput-7
5e122de0ba Merge branch 'main' into platform-pod/issues/1830-sa 2026-03-31 23:40:32 +05:30
SagarRajput-7
4c292ccfe2 feat: enabled service account and deprecated API Keys (#10715)
* feat: enabled service account and deprecated API Keys

* feat: deprecated API Keys

* feat: service account spec updates and role management changes

* feat: updated the error component for roles management

* feat: updated test case

* feat: updated the error component and added retries

* feat: refactored code and added retry to happend 3 times total

* feat: fixed feedbacks and added test case

* feat: refactored code and removed retry

* feat: updated the test cases
2026-03-31 23:39:28 +05:30
vikrantgupta25
faa964fb1d feat(serviceaccount): merge main 2026-03-31 20:10:48 +05:30
vikrantgupta25
73ecbb3951 feat(serviceaccount): add restrictions for factor_api_key 2026-03-31 01:17:48 +05:30
Vikrant Gupta
244216e49f Merge branch 'main' into platform-pod/issues/1830-sa 2026-03-30 23:49:09 +05:30
vikrantgupta25
be47fd5316 feat(serviceaccount): add restrictions for factor_api_key 2026-03-30 23:46:49 +05:30
vikrantgupta25
6f513b9497 feat(serviceaccount): rebase main 2026-03-30 22:37:28 +05:30
vikrantgupta25
1f9bfb5d97 feat(serviceaccount): update txlock to immediate to avoid busy snapshot errors 2026-03-30 22:29:26 +05:30
vikrantgupta25
e66dec439c feat(serviceaccount): fix openapi spec 2026-03-26 18:35:32 +05:30
vikrantgupta25
ffac1a8768 feat(serviceaccount): fix formatting 2026-03-26 15:20:09 +05:30
vikrantgupta25
b0859f3594 feat(serviceaccount): update integration tests 2026-03-26 14:49:09 +05:30
vikrantgupta25
6cd896b49b feat(serviceaccount): fix lint and testing changes 2026-03-26 13:57:34 +05:30
vikrantgupta25
84adb743f2 feat(serviceaccount): merge main 2026-03-26 13:22:46 +05:30
vikrantgupta25
3f6e20037a feat(serviceaccount): integrate service account with better types 2026-03-26 13:17:23 +05:30
vikrantgupta25
4a6241acc7 feat(serviceaccount): integrate service account 2026-03-26 13:16:20 +05:30
571 changed files with 12939 additions and 97964 deletions

View File

@@ -55,8 +55,6 @@ jobs:
sqlstore-provider:
- postgres
- sqlite
sqlite-mode:
- wal
clickhouse-version:
- 25.5.6
- 25.12.5
@@ -104,7 +102,6 @@ jobs:
--basetemp=./tmp/ \
src/${{matrix.src}} \
--sqlstore-provider ${{matrix.sqlstore-provider}} \
--sqlite-mode ${{matrix.sqlite-mode}} \
--postgres-version ${{matrix.postgres-version}} \
--clickhouse-version ${{matrix.clickhouse-version}} \
--schema-migrator-version ${{matrix.schema-migrator-version}}

2
.gitignore vendored
View File

@@ -51,8 +51,6 @@ ee/query-service/tests/test-deploy/data/
# local data
*.backup
*.db
*.db-shm
*.db-wal
**/db
/deploy/docker/clickhouse-setup/data/
/deploy/docker-swarm/clickhouse-setup/data/

View File

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

View File

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

View File

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

View File

@@ -190,7 +190,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.118.0
image: signoz/signoz:v0.117.1
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.118.0
image: signoz/signoz:v0.117.1
ports:
- "8080:8080" # signoz port
volumes:

View File

@@ -181,7 +181,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.118.0}
image: signoz/signoz:${VERSION:-v0.117.1}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -109,7 +109,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.118.0}
image: signoz/signoz:${VERSION:-v0.117.1}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -327,6 +327,27 @@ components:
nullable: true
type: array
type: object
AuthtypesStorableRole:
properties:
createdAt:
format: date-time
type: string
description:
type: string
id:
type: string
name:
type: string
orgId:
type: string
type:
type: string
updatedAt:
format: date-time
type: string
required:
- id
type: object
AuthtypesTransaction:
properties:
object:
@@ -350,7 +371,7 @@ components:
id:
type: string
role:
$ref: '#/components/schemas/AuthtypesRole'
$ref: '#/components/schemas/AuthtypesStorableRole'
roleId:
type: string
updatedAt:
@@ -360,11 +381,6 @@ components:
type: string
required:
- id
- userId
- roleId
- createdAt
- updatedAt
- role
type: object
AuthtypesUserWithRoles:
properties:
@@ -403,65 +419,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 +451,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 +502,7 @@ components:
properties:
enabled:
type: boolean
s3Buckets:
s3_buckets:
additionalProperties:
items:
type: string
@@ -496,19 +514,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 +577,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 +597,13 @@ components:
unit:
type: string
type: object
CloudintegrationtypesCollectionStrategy:
properties:
aws:
$ref: '#/components/schemas/CloudintegrationtypesAWSCollectionStrategy'
required:
- aws
type: object
CloudintegrationtypesConnectionArtifact:
properties:
aws:
@@ -619,21 +611,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 +642,7 @@ components:
nullable: true
type: array
type: object
CloudintegrationtypesGettableAccountWithConnectionArtifact:
CloudintegrationtypesGettableAccountWithArtifact:
properties:
connectionArtifact:
$ref: '#/components/schemas/CloudintegrationtypesConnectionArtifact'
@@ -678,7 +661,7 @@ components:
required:
- accounts
type: object
CloudintegrationtypesGettableAgentCheckIn:
CloudintegrationtypesGettableAgentCheckInResponse:
properties:
account_id:
type: string
@@ -727,72 +710,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 +743,6 @@ components:
properties:
assets:
$ref: '#/components/schemas/CloudintegrationtypesAssets'
cloudIntegrationService:
$ref: '#/components/schemas/CloudintegrationtypesCloudIntegrationService'
dataCollected:
$ref: '#/components/schemas/CloudintegrationtypesDataCollected'
icon:
@@ -830,10 +751,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 +765,9 @@ components:
- icon
- overview
- assets
- supportedSignals
- supported_signals
- dataCollected
- telemetryCollectionStrategy
- cloudIntegrationService
type: object
CloudintegrationtypesServiceConfig:
properties:
@@ -854,22 +776,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 +799,6 @@ components:
metrics:
type: boolean
type: object
CloudintegrationtypesTelemetryCollectionStrategy:
properties:
aws:
$ref: '#/components/schemas/CloudintegrationtypesAWSTelemetryCollectionStrategy'
required:
- aws
type: object
CloudintegrationtypesUpdatableAccount:
properties:
config:
@@ -2414,6 +2313,15 @@ components:
- status
- error
type: object
RulestatehistorytypesAlertState:
enum:
- inactive
- pending
- recovering
- firing
- nodata
- disabled
type: string
RulestatehistorytypesGettableRuleStateHistory:
properties:
fingerprint:
@@ -2425,15 +2333,15 @@ components:
nullable: true
type: array
overallState:
$ref: '#/components/schemas/RuletypesAlertState'
$ref: '#/components/schemas/RulestatehistorytypesAlertState'
overallStateChanged:
type: boolean
ruleId:
ruleID:
type: string
ruleName:
type: string
state:
$ref: '#/components/schemas/RuletypesAlertState'
$ref: '#/components/schemas/RulestatehistorytypesAlertState'
stateChanged:
type: boolean
unixMilli:
@@ -2443,7 +2351,7 @@ components:
format: double
type: number
required:
- ruleId
- ruleID
- ruleName
- overallState
- overallStateChanged
@@ -2533,21 +2441,12 @@ components:
format: int64
type: integer
state:
$ref: '#/components/schemas/RuletypesAlertState'
$ref: '#/components/schemas/RulestatehistorytypesAlertState'
required:
- state
- start
- end
type: object
RuletypesAlertState:
enum:
- inactive
- pending
- recovering
- firing
- nodata
- disabled
type: string
ServiceaccounttypesGettableFactorAPIKey:
properties:
createdAt:
@@ -3198,7 +3097,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/CloudintegrationtypesPostableAgentCheckIn'
$ref: '#/components/schemas/CloudintegrationtypesPostableAgentCheckInRequest'
responses:
"200":
content:
@@ -3206,7 +3105,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/CloudintegrationtypesGettableAgentCheckIn'
$ref: '#/components/schemas/CloudintegrationtypesGettableAgentCheckInResponse'
status:
type: string
required:
@@ -3307,22 +3206,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 +3410,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 +3425,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/CloudintegrationtypesPostableAgentCheckIn'
$ref: '#/components/schemas/CloudintegrationtypesPostableAgentCheckInRequest'
responses:
"200":
content:
@@ -3589,7 +3433,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/CloudintegrationtypesGettableAgentCheckIn'
$ref: '#/components/schemas/CloudintegrationtypesGettableAgentCheckInResponse'
status:
type: string
required:
@@ -3623,59 +3467,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 +3474,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 +3526,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 +3577,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
@@ -8639,7 +8469,7 @@ paths:
- in: query
name: state
schema:
$ref: '#/components/schemas/RuletypesAlertState'
$ref: '#/components/schemas/RulestatehistorytypesAlertState'
- in: query
name: filterExpression
schema:

View File

@@ -193,7 +193,6 @@ uv run pytest --basetemp=./tmp/ -vv --reuse src/passwordauthn/01_register.py::te
Tests can be configured using pytest options:
- `--sqlstore-provider` - Choose database provider (default: postgres)
- `--sqlite-mode` - SQLite journal mode: `delete` or `wal` (default: delete). Only relevant when `--sqlstore-provider=sqlite`.
- `--postgres-version` - PostgreSQL version (default: 15)
- `--clickhouse-version` - ClickHouse version (default: 25.5.6)
- `--zookeeper-version` - Zookeeper version (default: 3.7.1)
@@ -203,6 +202,7 @@ Example:
uv run pytest --basetemp=./tmp/ -vv --reuse --sqlstore-provider=postgres --postgres-version=14 src/auth/
```
## What should I remember?
- **Always use the `--reuse` flag** when setting up the environment to keep containers running
@@ -213,4 +213,3 @@ uv run pytest --basetemp=./tmp/ -vv --reuse --sqlstore-provider=postgres --postg
- **Use descriptive test names** that clearly indicate what is being tested
- **Leverage fixtures** for common setup and authentication
- **Test both success and failure scenarios** to ensure robust functionality
- **`--sqlite-mode=wal` does not work on macOS.** The integration test environment runs SigNoz inside a Linux container with the SQLite database file mounted from the macOS host. WAL mode requires shared memory between connections, and connections crossing the VM boundary (macOS host ↔ Linux container) cannot share the WAL index, resulting in `SQLITE_IOERR_SHORT_READ`. WAL mode is tested in CI on Linux only.

View File

@@ -32,7 +32,7 @@ func (s Seasonality) IsValid() bool {
}
type AnomaliesRequest struct {
Params *qbtypes.QueryRangeRequest
Params qbtypes.QueryRangeRequest
Seasonality Seasonality
}
@@ -81,7 +81,7 @@ type anomalyQueryParams struct {
Past3SeasonQuery qbtypes.QueryRangeRequest
}
func prepareAnomalyQueryParams(req *qbtypes.QueryRangeRequest, seasonality Seasonality) *anomalyQueryParams {
func prepareAnomalyQueryParams(req qbtypes.QueryRangeRequest, seasonality Seasonality) *anomalyQueryParams {
start := req.Start
end := req.End

View File

@@ -76,12 +76,12 @@ func (provider *provider) Start(ctx context.Context) error {
}
func (provider *provider) Audit(ctx context.Context, event audittypes.AuditEvent) {
if event.PrincipalAttributes.PrincipalOrgID.IsZero() {
if event.PrincipalOrgID.IsZero() {
provider.settings.Logger().WarnContext(ctx, "audit event dropped as org_id is zero")
return
}
if _, err := provider.licensing.GetActive(ctx, event.PrincipalAttributes.PrincipalOrgID); err != nil {
if _, err := provider.licensing.GetActive(ctx, event.PrincipalOrgID); err != nil {
return
}

View File

@@ -65,7 +65,7 @@ func (h *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
}
if anomalyQuery, ok := queryRangeRequest.IsAnomalyRequest(); ok {
anomalies, err := h.handleAnomalyQuery(ctx, orgID, anomalyQuery, &queryRangeRequest)
anomalies, err := h.handleAnomalyQuery(ctx, orgID, anomalyQuery, queryRangeRequest)
if err != nil {
render.Error(rw, errors.NewInternalf(errors.CodeInternal, "failed to get anomalies: %v", err))
return
@@ -149,7 +149,7 @@ func (h *handler) createAnomalyProvider(seasonality anomalyV2.Seasonality) anoma
}
}
func (h *handler) handleAnomalyQuery(ctx context.Context, orgID valuer.UUID, anomalyQuery *qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation], queryRangeRequest *qbtypes.QueryRangeRequest) (*anomalyV2.AnomaliesResponse, error) {
func (h *handler) handleAnomalyQuery(ctx context.Context, orgID valuer.UUID, anomalyQuery *qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation], queryRangeRequest qbtypes.QueryRangeRequest) (*anomalyV2.AnomaliesResponse, error) {
seasonality := extractSeasonality(anomalyQuery)
provider := h.createAnomalyProvider(seasonality)

View File

@@ -49,6 +49,7 @@ import (
opAmpModel "github.com/SigNoz/signoz/pkg/query-service/app/opamp/model"
baseconst "github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/query-service/healthcheck"
baseint "github.com/SigNoz/signoz/pkg/query-service/interfaces"
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/query-service/utils"
)
@@ -98,6 +99,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
)
rm, err := makeRulesManager(
reader,
signoz.Cache,
signoz.Alertmanager,
signoz.SQLStore,
@@ -227,7 +229,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.NewLogging(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes).Wrap)
r.Use(middleware.NewComment().Wrap)
apiHandler.RegisterRoutes(r, am)
@@ -343,7 +345,7 @@ func (s *Server) Stop(ctx context.Context) error {
return nil
}
func makeRulesManager(cache cache.Cache, alertmanager alertmanager.Alertmanager, sqlstore sqlstore.SQLStore, telemetryStore telemetrystore.TelemetryStore, metadataStore telemetrytypes.MetadataStore, prometheus prometheus.Prometheus, orgGetter organization.Getter, ruleStateHistoryModule rulestatehistory.Module, querier querier.Querier, providerSettings factory.ProviderSettings, queryParser queryparser.QueryParser) (*baserules.Manager, error) {
func makeRulesManager(ch baseint.Reader, cache cache.Cache, alertmanager alertmanager.Alertmanager, sqlstore sqlstore.SQLStore, telemetryStore telemetrystore.TelemetryStore, metadataStore telemetrytypes.MetadataStore, prometheus prometheus.Prometheus, orgGetter organization.Getter, ruleStateHistoryModule rulestatehistory.Module, querier querier.Querier, providerSettings factory.ProviderSettings, queryParser queryparser.QueryParser) (*baserules.Manager, error) {
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore)
// create manager opts
@@ -352,6 +354,7 @@ func makeRulesManager(cache cache.Cache, alertmanager alertmanager.Alertmanager,
MetadataStore: metadataStore,
Prometheus: prometheus,
Context: context.Background(),
Reader: ch,
Querier: querier,
Logger: providerSettings.Logger,
Cache: cache,
@@ -362,7 +365,7 @@ func makeRulesManager(cache cache.Cache, alertmanager alertmanager.Alertmanager,
OrgGetter: orgGetter,
RuleStore: ruleStore,
MaintenanceStore: maintenanceStore,
SQLStore: sqlstore,
SqlStore: sqlstore,
QueryParser: queryParser,
RuleStateHistoryModule: ruleStateHistoryModule,
}

View File

@@ -5,33 +5,58 @@ import (
"encoding/json"
"fmt"
"log/slog"
"math"
"strings"
"sync"
"time"
"github.com/SigNoz/signoz/ee/query-service/anomaly"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/types/rulestatehistorytypes"
"github.com/SigNoz/signoz/pkg/query-service/common"
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/transition"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
querierV2 "github.com/SigNoz/signoz/pkg/query-service/app/querier/v2"
"github.com/SigNoz/signoz/pkg/query-service/app/queryBuilder"
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
"github.com/SigNoz/signoz/pkg/query-service/utils/times"
"github.com/SigNoz/signoz/pkg/query-service/utils/timestamp"
"github.com/SigNoz/signoz/pkg/units"
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/ee/anomaly"
querierV5 "github.com/SigNoz/signoz/pkg/querier"
anomalyV2 "github.com/SigNoz/signoz/ee/anomaly"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
const (
RuleTypeAnomaly = "anomaly_rule"
)
type AnomalyRule struct {
*baserules.BaseRule
mtx sync.Mutex
// querier is used for alerts migrated after the introduction of new query builder
querier querier.Querier
reader interfaces.Reader
provider anomaly.Provider
// querierV2 is used for alerts created after the introduction of new metrics query builder
querierV2 interfaces.Querier
// querierV5 is used for alerts migrated after the introduction of new query builder
querierV5 querierV5.Querier
provider anomaly.Provider
providerV2 anomalyV2.Provider
version string
logger *slog.Logger
@@ -45,70 +70,125 @@ func NewAnomalyRule(
id string,
orgID valuer.UUID,
p *ruletypes.PostableRule,
querier querier.Querier,
reader interfaces.Reader,
querierV5 querierV5.Querier,
logger *slog.Logger,
cache cache.Cache,
opts ...baserules.RuleOption,
) (*AnomalyRule, error) {
logger.Info("creating new AnomalyRule", slog.String("rule.id", id))
logger.Info("creating new AnomalyRule", "rule_id", id)
opts = append(opts, baserules.WithLogger(logger))
baseRule, err := baserules.NewBaseRule(id, orgID, p, opts...)
baseRule, err := baserules.NewBaseRule(id, orgID, p, reader, opts...)
if err != nil {
return nil, err
}
r := AnomalyRule{
t := AnomalyRule{
BaseRule: baseRule,
querier: querier,
version: p.Version,
logger: logger.With(slog.String("rule.id", id)),
}
switch p.RuleCondition.Seasonality {
case ruletypes.SeasonalityHourly:
r.seasonality = anomaly.SeasonalityHourly
case ruletypes.SeasonalityDaily:
r.seasonality = anomaly.SeasonalityDaily
case ruletypes.SeasonalityWeekly:
r.seasonality = anomaly.SeasonalityWeekly
switch strings.ToLower(p.RuleCondition.Seasonality) {
case "hourly":
t.seasonality = anomaly.SeasonalityHourly
case "daily":
t.seasonality = anomaly.SeasonalityDaily
case "weekly":
t.seasonality = anomaly.SeasonalityWeekly
default:
r.seasonality = anomaly.SeasonalityDaily
t.seasonality = anomaly.SeasonalityDaily
}
r.logger.Info("using seasonality", slog.String("rule.seasonality", r.seasonality.StringValue()))
logger.Info("using seasonality", "seasonality", t.seasonality.String())
if r.seasonality == anomaly.SeasonalityHourly {
r.provider = anomaly.NewHourlyProvider(
anomaly.WithQuerier[*anomaly.HourlyProvider](querier),
anomaly.WithLogger[*anomaly.HourlyProvider](r.logger),
querierOptsV2 := querierV2.QuerierOptions{
Reader: reader,
Cache: cache,
KeyGenerator: queryBuilder.NewKeyGenerator(),
}
t.querierV2 = querierV2.NewQuerier(querierOptsV2)
t.reader = reader
if t.seasonality == anomaly.SeasonalityHourly {
t.provider = anomaly.NewHourlyProvider(
anomaly.WithCache[*anomaly.HourlyProvider](cache),
anomaly.WithKeyGenerator[*anomaly.HourlyProvider](queryBuilder.NewKeyGenerator()),
anomaly.WithReader[*anomaly.HourlyProvider](reader),
)
} else if r.seasonality == anomaly.SeasonalityDaily {
r.provider = anomaly.NewDailyProvider(
anomaly.WithQuerier[*anomaly.DailyProvider](querier),
anomaly.WithLogger[*anomaly.DailyProvider](r.logger),
} else if t.seasonality == anomaly.SeasonalityDaily {
t.provider = anomaly.NewDailyProvider(
anomaly.WithCache[*anomaly.DailyProvider](cache),
anomaly.WithKeyGenerator[*anomaly.DailyProvider](queryBuilder.NewKeyGenerator()),
anomaly.WithReader[*anomaly.DailyProvider](reader),
)
} else if r.seasonality == anomaly.SeasonalityWeekly {
r.provider = anomaly.NewWeeklyProvider(
anomaly.WithQuerier[*anomaly.WeeklyProvider](querier),
anomaly.WithLogger[*anomaly.WeeklyProvider](r.logger),
} else if t.seasonality == anomaly.SeasonalityWeekly {
t.provider = anomaly.NewWeeklyProvider(
anomaly.WithCache[*anomaly.WeeklyProvider](cache),
anomaly.WithKeyGenerator[*anomaly.WeeklyProvider](queryBuilder.NewKeyGenerator()),
anomaly.WithReader[*anomaly.WeeklyProvider](reader),
)
}
return &r, nil
if t.seasonality == anomaly.SeasonalityHourly {
t.providerV2 = anomalyV2.NewHourlyProvider(
anomalyV2.WithQuerier[*anomalyV2.HourlyProvider](querierV5),
anomalyV2.WithLogger[*anomalyV2.HourlyProvider](logger),
)
} else if t.seasonality == anomaly.SeasonalityDaily {
t.providerV2 = anomalyV2.NewDailyProvider(
anomalyV2.WithQuerier[*anomalyV2.DailyProvider](querierV5),
anomalyV2.WithLogger[*anomalyV2.DailyProvider](logger),
)
} else if t.seasonality == anomaly.SeasonalityWeekly {
t.providerV2 = anomalyV2.NewWeeklyProvider(
anomalyV2.WithQuerier[*anomalyV2.WeeklyProvider](querierV5),
anomalyV2.WithLogger[*anomalyV2.WeeklyProvider](logger),
)
}
t.querierV5 = querierV5
t.version = p.Version
t.logger = logger
return &t, nil
}
func (r *AnomalyRule) Type() ruletypes.RuleType {
return ruletypes.RuleTypeAnomaly
return RuleTypeAnomaly
}
func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) *qbtypes.QueryRangeRequest {
func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) (*v3.QueryRangeParamsV3, error) {
r.logger.InfoContext(
ctx, "prepare query range request", slog.Int64("ts", ts.UnixMilli()),
slog.Int64("eval.window_ms", r.EvalWindow().Milliseconds()),
slog.Int64("eval.delay_ms", r.EvalDelay().Milliseconds()),
ctx, "prepare query range request v4", "ts", ts.UnixMilli(), "eval_window", r.EvalWindow().Milliseconds(), "eval_delay", r.EvalDelay().Milliseconds(),
)
st, en := r.Timestamps(ts)
start := st.UnixMilli()
end := en.UnixMilli()
compositeQuery := r.Condition().CompositeQuery
if compositeQuery.PanelType != v3.PanelTypeGraph {
compositeQuery.PanelType = v3.PanelTypeGraph
}
// default mode
return &v3.QueryRangeParamsV3{
Start: start,
End: end,
Step: int64(math.Max(float64(common.MinAllowedStepInterval(start, end)), 60)),
CompositeQuery: compositeQuery,
Variables: make(map[string]interface{}, 0),
NoCache: false,
}, nil
}
func (r *AnomalyRule) prepareQueryRangeV5(ctx context.Context, ts time.Time) (*qbtypes.QueryRangeRequest, error) {
r.logger.InfoContext(ctx, "prepare query range request v5", "ts", ts.UnixMilli(), "eval_window", r.EvalWindow().Milliseconds(), "eval_delay", r.EvalDelay().Milliseconds())
startTs, endTs := r.Timestamps(ts)
start, end := startTs.UnixMilli(), endTs.UnixMilli()
@@ -123,14 +203,25 @@ func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) *qbty
}
req.CompositeQuery.Queries = make([]qbtypes.QueryEnvelope, len(r.Condition().CompositeQuery.Queries))
copy(req.CompositeQuery.Queries, r.Condition().CompositeQuery.Queries)
return req
return req, nil
}
func (r *AnomalyRule) GetSelectedQuery() string {
return r.Condition().GetSelectedQueryName()
}
func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, ts time.Time) (ruletypes.Vector, error) {
params := r.prepareQueryRange(ctx, ts)
params, err := r.prepareQueryRange(ctx, ts)
if err != nil {
return nil, err
}
err = r.PopulateTemporality(ctx, orgID, params)
if err != nil {
return nil, fmt.Errorf("internal error while setting temporality")
}
anomalies, err := r.provider.GetAnomalies(ctx, orgID, &anomaly.AnomaliesRequest{
anomalies, err := r.provider.GetAnomalies(ctx, orgID, &anomaly.GetAnomaliesRequest{
Params: params,
Seasonality: r.seasonality,
})
@@ -138,43 +229,87 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t
return nil, err
}
var queryResult *qbtypes.TimeSeriesData
var queryResult *v3.Result
for _, result := range anomalies.Results {
if result.QueryName == r.SelectedQuery(ctx) {
if result.QueryName == r.GetSelectedQuery() {
queryResult = result
break
}
}
if queryResult == nil {
r.logger.WarnContext(ctx, "nil qb result", slog.Int64("ts", ts.UnixMilli()))
return ruletypes.Vector{}, nil
}
hasData := len(queryResult.Aggregations) > 0 &&
queryResult.Aggregations[0] != nil &&
len(queryResult.Aggregations[0].AnomalyScores) > 0
hasData := len(queryResult.AnomalyScores) > 0
if missingDataAlert := r.HandleMissingDataAlert(ctx, ts, hasData); missingDataAlert != nil {
return ruletypes.Vector{*missingDataAlert}, nil
} else if !hasData {
r.logger.WarnContext(ctx, "no anomaly result")
return ruletypes.Vector{}, nil
}
var resultVector ruletypes.Vector
scoresJSON, _ := json.Marshal(queryResult.Aggregations[0].AnomalyScores)
// TODO(srikanthccv): this could be noisy but we do this to answer false alert requests
r.logger.InfoContext(ctx, "anomaly scores", slog.String("anomaly.scores", string(scoresJSON)))
scoresJSON, _ := json.Marshal(queryResult.AnomalyScores)
r.logger.InfoContext(ctx, "anomaly scores", "scores", string(scoresJSON))
for _, series := range queryResult.AnomalyScores {
if !r.Condition().ShouldEval(series) {
r.logger.InfoContext(ctx, "not enough data points to evaluate series, skipping", "ruleid", r.ID(), "numPoints", len(series.Points), "requiredPoints", r.Condition().RequiredNumPoints)
continue
}
results, err := r.Threshold.Eval(*series, r.Unit(), ruletypes.EvalData{
ActiveAlerts: r.ActiveAlertsLabelFP(),
SendUnmatched: r.ShouldSendUnmatched(),
})
if err != nil {
return nil, err
}
resultVector = append(resultVector, results...)
}
return resultVector, nil
}
func (r *AnomalyRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUID, ts time.Time) (ruletypes.Vector, error) {
params, err := r.prepareQueryRangeV5(ctx, ts)
if err != nil {
return nil, err
}
anomalies, err := r.providerV2.GetAnomalies(ctx, orgID, &anomalyV2.AnomaliesRequest{
Params: *params,
Seasonality: anomalyV2.Seasonality{String: valuer.NewString(r.seasonality.String())},
})
if err != nil {
return nil, err
}
var qbResult *qbtypes.TimeSeriesData
for _, result := range anomalies.Results {
if result.QueryName == r.GetSelectedQuery() {
qbResult = result
break
}
}
if qbResult == nil {
r.logger.WarnContext(ctx, "nil qb result", "ts", ts.UnixMilli())
}
queryResult := transition.ConvertV5TimeSeriesDataToV4Result(qbResult)
hasData := len(queryResult.AnomalyScores) > 0
if missingDataAlert := r.HandleMissingDataAlert(ctx, ts, hasData); missingDataAlert != nil {
return ruletypes.Vector{*missingDataAlert}, nil
}
var resultVector ruletypes.Vector
scoresJSON, _ := json.Marshal(queryResult.AnomalyScores)
r.logger.InfoContext(ctx, "anomaly scores", "scores", string(scoresJSON))
// Filter out new series if newGroupEvalDelay is configured
seriesToProcess := queryResult.Aggregations[0].AnomalyScores
seriesToProcess := queryResult.AnomalyScores
if r.ShouldSkipNewGroups() {
filteredSeries, filterErr := r.BaseRule.FilterNewSeries(ctx, ts, seriesToProcess)
// In case of error we log the error and continue with the original series
if filterErr != nil {
r.logger.ErrorContext(ctx, "error filtering new series", errors.Attr(filterErr))
r.logger.ErrorContext(ctx, "Error filtering new series, ", errors.Attr(filterErr), "rule_name", r.Name())
} else {
seriesToProcess = filteredSeries
}
@@ -182,14 +317,10 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t
for _, series := range seriesToProcess {
if !r.Condition().ShouldEval(series) {
r.logger.InfoContext(
ctx, "not enough data points to evaluate series, skipping",
slog.Int("series.num_points", len(series.Values)),
slog.Int("series.required_points", r.Condition().RequiredNumPoints),
)
r.logger.InfoContext(ctx, "not enough data points to evaluate series, skipping", "ruleid", r.ID(), "numPoints", len(series.Points), "requiredPoints", r.Condition().RequiredNumPoints)
continue
}
results, err := r.Threshold.Eval(series, r.Unit(), ruletypes.EvalData{
results, err := r.Threshold.Eval(*series, r.Unit(), ruletypes.EvalData{
ActiveAlerts: r.ActiveAlertsLabelFP(),
SendUnmatched: r.ShouldSendUnmatched(),
})
@@ -210,9 +341,13 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
var res ruletypes.Vector
var err error
r.logger.InfoContext(ctx, "running query")
res, err = r.buildAndRunQuery(ctx, r.OrgID(), ts)
if r.version == "v5" {
r.logger.InfoContext(ctx, "running v5 query")
res, err = r.buildAndRunQueryV5(ctx, r.OrgID(), ts)
} else {
r.logger.InfoContext(ctx, "running v4 query")
res, err = r.buildAndRunQuery(ctx, r.OrgID(), ts)
}
if err != nil {
return 0, err
}
@@ -236,10 +371,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
}
value := valueFormatter.Format(smpl.V, r.Unit())
threshold := valueFormatter.Format(smpl.Target, smpl.TargetUnit)
r.logger.DebugContext(
ctx, "alert template data for rule", slog.String("formatter.name", valueFormatter.Name()),
slog.String("alert.value", value), slog.String("alert.threshold", threshold),
)
r.logger.DebugContext(ctx, "Alert template data for rule", "rule_name", r.Name(), "formatter", valueFormatter.Name(), "value", value, "threshold", threshold)
tmplData := ruletypes.AlertTemplateData(l, value, threshold)
// Inject some convenience variables that are easier to remember for users
@@ -254,34 +386,35 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
defs+text,
"__alert_"+r.Name(),
tmplData,
times.Time(timestamp.FromTime(ts)),
nil,
)
result, err := tmpl.Expand()
if err != nil {
result = fmt.Sprintf("<error expanding template: %s>", err)
r.logger.ErrorContext(ctx, "expanding alert template failed", errors.Attr(err), slog.Any("alert.template_data", tmplData))
r.logger.ErrorContext(ctx, "Expanding alert template failed", errors.Attr(err), "data", tmplData, "rule_name", r.Name())
}
return result
}
lb := ruletypes.NewBuilder(smpl.Metric...).Del(ruletypes.MetricNameLabel).Del(ruletypes.TemporalityLabel)
resultLabels := ruletypes.NewBuilder(smpl.Metric...).Del(ruletypes.MetricNameLabel).Del(ruletypes.TemporalityLabel).Labels()
lb := labels.NewBuilder(smpl.Metric).Del(labels.MetricNameLabel).Del(labels.TemporalityLabel)
resultLabels := labels.NewBuilder(smpl.Metric).Del(labels.MetricNameLabel).Del(labels.TemporalityLabel).Labels()
for name, value := range r.Labels().Map() {
lb.Set(name, expand(value))
}
lb.Set(ruletypes.AlertNameLabel, r.Name())
lb.Set(ruletypes.AlertRuleIDLabel, r.ID())
lb.Set(ruletypes.RuleSourceLabel, r.GeneratorURL())
lb.Set(labels.AlertNameLabel, r.Name())
lb.Set(labels.AlertRuleIdLabel, r.ID())
lb.Set(labels.RuleSourceLabel, r.GeneratorURL())
annotations := make(ruletypes.Labels, 0, len(r.Annotations().Map()))
annotations := make(labels.Labels, 0, len(r.Annotations().Map()))
for name, value := range r.Annotations().Map() {
annotations = append(annotations, ruletypes.Label{Name: name, Value: expand(value)})
annotations = append(annotations, labels.Label{Name: name, Value: expand(value)})
}
if smpl.IsMissing {
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
lb.Set(ruletypes.NoDataLabel, "true")
lb.Set(labels.AlertNameLabel, "[No data] "+r.Name())
lb.Set(labels.NoDataLabel, "true")
}
lbs := lb.Labels()
@@ -289,17 +422,17 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
resultFPs[h] = struct{}{}
if _, ok := alerts[h]; ok {
r.logger.ErrorContext(ctx, "the alert query returns duplicate records", slog.Any("alert", alerts[h]))
err = errors.NewInternalf(errors.CodeInternal, "duplicate alert found, vector contains metrics with the same labelset after applying alert labels")
r.logger.ErrorContext(ctx, "the alert query returns duplicate records", "rule_id", r.ID(), "alert", alerts[h])
err = fmt.Errorf("duplicate alert found, vector contains metrics with the same labelset after applying alert labels")
return 0, err
}
alerts[h] = &ruletypes.Alert{
Labels: lbs,
QueryResultLabels: resultLabels,
QueryResultLables: resultLabels,
Annotations: annotations,
ActiveAt: ts,
State: ruletypes.StatePending,
State: model.StatePending,
Value: smpl.V,
GeneratorURL: r.GeneratorURL(),
Receivers: ruleReceiverMap[lbs.Map()[ruletypes.LabelThresholdName]],
@@ -308,12 +441,12 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
}
}
r.logger.InfoContext(ctx, "number of alerts found", slog.Int("alert.count", len(alerts)))
r.logger.InfoContext(ctx, "number of alerts found", "rule_name", r.Name(), "alerts_count", len(alerts))
// alerts[h] is ready, add or update active list now
for h, a := range alerts {
// Check whether we already have alerting state for the identifying label set.
// Update the last value and annotations if so, create a new alert entry otherwise.
if alert, ok := r.Active[h]; ok && alert.State != ruletypes.StateInactive {
if alert, ok := r.Active[h]; ok && alert.State != model.StateInactive {
alert.Value = a.Value
alert.Annotations = a.Annotations
@@ -329,76 +462,76 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
r.Active[h] = a
}
itemsToAdd := []rulestatehistorytypes.RuleStateHistory{}
itemsToAdd := []model.RuleStateHistory{}
// Check if any pending alerts should be removed or fire now. Write out alert timeseries.
for fp, a := range r.Active {
labelsJSON, err := json.Marshal(a.QueryResultLabels)
labelsJSON, err := json.Marshal(a.QueryResultLables)
if err != nil {
r.logger.ErrorContext(ctx, "error marshaling labels", errors.Attr(err), slog.Any("alert.labels", a.Labels))
r.logger.ErrorContext(ctx, "error marshaling labels", errors.Attr(err), "labels", a.Labels)
}
if _, ok := resultFPs[fp]; !ok {
// If the alert was previously firing, keep it around for a given
// retention time so it is reported as resolved to the AlertManager.
if a.State == ruletypes.StatePending || (!a.ResolvedAt.IsZero() && ts.Sub(a.ResolvedAt) > ruletypes.ResolvedRetention) {
if a.State == model.StatePending || (!a.ResolvedAt.IsZero() && ts.Sub(a.ResolvedAt) > ruletypes.ResolvedRetention) {
delete(r.Active, fp)
}
if a.State != ruletypes.StateInactive {
a.State = ruletypes.StateInactive
if a.State != model.StateInactive {
a.State = model.StateInactive
a.ResolvedAt = ts
itemsToAdd = append(itemsToAdd, rulestatehistorytypes.RuleStateHistory{
itemsToAdd = append(itemsToAdd, model.RuleStateHistory{
RuleID: r.ID(),
RuleName: r.Name(),
State: ruletypes.StateInactive,
State: model.StateInactive,
StateChanged: true,
UnixMilli: ts.UnixMilli(),
Labels: rulestatehistorytypes.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLabels.Hash(),
Labels: model.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLables.Hash(),
Value: a.Value,
})
}
continue
}
if a.State == ruletypes.StatePending && ts.Sub(a.ActiveAt) >= r.HoldDuration().Duration() {
a.State = ruletypes.StateFiring
if a.State == model.StatePending && ts.Sub(a.ActiveAt) >= r.HoldDuration().Duration() {
a.State = model.StateFiring
a.FiredAt = ts
state := ruletypes.StateFiring
state := model.StateFiring
if a.Missing {
state = ruletypes.StateNoData
state = model.StateNoData
}
itemsToAdd = append(itemsToAdd, rulestatehistorytypes.RuleStateHistory{
itemsToAdd = append(itemsToAdd, model.RuleStateHistory{
RuleID: r.ID(),
RuleName: r.Name(),
State: state,
StateChanged: true,
UnixMilli: ts.UnixMilli(),
Labels: rulestatehistorytypes.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLabels.Hash(),
Labels: model.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLables.Hash(),
Value: a.Value,
})
}
// We need to change firing alert to recovering if the returned sample meets recovery threshold
changeFiringToRecovering := a.State == ruletypes.StateFiring && a.IsRecovering
changeFiringToRecovering := a.State == model.StateFiring && a.IsRecovering
// We need to change recovering alerts to firing if the returned sample meets target threshold
changeRecoveringToFiring := a.State == ruletypes.StateRecovering && !a.IsRecovering && !a.Missing
changeRecoveringToFiring := a.State == model.StateRecovering && !a.IsRecovering && !a.Missing
// in any of the above case we need to update the status of alert
if changeFiringToRecovering || changeRecoveringToFiring {
state := ruletypes.StateRecovering
state := model.StateRecovering
if changeRecoveringToFiring {
state = ruletypes.StateFiring
state = model.StateFiring
}
a.State = state
r.logger.DebugContext(ctx, "converting alert state", slog.Any("alert.state", state))
itemsToAdd = append(itemsToAdd, rulestatehistorytypes.RuleStateHistory{
r.logger.DebugContext(ctx, "converting alert state", "name", r.Name(), "state", state)
itemsToAdd = append(itemsToAdd, model.RuleStateHistory{
RuleID: r.ID(),
RuleName: r.Name(),
State: state,
StateChanged: true,
UnixMilli: ts.UnixMilli(),
Labels: rulestatehistorytypes.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLabels.Hash(),
Labels: model.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLables.Hash(),
Value: a.Value,
})
}
@@ -413,7 +546,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
itemsToAdd[idx] = item
}
_ = r.RecordRuleStateHistory(ctx, itemsToAdd)
r.RecordRuleStateHistory(ctx, prevState, currentState, itemsToAdd)
return len(r.Active), nil
}

View File

@@ -2,19 +2,21 @@ package rules
import (
"context"
"log/slog"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/ee/query-service/anomaly"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/SigNoz/signoz/ee/anomaly"
)
// mockAnomalyProvider is a mock implementation of anomaly.Provider for testing.
@@ -22,13 +24,13 @@ import (
// time periods (current, past period, current season, past season, past 2 seasons,
// past 3 seasons), making it cumbersome to create mock data.
type mockAnomalyProvider struct {
responses []*anomaly.AnomaliesResponse
responses []*anomaly.GetAnomaliesResponse
callCount int
}
func (m *mockAnomalyProvider) GetAnomalies(ctx context.Context, orgID valuer.UUID, req *anomaly.AnomaliesRequest) (*anomaly.AnomaliesResponse, error) {
func (m *mockAnomalyProvider) GetAnomalies(ctx context.Context, orgID valuer.UUID, req *anomaly.GetAnomaliesRequest) (*anomaly.GetAnomaliesResponse, error) {
if m.callCount >= len(m.responses) {
return &anomaly.AnomaliesResponse{Results: []*qbtypes.TimeSeriesData{}}, nil
return &anomaly.GetAnomaliesResponse{Results: []*v3.Result{}}, nil
}
resp := m.responses[m.callCount]
m.callCount++
@@ -47,46 +49,45 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "Test anomaly no data",
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeAnomaly,
RuleType: RuleTypeAnomaly,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: evalWindow,
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
Target: &target,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
CompareOp: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
Target: &target,
CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder,
BuilderQueries: map[string]*v3.BuilderQuery{
"A": {
QueryName: "A",
Expression: "A",
DataSource: v3.DataSourceMetrics,
Temporality: v3.Unspecified,
},
}},
},
},
SelectedQuery: "A",
Seasonality: ruletypes.SeasonalityDaily,
Seasonality: "daily",
Thresholds: &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{{
Name: "Test anomaly no data",
TargetValue: &target,
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsAbove,
Name: "Test anomaly no data",
TargetValue: &target,
MatchType: ruletypes.AtleastOnce,
CompareOp: ruletypes.ValueIsAbove,
}},
},
},
}
responseNoData := &anomaly.AnomaliesResponse{
Results: []*qbtypes.TimeSeriesData{
responseNoData := &anomaly.GetAnomaliesResponse{
Results: []*v3.Result{
{
QueryName: "A",
Aggregations: []*qbtypes.AggregationBucket{{
AnomalyScores: []*qbtypes.TimeSeries{},
}},
QueryName: "A",
AnomalyScores: []*v3.Series{},
},
},
}
@@ -114,17 +115,23 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
t.Run(c.description, func(t *testing.T) {
postableRule.RuleCondition.AlertOnAbsent = c.alertOnAbsent
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, nil)
options := clickhouseReader.NewOptions("primaryNamespace")
reader := clickhouseReader.NewReader(slog.Default(), nil, telemetryStore, nil, "", time.Second, nil, nil, options)
rule, err := NewAnomalyRule(
"test-anomaly-rule",
valuer.GenerateUUID(),
&postableRule,
reader,
nil,
logger,
nil,
)
require.NoError(t, err)
rule.provider = &mockAnomalyProvider{
responses: []*anomaly.AnomaliesResponse{responseNoData},
responses: []*anomaly.GetAnomaliesResponse{responseNoData},
}
alertsFound, err := rule.Eval(context.Background(), evalTime)
@@ -149,47 +156,46 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "Test anomaly no data with AbsentFor",
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeAnomaly,
RuleType: RuleTypeAnomaly,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: evalWindow,
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
AlertOnAbsent: true,
Target: &target,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
CompareOp: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
AlertOnAbsent: true,
Target: &target,
CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder,
BuilderQueries: map[string]*v3.BuilderQuery{
"A": {
QueryName: "A",
Expression: "A",
DataSource: v3.DataSourceMetrics,
Temporality: v3.Unspecified,
},
}},
},
},
SelectedQuery: "A",
Seasonality: ruletypes.SeasonalityDaily,
Seasonality: "daily",
Thresholds: &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{{
Name: "Test anomaly no data with AbsentFor",
TargetValue: &target,
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsAbove,
Name: "Test anomaly no data with AbsentFor",
TargetValue: &target,
MatchType: ruletypes.AtleastOnce,
CompareOp: ruletypes.ValueIsAbove,
}},
},
},
}
responseNoData := &anomaly.AnomaliesResponse{
Results: []*qbtypes.TimeSeriesData{
responseNoData := &anomaly.GetAnomaliesResponse{
Results: []*v3.Result{
{
QueryName: "A",
Aggregations: []*qbtypes.AggregationBucket{{
AnomalyScores: []*qbtypes.TimeSeries{},
}},
QueryName: "A",
AnomalyScores: []*v3.Series{},
},
},
}
@@ -223,35 +229,32 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
t1 := baseTime.Add(5 * time.Minute)
t2 := t1.Add(c.timeBetweenEvals)
responseWithData := &anomaly.AnomaliesResponse{
Results: []*qbtypes.TimeSeriesData{
responseWithData := &anomaly.GetAnomaliesResponse{
Results: []*v3.Result{
{
QueryName: "A",
Aggregations: []*qbtypes.AggregationBucket{{
AnomalyScores: []*qbtypes.TimeSeries{
{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{Name: "Test"},
Value: "labels",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: baseTime.UnixMilli(), Value: 1.0},
{Timestamp: baseTime.Add(time.Minute).UnixMilli(), Value: 1.5},
},
AnomalyScores: []*v3.Series{
{
Labels: map[string]string{"test": "label"},
Points: []v3.Point{
{Timestamp: baseTime.UnixMilli(), Value: 1.0},
{Timestamp: baseTime.Add(time.Minute).UnixMilli(), Value: 1.5},
},
},
}},
},
},
},
}
rule, err := NewAnomalyRule("test-anomaly-rule", valuer.GenerateUUID(), &postableRule, nil, logger)
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, nil)
options := clickhouseReader.NewOptions("primaryNamespace")
reader := clickhouseReader.NewReader(slog.Default(), nil, telemetryStore, nil, "", time.Second, nil, nil, options)
rule, err := NewAnomalyRule("test-anomaly-rule", valuer.GenerateUUID(), &postableRule, reader, nil, logger, nil)
require.NoError(t, err)
rule.provider = &mockAnomalyProvider{
responses: []*anomaly.AnomaliesResponse{responseWithData, responseNoData},
responses: []*anomaly.GetAnomaliesResponse{responseWithData, responseNoData},
}
alertsFound1, err := rule.Eval(context.Background(), t1)

View File

@@ -11,7 +11,9 @@ import (
"github.com/google/uuid"
"github.com/SigNoz/signoz/pkg/errors"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -21,7 +23,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
rules := make([]baserules.Rule, 0)
var task baserules.Task
ruleID := baserules.RuleIDFromTaskName(opts.TaskName)
ruleId := baserules.RuleIdFromTaskName(opts.TaskName)
evaluation, err := opts.Rule.Evaluation.GetEvaluation()
if err != nil {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "evaluation is invalid: %v", err)
@@ -30,9 +32,10 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
if opts.Rule.RuleType == ruletypes.RuleTypeThreshold {
// create a threshold rule
tr, err := baserules.NewThresholdRule(
ruleID,
ruleId,
opts.OrgID,
opts.Rule,
opts.Reader,
opts.Querier,
opts.Logger,
baserules.WithEvalDelay(opts.ManagerOpts.EvalDelay),
@@ -55,10 +58,11 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
// create promql rule
pr, err := baserules.NewPromRule(
ruleID,
ruleId,
opts.OrgID,
opts.Rule,
opts.Logger,
opts.Reader,
opts.ManagerOpts.Prometheus,
baserules.WithSQLStore(opts.SQLStore),
baserules.WithQueryParser(opts.ManagerOpts.QueryParser),
@@ -78,11 +82,13 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
} else if opts.Rule.RuleType == ruletypes.RuleTypeAnomaly {
// create anomaly rule
ar, err := NewAnomalyRule(
ruleID,
ruleId,
opts.OrgID,
opts.Rule,
opts.Reader,
opts.Querier,
opts.Logger,
opts.Cache,
baserules.WithEvalDelay(opts.ManagerOpts.EvalDelay),
baserules.WithSQLStore(opts.SQLStore),
baserules.WithQueryParser(opts.ManagerOpts.QueryParser),
@@ -99,7 +105,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)
return nil, fmt.Errorf("unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)
}
return task, nil
@@ -107,12 +113,12 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
// TestNotification prepares a dummy rule for given rule parameters and
// sends a test notification. returns alert count and error (if any)
func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.ApiError) {
ctx := context.Background()
if opts.Rule == nil {
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "rule is required")
return 0, basemodel.BadRequest(fmt.Errorf("rule is required"))
}
parsedRule := opts.Rule
@@ -132,14 +138,15 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
if parsedRule.RuleType == ruletypes.RuleTypeThreshold {
// add special labels for test alerts
parsedRule.Labels[ruletypes.RuleSourceLabel] = ""
parsedRule.Labels[ruletypes.AlertRuleIDLabel] = ""
parsedRule.Labels[labels.RuleSourceLabel] = ""
parsedRule.Labels[labels.AlertRuleIdLabel] = ""
// create a threshold rule
rule, err = baserules.NewThresholdRule(
alertname,
opts.OrgID,
parsedRule,
opts.Reader,
opts.Querier,
opts.Logger,
baserules.WithSendAlways(),
@@ -151,7 +158,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
if err != nil {
slog.Error("failed to prepare a new threshold rule for test", "name", alertname, errors.Attr(err))
return 0, err
return 0, basemodel.BadRequest(err)
}
} else if parsedRule.RuleType == ruletypes.RuleTypeProm {
@@ -162,6 +169,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
opts.OrgID,
parsedRule,
opts.Logger,
opts.Reader,
opts.ManagerOpts.Prometheus,
baserules.WithSendAlways(),
baserules.WithSendUnmatched(),
@@ -172,7 +180,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
if err != nil {
slog.Error("failed to prepare a new promql rule for test", "name", alertname, errors.Attr(err))
return 0, err
return 0, basemodel.BadRequest(err)
}
} else if parsedRule.RuleType == ruletypes.RuleTypeAnomaly {
// create anomaly rule
@@ -180,8 +188,10 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
alertname,
opts.OrgID,
parsedRule,
opts.Reader,
opts.Querier,
opts.Logger,
opts.Cache,
baserules.WithSendAlways(),
baserules.WithSendUnmatched(),
baserules.WithSQLStore(opts.SQLStore),
@@ -190,10 +200,10 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
)
if err != nil {
slog.Error("failed to prepare a new anomaly rule for test", "name", alertname, errors.Attr(err))
return 0, err
return 0, basemodel.BadRequest(err)
}
} else {
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to derive ruletype with given information")
return 0, basemodel.BadRequest(fmt.Errorf("failed to derive ruletype with given information"))
}
// set timestamp to current utc time
@@ -202,7 +212,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
alertsFound, err := rule.Eval(ctx, ts)
if err != nil {
slog.Error("evaluating rule failed", "rule", rule.Name(), errors.Attr(err))
return 0, err
return 0, basemodel.InternalError(fmt.Errorf("rule evaluation failed"))
}
rule.SendAlerts(ctx, ts, 0, time.Minute, opts.NotifyFunc)

View File

@@ -114,8 +114,11 @@ func TestManager_TestNotification_SendUnmatched_ThresholdRule(t *testing.T) {
},
})
count, err := mgr.TestNotification(context.Background(), orgID, string(ruleBytes))
require.Nil(t, err)
count, apiErr := mgr.TestNotification(context.Background(), orgID, string(ruleBytes))
if apiErr != nil {
t.Logf("TestNotification error: %v, type: %s", apiErr.Err, apiErr.Typ)
}
require.Nil(t, apiErr)
assert.Equal(t, tc.ExpectAlerts, count)
if tc.ExpectAlerts > 0 {
@@ -265,8 +268,11 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
},
})
count, err := mgr.TestNotification(context.Background(), orgID, string(ruleBytes))
require.Nil(t, err)
count, apiErr := mgr.TestNotification(context.Background(), orgID, string(ruleBytes))
if apiErr != nil {
t.Logf("TestNotification error: %v, type: %s", apiErr.Err, apiErr.Typ)
}
require.Nil(t, apiErr)
assert.Equal(t, tc.ExpectAlerts, count)
if tc.ExpectAlerts > 0 {

View File

@@ -1,77 +0,0 @@
---
description: Global vs local mock strategy for Jest tests
globs:
- "**/*.test.ts"
- "**/*.test.tsx"
- "**/__tests__/**/*.ts"
- "**/__tests__/**/*.tsx"
alwaysApply: false
---
# Mock Decision Strategy
## Global Mocks (20+ test files)
- Core infrastructure: react-router-dom, react-query, antd
- Browser APIs: ResizeObserver, matchMedia, localStorage
- Utility libraries: date-fns, lodash
- Available: `uplot` → `__mocks__/uplotMock.ts`
## Local Mocks (515 test files)
- Business logic dependencies
- API endpoints with specific responses
- Domain-specific components
- Error scenarios and edge cases
## Decision Tree
```
Is it used in 20+ test files?
├─ YES → Use Global Mock
│ ├─ react-router-dom
│ ├─ react-query
│ ├─ antd components
│ └─ browser APIs
└─ NO → Is it business logic?
├─ YES → Use Local Mock
│ ├─ API endpoints
│ ├─ Custom hooks
│ └─ Domain components
└─ NO → Is it test-specific?
├─ YES → Use Local Mock
│ ├─ Error scenarios
│ ├─ Loading states
│ └─ Specific data
└─ NO → Consider Global Mock
└─ If it becomes frequently used
```
## Anti-patterns
❌ Don't mock global dependencies locally:
```ts
jest.mock('react-router-dom', () => ({ ... })); // Already globally mocked
```
❌ Don't create global mocks for test-specific data:
```ts
jest.mock('../api/tracesService', () => ({
getTraces: jest.fn(() => specificTestData) // BAD - should be local
}));
```
✅ Do use global mocks for infrastructure:
```ts
import { useLocation } from 'react-router-dom';
```
✅ Do create local mocks for business logic:
```ts
jest.mock('../api/tracesService', () => ({
getTraces: jest.fn(() => mockTracesData)
}));
```

View File

@@ -1,124 +0,0 @@
---
description: Core Jest/React Testing Library conventions - harness, MSW, interactions, timers
globs:
- "**/*.test.ts"
- "**/*.test.tsx"
- "**/__tests__/**/*.ts"
- "**/__tests__/**/*.tsx"
alwaysApply: false
---
# Jest Test Conventions
Expert developer with Jest, React Testing Library, MSW, and TypeScript. Focus on critical functionality, mock dependencies before imports, test multiple scenarios, write maintainable tests.
**Auto-detect TypeScript**: Check for TypeScript in the project through tsconfig.json or package.json dependencies. Adjust syntax based on this detection.
## Imports
Always import from our harness:
```ts
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
```
For API mocks:
```ts
import { server, rest } from 'mocks-server/server';
```
❌ Do not import directly from `@testing-library/react`.
## Router
Use the router built into render:
```ts
render(<Page />, undefined, { initialRoute: '/traces-explorer' });
```
Only mock `useLocation` / `useParams` if the test depends on them.
## Hook Mocks
```ts
import useFoo from 'hooks/useFoo';
jest.mock('hooks/useFoo');
const mockUseFoo = jest.mocked(useFoo);
mockUseFoo.mockReturnValue(/* minimal shape */ as any);
```
Prefer helpers (`rqSuccess`, `rqLoading`, `rqError`) for React Query results.
## MSW
Global MSW server runs automatically. Override per-test:
```ts
server.use(
rest.get('*/api/v1/foo', (_req, res, ctx) => res(ctx.status(200), ctx.json({ ok: true })))
);
```
Keep large responses in `mocks-server/__mockdata__/`.
## Interactions
- Prefer `userEvent` for real user interactions (click, type, select, tab).
- Use `fireEvent` only for low-level/programmatic events not covered by `userEvent` (e.g., scroll, resize, setting `element.scrollTop` for virtualization). Wrap in `act(...)` if needed.
- Always await interactions:
```ts
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(screen.getByRole('button', { name: /save/i }));
```
```ts
// Example: virtualized list scroll (no userEvent helper)
const scroller = container.querySelector('[data-test-id="virtuoso-scroller"]') as HTMLElement;
scroller.scrollTop = targetScrollTop;
act(() => { fireEvent.scroll(scroller); });
```
## Timers
❌ No global fake timers. ✅ Per-test only:
```ts
jest.useFakeTimers();
const user = userEvent.setup({ advanceTimers: (ms) => jest.advanceTimersByTime(ms) });
await user.type(screen.getByRole('textbox'), 'query');
jest.advanceTimersByTime(400);
jest.useRealTimers();
```
## Queries
Prefer accessible queries (`getByRole`, `findByRole`, `getByLabelText`). Fallback: visible text. Last resort: `data-testid`.
## Best Practices
- **Critical Functionality**: Prioritize testing business logic and utilities
- **Dependency Mocking**: Global mocks for infra, local mocks for business logic
- **Data Scenarios**: Always test valid, invalid, and edge cases
- **Descriptive Names**: Make test intent clear
- **Organization**: Group related tests in describe
- **Consistency**: Match repo conventions
- **Edge Cases**: Test null, undefined, unexpected values
- **Limit Scope**: 35 focused tests per file
- **Use Helpers**: `rqSuccess`, `makeUser`, etc.
- **No Any**: Enforce type safety
## Anti-patterns
❌ Importing RTL directly | ❌ Global fake timers | ❌ Wrapping render in `act(...)` | ❌ Mocking infra locally
✅ Use harness | ✅ MSW for API | ✅ userEvent + await | ✅ Pin time only for relative-date tests
## Example
```ts
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { server, rest } from 'mocks-server/server';
import MyComponent from '../MyComponent';
describe('MyComponent', () => {
it('renders and interacts', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(rest.get('*/api/v1/example', (_req, res, ctx) => res(ctx.status(200), ctx.json({ value: 42 }))));
render(<MyComponent />, undefined, { initialRoute: '/foo' });
expect(await screen.findByText(/value: 42/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /refresh/i }));
await waitFor(() => expect(screen.getByText(/loading/i)).toBeInTheDocument());
});
});
```

View File

@@ -1,168 +0,0 @@
---
description: TypeScript type safety for Jest tests - mocks, interfaces, no any
globs:
- "**/*.test.ts"
- "**/*.test.tsx"
- "**/__tests__/**/*.ts"
- "**/__tests__/**/*.tsx"
alwaysApply: false
---
# TypeScript Type Safety for Jest Tests
**CRITICAL**: All Jest tests MUST be fully type-safe.
## Requirements
- Use proper TypeScript interfaces for all mock data
- Type all Jest mock functions with `jest.MockedFunction<T>`
- Use generic types for React components and hooks
- Define proper return types for mock functions
- Use `as const` for literal types when needed
- Avoid `any` type use proper typing instead
## Mock Function Typing
```ts
// ✅ GOOD
const mockFetchUser = jest.fn() as jest.MockedFunction<(id: number) => Promise<ApiResponse<User>>>;
const mockEventHandler = jest.fn() as jest.MockedFunction<(event: Event) => void>;
// ❌ BAD
const mockFetchUser = jest.fn() as any;
```
## Mock Data with Interfaces
```ts
interface User { id: number; name: string; email: string; }
interface ApiResponse<T> { data: T; status: number; message: string; }
const mockUser: User = { id: 1, name: 'John Doe', email: 'john@example.com' };
mockFetchUser.mockResolvedValue({ data: mockUser, status: 200, message: 'Success' });
```
## Component Props Typing
```ts
interface ComponentProps { title: string; data: User[]; onUserSelect: (user: User) => void; }
const mockProps: ComponentProps = {
title: 'Test',
data: [{ id: 1, name: 'John', email: 'john@example.com' }],
onUserSelect: jest.fn() as jest.MockedFunction<(user: User) => void>,
};
render(<TestComponent {...mockProps} />);
```
## Hook Testing with Types
```ts
interface UseUserDataReturn {
user: User | null;
loading: boolean;
error: string | null;
refetch: () => void;
}
describe('useUserData', () => {
it('should return user data with proper typing', () => {
const mockUser: User = { id: 1, name: 'John', email: 'john@example.com' };
mockFetchUser.mockResolvedValue({ data: mockUser, status: 200, message: 'Success' });
const { result } = renderHook(() => useUserData(1));
expect(result.current.user).toEqual(mockUser);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
});
});
```
## Generic Mock Typing
```ts
interface MockApiResponse<T> { data: T; status: number; }
const mockFetchData = jest.fn() as jest.MockedFunction<
<T>(endpoint: string) => Promise<MockApiResponse<T>>
>;
mockFetchData<User>('/users').mockResolvedValue({ data: { id: 1, name: 'John' }, status: 200 });
```
## React Testing Library with Types
```ts
type TestComponentProps = ComponentProps<typeof TestComponent>;
const renderTestComponent = (props: Partial<TestComponentProps> = {}): RenderResult => {
const defaultProps: TestComponentProps = { title: 'Test', data: [], onSelect: jest.fn(), ...props };
return render(<TestComponent {...defaultProps} />);
};
```
## Error Handling with Types
```ts
interface ApiError { message: string; code: number; details?: Record<string, unknown>; }
const mockApiError: ApiError = { message: 'API Error', code: 500, details: { endpoint: '/users' } };
mockFetchUser.mockRejectedValue(new Error(JSON.stringify(mockApiError)));
```
## Global Mock Type Safety
```ts
// In __mocks__/routerMock.ts
export const mockUseLocation = (overrides: Partial<Location> = {}): Location => ({
pathname: '/traces',
search: '',
hash: '',
state: null,
key: 'test-key',
...overrides,
});
// In test files: const location = useLocation(); // Properly typed from global mock
```
## TypeScript Configuration for Jest
```json
// jest.config.ts
{
"preset": "ts-jest/presets/js-with-ts-esm",
"globals": {
"ts-jest": {
"useESM": true,
"isolatedModules": true,
"tsconfig": "<rootDir>/tsconfig.jest.json"
}
},
"extensionsToTreatAsEsm": [".ts", ".tsx"],
"moduleFileExtensions": ["ts", "tsx", "js", "json"]
}
```
```json
// tsconfig.jest.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["jest", "@testing-library/jest-dom"],
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"moduleResolution": "node"
},
"include": ["src/**/*", "**/*.test.ts", "**/*.test.tsx", "__mocks__/**/*"]
}
```
## Type Safety Checklist
- [ ] All mock functions use `jest.MockedFunction<T>`
- [ ] All mock data has proper interfaces
- [ ] No `any` types in test files
- [ ] Generic types are used where appropriate
- [ ] Error types are properly defined
- [ ] Component props are typed
- [ ] Hook return types are defined
- [ ] API response types are defined
- [ ] Global mocks are type-safe
- [ ] Test utilities are properly typed

View File

@@ -65,14 +65,12 @@
"@signozhq/sonner": "0.1.0",
"@signozhq/switch": "0.0.2",
"@signozhq/table": "0.3.7",
"@signozhq/tabs": "0.0.11",
"@signozhq/toggle-group": "0.0.1",
"@signozhq/tooltip": "0.0.2",
"@signozhq/ui": "0.0.5",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.22",
"@tanstack/react-table": "8.20.6",
"@tanstack/react-virtual": "3.11.2",
"@uiw/codemirror-theme-copilot": "4.23.11",
"@uiw/codemirror-theme-dracula": "4.25.9",
"@uiw/codemirror-theme-github": "4.24.1",
"@uiw/react-codemirror": "4.23.10",
"@uiw/react-md-editor": "3.23.5",

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#1eb4d4" viewBox="0 0 24 24"><title>Hasura</title><path d="M23.558 8.172c.707-2.152.282-6.447-1.09-8.032a.42.42 0 0 0-.664.051l-1.69 2.59a1.32 1.32 0 0 1-1.737.276C16.544 1.885 14.354 1.204 12 1.204s-4.544.68-6.378 1.853a1.326 1.326 0 0 1-1.736-.276L2.196.191A.42.42 0 0 0 1.532.14C.16 1.728-.265 6.023.442 8.172c.236.716.3 1.472.16 2.207-.137.73-.276 1.61-.276 2.223C.326 18.898 5.553 24 11.997 24c6.447 0 11.671-5.105 11.671-11.398 0-.613-.138-1.494-.276-2.223a4.47 4.47 0 0 1 .166-2.207m-11.56 13.284c-4.984 0-9.036-3.96-9.036-8.827q0-.239.014-.473c.18-3.316 2.243-6.15 5.16-7.5 1.17-.546 2.481-.848 3.864-.848s2.69.302 3.864.85c2.917 1.351 4.98 4.187 5.16 7.501q.013.236.014.473c-.003 4.864-4.057 8.824-9.04 8.824m3.915-5.43-2.31-3.91-1.98-3.26a.26.26 0 0 0-.223-.125H9.508a.26.26 0 0 0-.227.13.25.25 0 0 0 .003.254l1.895 3.109-2.542 3.787a.25.25 0 0 0-.011.259.26.26 0 0 0 .23.132h1.905a.26.26 0 0 0 .218-.116l1.375-2.096 1.233 2.088a.26.26 0 0 0 .224.127h1.878c.094 0 .18-.049.224-.127a.24.24 0 0 0 0-.251z"/></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#ea4b71" viewBox="0 0 24 24"><title>n8n</title><path d="M21.474 5.684a2.53 2.53 0 0 0-2.447 1.895H16.13a2.526 2.526 0 0 0-2.492 2.11l-.103.624a1.26 1.26 0 0 1-1.246 1.055h-1.001a2.527 2.527 0 0 0-4.893 0H4.973a2.527 2.527 0 1 0 0 1.264h1.422a2.527 2.527 0 0 0 4.894 0h1a1.26 1.26 0 0 1 1.247 1.055l.103.623a2.526 2.526 0 0 0 2.492 2.111h.37a2.527 2.527 0 1 0 0-1.263h-.37a1.26 1.26 0 0 1-1.246-1.056l-.103-.623A2.52 2.52 0 0 0 13.96 12a2.52 2.52 0 0 0 .82-1.48l.104-.622a1.26 1.26 0 0 1 1.246-1.056h2.896a2.527 2.527 0 1 0 2.447-3.158m0 1.263a1.263 1.263 0 0 1 1.263 1.263 1.263 1.263 0 0 1-1.263 1.264A1.263 1.263 0 0 1 20.21 8.21a1.263 1.263 0 0 1 1.264-1.263m-18.948 3.79A1.263 1.263 0 0 1 3.79 12a1.263 1.263 0 0 1-1.264 1.263A1.263 1.263 0 0 1 1.263 12a1.263 1.263 0 0 1 1.263-1.263m6.316 0A1.263 1.263 0 0 1 10.105 12a1.263 1.263 0 0 1-1.263 1.263A1.263 1.263 0 0 1 7.58 12a1.263 1.263 0 0 1 1.263-1.263m10.106 3.79a1.263 1.263 0 0 1 1.263 1.263 1.263 1.263 0 0 1-1.263 1.263 1.263 1.263 0 0 1-1.264-1.263 1.263 1.263 0 0 1 1.263-1.264"/></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.8 KiB

View File

@@ -1,312 +0,0 @@
<svg width="929" height="8" viewBox="0 0 929 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="2" height="2" rx="1" fill="#242834"/>
<rect x="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="12" width="2" height="2" rx="1" fill="#242834"/>
<rect x="18" width="2" height="2" rx="1" fill="#242834"/>
<rect x="24" width="2" height="2" rx="1" fill="#242834"/>
<rect x="30" width="2" height="2" rx="1" fill="#242834"/>
<rect x="36" width="2" height="2" rx="1" fill="#242834"/>
<rect x="42" width="2" height="2" rx="1" fill="#242834"/>
<rect x="48" width="2" height="2" rx="1" fill="#242834"/>
<rect x="54" width="2" height="2" rx="1" fill="#242834"/>
<rect x="60" width="2" height="2" rx="1" fill="#242834"/>
<rect x="66" width="2" height="2" rx="1" fill="#242834"/>
<rect x="72" width="2" height="2" rx="1" fill="#242834"/>
<rect x="78" width="2" height="2" rx="1" fill="#242834"/>
<rect x="84" width="2" height="2" rx="1" fill="#242834"/>
<rect x="90" width="2" height="2" rx="1" fill="#242834"/>
<rect x="96" width="2" height="2" rx="1" fill="#242834"/>
<rect x="102" width="2" height="2" rx="1" fill="#242834"/>
<rect x="108" width="2" height="2" rx="1" fill="#242834"/>
<rect x="114" width="2" height="2" rx="1" fill="#242834"/>
<rect x="120" width="2" height="2" rx="1" fill="#242834"/>
<rect x="126" width="2" height="2" rx="1" fill="#242834"/>
<rect x="132" width="2" height="2" rx="1" fill="#242834"/>
<rect x="138" width="2" height="2" rx="1" fill="#242834"/>
<rect x="144" width="2" height="2" rx="1" fill="#242834"/>
<rect x="150" width="2" height="2" rx="1" fill="#242834"/>
<rect x="156" width="2" height="2" rx="1" fill="#242834"/>
<rect x="162" width="2" height="2" rx="1" fill="#242834"/>
<rect x="168" width="2" height="2" rx="1" fill="#242834"/>
<rect x="174" width="2" height="2" rx="1" fill="#242834"/>
<rect x="180" width="2" height="2" rx="1" fill="#242834"/>
<rect x="186" width="2" height="2" rx="1" fill="#242834"/>
<rect x="192" width="2" height="2" rx="1" fill="#242834"/>
<rect x="198" width="2" height="2" rx="1" fill="#242834"/>
<rect x="204" width="2" height="2" rx="1" fill="#242834"/>
<rect x="210" width="2" height="2" rx="1" fill="#242834"/>
<rect x="216" width="2" height="2" rx="1" fill="#242834"/>
<rect x="222" width="2" height="2" rx="1" fill="#242834"/>
<rect x="228" width="2" height="2" rx="1" fill="#242834"/>
<rect x="234" width="2" height="2" rx="1" fill="#242834"/>
<rect x="240" width="2" height="2" rx="1" fill="#242834"/>
<rect x="246" width="2" height="2" rx="1" fill="#242834"/>
<rect x="252" width="2" height="2" rx="1" fill="#242834"/>
<rect x="258" width="2" height="2" rx="1" fill="#242834"/>
<rect x="264" width="2" height="2" rx="1" fill="#242834"/>
<rect x="270" width="2" height="2" rx="1" fill="#242834"/>
<rect x="276" width="2" height="2" rx="1" fill="#242834"/>
<rect x="282" width="2" height="2" rx="1" fill="#242834"/>
<rect x="288" width="2" height="2" rx="1" fill="#242834"/>
<rect x="294" width="2" height="2" rx="1" fill="#242834"/>
<rect x="300" width="2" height="2" rx="1" fill="#242834"/>
<rect x="306" width="2" height="2" rx="1" fill="#242834"/>
<rect x="312" width="2" height="2" rx="1" fill="#242834"/>
<rect x="318" width="2" height="2" rx="1" fill="#242834"/>
<rect x="324" width="2" height="2" rx="1" fill="#242834"/>
<rect x="330" width="2" height="2" rx="1" fill="#242834"/>
<rect x="336" width="2" height="2" rx="1" fill="#242834"/>
<rect x="342" width="2" height="2" rx="1" fill="#242834"/>
<rect x="348" width="2" height="2" rx="1" fill="#242834"/>
<rect x="354" width="2" height="2" rx="1" fill="#242834"/>
<rect x="360" width="2" height="2" rx="1" fill="#242834"/>
<rect x="366" width="2" height="2" rx="1" fill="#242834"/>
<rect x="372" width="2" height="2" rx="1" fill="#242834"/>
<rect x="378" width="2" height="2" rx="1" fill="#242834"/>
<rect x="384" width="2" height="2" rx="1" fill="#242834"/>
<rect x="390" width="2" height="2" rx="1" fill="#242834"/>
<rect x="396" width="2" height="2" rx="1" fill="#242834"/>
<rect x="402" width="2" height="2" rx="1" fill="#242834"/>
<rect x="408" width="2" height="2" rx="1" fill="#242834"/>
<rect x="414" width="2" height="2" rx="1" fill="#242834"/>
<rect x="420" width="2" height="2" rx="1" fill="#242834"/>
<rect x="426" width="2" height="2" rx="1" fill="#242834"/>
<rect x="432" width="2" height="2" rx="1" fill="#242834"/>
<rect x="438" width="2" height="2" rx="1" fill="#242834"/>
<rect x="444" width="2" height="2" rx="1" fill="#242834"/>
<rect x="450" width="2" height="2" rx="1" fill="#242834"/>
<rect x="456" width="2" height="2" rx="1" fill="#242834"/>
<rect x="462" width="2" height="2" rx="1" fill="#242834"/>
<rect x="468" width="2" height="2" rx="1" fill="#242834"/>
<rect x="474" width="2" height="2" rx="1" fill="#242834"/>
<rect x="480" width="2" height="2" rx="1" fill="#242834"/>
<rect x="486" width="2" height="2" rx="1" fill="#242834"/>
<rect x="492" width="2" height="2" rx="1" fill="#242834"/>
<rect x="498" width="2" height="2" rx="1" fill="#242834"/>
<rect x="504" width="2" height="2" rx="1" fill="#242834"/>
<rect x="510" width="2" height="2" rx="1" fill="#242834"/>
<rect x="516" width="2" height="2" rx="1" fill="#242834"/>
<rect x="522" width="2" height="2" rx="1" fill="#242834"/>
<rect x="528" width="2" height="2" rx="1" fill="#242834"/>
<rect x="534" width="2" height="2" rx="1" fill="#242834"/>
<rect x="540" width="2" height="2" rx="1" fill="#242834"/>
<rect x="546" width="2" height="2" rx="1" fill="#242834"/>
<rect x="552" width="2" height="2" rx="1" fill="#242834"/>
<rect x="558" width="2" height="2" rx="1" fill="#242834"/>
<rect x="564" width="2" height="2" rx="1" fill="#242834"/>
<rect x="570" width="2" height="2" rx="1" fill="#242834"/>
<rect x="576" width="2" height="2" rx="1" fill="#242834"/>
<rect x="582" width="2" height="2" rx="1" fill="#242834"/>
<rect x="588" width="2" height="2" rx="1" fill="#242834"/>
<rect x="594" width="2" height="2" rx="1" fill="#242834"/>
<rect x="600" width="2" height="2" rx="1" fill="#242834"/>
<rect x="606" width="2" height="2" rx="1" fill="#242834"/>
<rect x="612" width="2" height="2" rx="1" fill="#242834"/>
<rect x="618" width="2" height="2" rx="1" fill="#242834"/>
<rect x="624" width="2" height="2" rx="1" fill="#242834"/>
<rect x="630" width="2" height="2" rx="1" fill="#242834"/>
<rect x="636" width="2" height="2" rx="1" fill="#242834"/>
<rect x="642" width="2" height="2" rx="1" fill="#242834"/>
<rect x="648" width="2" height="2" rx="1" fill="#242834"/>
<rect x="654" width="2" height="2" rx="1" fill="#242834"/>
<rect x="660" width="2" height="2" rx="1" fill="#242834"/>
<rect x="666" width="2" height="2" rx="1" fill="#242834"/>
<rect x="672" width="2" height="2" rx="1" fill="#242834"/>
<rect x="678" width="2" height="2" rx="1" fill="#242834"/>
<rect x="684" width="2" height="2" rx="1" fill="#242834"/>
<rect x="690" width="2" height="2" rx="1" fill="#242834"/>
<rect x="696" width="2" height="2" rx="1" fill="#242834"/>
<rect x="702" width="2" height="2" rx="1" fill="#242834"/>
<rect x="708" width="2" height="2" rx="1" fill="#242834"/>
<rect x="714" width="2" height="2" rx="1" fill="#242834"/>
<rect x="720" width="2" height="2" rx="1" fill="#242834"/>
<rect x="726" width="2" height="2" rx="1" fill="#242834"/>
<rect x="732" width="2" height="2" rx="1" fill="#242834"/>
<rect x="738" width="2" height="2" rx="1" fill="#242834"/>
<rect x="744" width="2" height="2" rx="1" fill="#242834"/>
<rect x="750" width="2" height="2" rx="1" fill="#242834"/>
<rect x="756" width="2" height="2" rx="1" fill="#242834"/>
<rect x="762" width="2" height="2" rx="1" fill="#242834"/>
<rect x="768" width="2" height="2" rx="1" fill="#242834"/>
<rect x="774" width="2" height="2" rx="1" fill="#242834"/>
<rect x="780" width="2" height="2" rx="1" fill="#242834"/>
<rect x="786" width="2" height="2" rx="1" fill="#242834"/>
<rect x="792" width="2" height="2" rx="1" fill="#242834"/>
<rect x="798" width="2" height="2" rx="1" fill="#242834"/>
<rect x="804" width="2" height="2" rx="1" fill="#242834"/>
<rect x="810" width="2" height="2" rx="1" fill="#242834"/>
<rect x="816" width="2" height="2" rx="1" fill="#242834"/>
<rect x="822" width="2" height="2" rx="1" fill="#242834"/>
<rect x="828" width="2" height="2" rx="1" fill="#242834"/>
<rect x="834" width="2" height="2" rx="1" fill="#242834"/>
<rect x="840" width="2" height="2" rx="1" fill="#242834"/>
<rect x="846" width="2" height="2" rx="1" fill="#242834"/>
<rect x="852" width="2" height="2" rx="1" fill="#242834"/>
<rect x="858" width="2" height="2" rx="1" fill="#242834"/>
<rect x="864" width="2" height="2" rx="1" fill="#242834"/>
<rect x="870" width="2" height="2" rx="1" fill="#242834"/>
<rect x="876" width="2" height="2" rx="1" fill="#242834"/>
<rect x="882" width="2" height="2" rx="1" fill="#242834"/>
<rect x="888" width="2" height="2" rx="1" fill="#242834"/>
<rect x="894" width="2" height="2" rx="1" fill="#242834"/>
<rect x="900" width="2" height="2" rx="1" fill="#242834"/>
<rect x="906" width="2" height="2" rx="1" fill="#242834"/>
<rect x="912" width="2" height="2" rx="1" fill="#242834"/>
<rect x="918" width="2" height="2" rx="1" fill="#242834"/>
<rect x="924" width="2" height="2" rx="1" fill="#242834"/>
<rect y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="6" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="12" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="18" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="24" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="30" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="36" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="42" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="48" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="54" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="60" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="66" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="72" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="78" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="84" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="90" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="96" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="102" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="108" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="114" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="120" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="126" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="132" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="138" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="144" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="150" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="156" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="162" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="168" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="174" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="180" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="186" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="192" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="198" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="204" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="210" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="216" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="222" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="228" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="234" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="240" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="246" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="252" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="258" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="264" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="270" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="276" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="282" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="288" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="294" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="300" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="306" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="312" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="318" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="324" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="330" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="336" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="342" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="348" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="354" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="360" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="366" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="372" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="378" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="384" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="390" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="396" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="402" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="408" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="414" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="420" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="426" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="432" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="438" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="444" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="450" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="456" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="462" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="468" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="474" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="480" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="486" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="492" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="498" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="504" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="510" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="516" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="522" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="528" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="534" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="540" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="546" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="552" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="558" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="564" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="570" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="576" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="582" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="588" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="594" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="600" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="606" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="612" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="618" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="624" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="630" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="636" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="642" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="648" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="654" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="660" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="666" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="672" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="678" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="684" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="690" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="696" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="702" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="708" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="714" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="720" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="726" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="732" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="738" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="744" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="750" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="756" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="762" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="768" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="774" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="780" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="786" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="792" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="798" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="804" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="810" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="816" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="822" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="828" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="834" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="840" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="846" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="852" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="858" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="864" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="870" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="876" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="882" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="888" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="894" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="900" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="906" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="912" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="918" y="6" width="2" height="2" rx="1" fill="#242834"/>
<rect x="924" y="6" width="2" height="2" rx="1" fill="#242834"/>
</svg>

Before

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -1,4 +1,4 @@
import { ReactChild, useCallback, useMemo } from 'react';
import { ReactChild, useCallback, useEffect, useMemo, useState } from 'react';
import { matchPath, Redirect, useLocation } from 'react-router-dom';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
@@ -8,10 +8,12 @@ import { LOCALSTORAGE } from 'constants/localStorage';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import ROUTES from 'constants/routes';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import history from 'lib/history';
import { isEmpty } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
import { OrgPreference } from 'types/api/preferences/preference';
import { Organization } from 'types/api/user/getOrganization';
import { USER_ROLES } from 'types/roles';
import { routePermission } from 'utils/permission';
@@ -23,7 +25,6 @@ import routes, {
SUPPORT_ROUTE,
} from './routes';
// eslint-disable-next-line sonarjs/cognitive-complexity
function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
const location = useLocation();
const { pathname } = location;
@@ -56,12 +57,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
const currentRoute = mapRoutes.get('current');
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
const orgData = useMemo(() => {
if (org && org.length > 0 && org[0].id !== undefined) {
return org[0];
}
return undefined;
}, [org]);
const [orgData, setOrgData] = useState<Organization | undefined>(undefined);
const { data: usersData, isFetching: isFetchingUsers } = useListUsers({
query: {
@@ -79,7 +75,214 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
return remainingUsers.length === 1;
}, [usersData?.data]);
// Handle old routes - redirect to new routes
useEffect(() => {
if (
isCloudUserVal &&
!isFetchingOrgPreferences &&
orgPreferences &&
!isFetchingUsers &&
usersData &&
usersData.data
) {
const isOnboardingComplete = orgPreferences?.find(
(preference: OrgPreference) =>
preference.name === ORG_PREFERENCES.ORG_ONBOARDING,
)?.value;
// Don't redirect to onboarding if workspace has issues (blocked, suspended, or restricted)
// User needs access to settings/billing to fix payment issues
const isWorkspaceBlocked = trialInfo?.workSpaceBlock;
const isWorkspaceSuspended = activeLicense?.state === LicenseState.DEFAULTED;
const isWorkspaceAccessRestricted =
activeLicense?.state === LicenseState.TERMINATED ||
activeLicense?.state === LicenseState.EXPIRED ||
activeLicense?.state === LicenseState.CANCELLED;
const hasWorkspaceIssue =
isWorkspaceBlocked || isWorkspaceSuspended || isWorkspaceAccessRestricted;
if (hasWorkspaceIssue) {
return;
}
const isFirstUser = checkFirstTimeUser();
if (
isFirstUser &&
!isOnboardingComplete &&
// if the current route is allowed to be overriden by org onboarding then only do the same
!ROUTES_NOT_TO_BE_OVERRIDEN.includes(pathname)
) {
history.push(ROUTES.ONBOARDING);
}
}
}, [
checkFirstTimeUser,
isCloudUserVal,
isFetchingOrgPreferences,
isFetchingUsers,
orgPreferences,
usersData,
pathname,
trialInfo?.workSpaceBlock,
activeLicense?.state,
]);
const navigateToWorkSpaceBlocked = useCallback((): void => {
const isRouteEnabledForWorkspaceBlockedState =
isAdmin &&
(pathname === ROUTES.SETTINGS ||
pathname === ROUTES.ORG_SETTINGS ||
pathname === ROUTES.MEMBERS_SETTINGS ||
pathname === ROUTES.BILLING ||
pathname === ROUTES.MY_SETTINGS);
if (
pathname &&
pathname !== ROUTES.WORKSPACE_LOCKED &&
!isRouteEnabledForWorkspaceBlockedState
) {
history.push(ROUTES.WORKSPACE_LOCKED);
}
}, [isAdmin, pathname]);
const navigateToWorkSpaceAccessRestricted = useCallback((): void => {
if (pathname && pathname !== ROUTES.WORKSPACE_ACCESS_RESTRICTED) {
history.push(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
}
}, [pathname]);
useEffect(() => {
if (!isFetchingActiveLicense && activeLicense) {
const isTerminated = activeLicense.state === LicenseState.TERMINATED;
const isExpired = activeLicense.state === LicenseState.EXPIRED;
const isCancelled = activeLicense.state === LicenseState.CANCELLED;
const isWorkspaceAccessRestricted = isTerminated || isExpired || isCancelled;
const { platform } = activeLicense;
if (isWorkspaceAccessRestricted && platform === LicensePlatform.CLOUD) {
navigateToWorkSpaceAccessRestricted();
}
}
}, [
isFetchingActiveLicense,
activeLicense,
navigateToWorkSpaceAccessRestricted,
]);
useEffect(() => {
if (!isFetchingActiveLicense) {
const shouldBlockWorkspace = trialInfo?.workSpaceBlock;
if (
shouldBlockWorkspace &&
activeLicense?.platform === LicensePlatform.CLOUD
) {
navigateToWorkSpaceBlocked();
}
}
}, [
isFetchingActiveLicense,
trialInfo?.workSpaceBlock,
activeLicense?.platform,
navigateToWorkSpaceBlocked,
]);
const navigateToWorkSpaceSuspended = useCallback((): void => {
if (pathname && pathname !== ROUTES.WORKSPACE_SUSPENDED) {
history.push(ROUTES.WORKSPACE_SUSPENDED);
}
}, [pathname]);
useEffect(() => {
if (!isFetchingActiveLicense && activeLicense) {
const shouldSuspendWorkspace =
activeLicense.state === LicenseState.DEFAULTED;
if (
shouldSuspendWorkspace &&
activeLicense.platform === LicensePlatform.CLOUD
) {
navigateToWorkSpaceSuspended();
}
}
}, [isFetchingActiveLicense, activeLicense, navigateToWorkSpaceSuspended]);
useEffect(() => {
if (org && org.length > 0 && org[0].id !== undefined) {
setOrgData(org[0]);
}
}, [org]);
// if the feature flag is enabled and the current route is /get-started then redirect to /get-started-with-signoz-cloud
useEffect(() => {
if (
currentRoute?.path === ROUTES.GET_STARTED &&
featureFlags?.find((e) => e.name === FeatureKeys.ONBOARDING_V3)?.active
) {
history.push(ROUTES.GET_STARTED_WITH_CLOUD);
}
}, [currentRoute, featureFlags]);
// eslint-disable-next-line sonarjs/cognitive-complexity
useEffect(() => {
// if it is an old route navigate to the new route
if (isOldRoute) {
// this will be handled by the redirect component below
return;
}
// if the current route is public dashboard then don't redirect to login
const isPublicDashboard = currentRoute?.path === ROUTES.PUBLIC_DASHBOARD;
if (isPublicDashboard) {
return;
}
// if the current route
if (currentRoute) {
const { isPrivate, key } = currentRoute;
if (isPrivate) {
if (isLoggedInState) {
const route = routePermission[key];
if (route && route.find((e) => e === user.role) === undefined) {
history.push(ROUTES.UN_AUTHORIZED);
}
} else {
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, pathname);
history.push(ROUTES.LOGIN);
}
} else if (isLoggedInState) {
const fromPathname = getLocalStorageApi(
LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT,
);
if (fromPathname) {
history.push(fromPathname);
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, '');
} else if (pathname !== ROUTES.SOMETHING_WENT_WRONG) {
history.push(ROUTES.HOME);
}
} else {
// do nothing as the unauthenticated routes are LOGIN and SIGNUP and the LOGIN container takes care of routing to signup if
// setup is not completed
}
} else if (isLoggedInState) {
const fromPathname = getLocalStorageApi(
LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT,
);
if (fromPathname) {
history.push(fromPathname);
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, '');
} else {
history.push(ROUTES.HOME);
}
} else {
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, pathname);
history.push(ROUTES.LOGIN);
}
}, [isLoggedInState, pathname, user, isOldRoute, currentRoute, location]);
if (isOldRoute) {
const redirectUrl = oldNewRoutesMapping[pathname];
return (
@@ -93,143 +296,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
);
}
// Public dashboard - no redirect needed
const isPublicDashboard = currentRoute?.path === ROUTES.PUBLIC_DASHBOARD;
if (isPublicDashboard) {
return <>{children}</>;
}
// Check for workspace access restriction (cloud only)
const isCloudPlatform = activeLicense?.platform === LicensePlatform.CLOUD;
if (!isFetchingActiveLicense && activeLicense && isCloudPlatform) {
const isTerminated = activeLicense.state === LicenseState.TERMINATED;
const isExpired = activeLicense.state === LicenseState.EXPIRED;
const isCancelled = activeLicense.state === LicenseState.CANCELLED;
const isWorkspaceAccessRestricted = isTerminated || isExpired || isCancelled;
if (
isWorkspaceAccessRestricted &&
pathname !== ROUTES.WORKSPACE_ACCESS_RESTRICTED
) {
return <Redirect to={ROUTES.WORKSPACE_ACCESS_RESTRICTED} />;
}
// Check for workspace suspended (DEFAULTED)
const shouldSuspendWorkspace = activeLicense.state === LicenseState.DEFAULTED;
if (shouldSuspendWorkspace && pathname !== ROUTES.WORKSPACE_SUSPENDED) {
return <Redirect to={ROUTES.WORKSPACE_SUSPENDED} />;
}
}
// Check for workspace blocked (trial expired)
if (!isFetchingActiveLicense && isCloudPlatform && trialInfo?.workSpaceBlock) {
const isRouteEnabledForWorkspaceBlockedState =
isAdmin &&
(pathname === ROUTES.SETTINGS ||
pathname === ROUTES.ORG_SETTINGS ||
pathname === ROUTES.MEMBERS_SETTINGS ||
pathname === ROUTES.BILLING ||
pathname === ROUTES.MY_SETTINGS);
if (
pathname !== ROUTES.WORKSPACE_LOCKED &&
!isRouteEnabledForWorkspaceBlockedState
) {
return <Redirect to={ROUTES.WORKSPACE_LOCKED} />;
}
}
// Check for onboarding redirect (cloud users, first user, onboarding not complete)
if (
isCloudUserVal &&
!isFetchingOrgPreferences &&
orgPreferences &&
!isFetchingUsers &&
usersData &&
usersData.data
) {
const isOnboardingComplete = orgPreferences?.find(
(preference: OrgPreference) =>
preference.name === ORG_PREFERENCES.ORG_ONBOARDING,
)?.value;
// Don't redirect to onboarding if workspace has issues
const isWorkspaceBlocked = trialInfo?.workSpaceBlock;
const isWorkspaceSuspended = activeLicense?.state === LicenseState.DEFAULTED;
const isWorkspaceAccessRestricted =
activeLicense?.state === LicenseState.TERMINATED ||
activeLicense?.state === LicenseState.EXPIRED ||
activeLicense?.state === LicenseState.CANCELLED;
const hasWorkspaceIssue =
isWorkspaceBlocked || isWorkspaceSuspended || isWorkspaceAccessRestricted;
if (!hasWorkspaceIssue) {
const isFirstUser = checkFirstTimeUser();
if (
isFirstUser &&
!isOnboardingComplete &&
!ROUTES_NOT_TO_BE_OVERRIDEN.includes(pathname) &&
pathname !== ROUTES.ONBOARDING
) {
return <Redirect to={ROUTES.ONBOARDING} />;
}
}
}
// Check for GET_STARTED → GET_STARTED_WITH_CLOUD redirect (feature flag)
if (
currentRoute?.path === ROUTES.GET_STARTED &&
featureFlags?.find((e) => e.name === FeatureKeys.ONBOARDING_V3)?.active
) {
return <Redirect to={ROUTES.GET_STARTED_WITH_CLOUD} />;
}
// Main routing logic
if (currentRoute) {
const { isPrivate, key } = currentRoute;
if (isPrivate) {
if (isLoggedInState) {
const route = routePermission[key];
if (route && route.find((e) => e === user.role) === undefined) {
return <Redirect to={ROUTES.UN_AUTHORIZED} />;
}
} else {
// Save current path and redirect to login
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, pathname);
return <Redirect to={ROUTES.LOGIN} />;
}
} else if (isLoggedInState) {
// Non-private route, but user is logged in
const fromPathname = getLocalStorageApi(
LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT,
);
if (fromPathname) {
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, '');
return <Redirect to={fromPathname} />;
}
if (pathname !== ROUTES.SOMETHING_WENT_WRONG) {
return <Redirect to={ROUTES.HOME} />;
}
}
// Non-private route, user not logged in - let login/signup pages handle it
} else if (isLoggedInState) {
// Unknown route, logged in
const fromPathname = getLocalStorageApi(
LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT,
);
if (fromPathname) {
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, '');
return <Redirect to={fromPathname} />;
}
return <Redirect to={ROUTES.HOME} />;
} else {
// Unknown route, not logged in
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, pathname);
return <Redirect to={ROUTES.LOGIN} />;
}
// NOTE: disabling this rule as there is no need to have div
return <>{children}</>;
}

View File

@@ -6,6 +6,7 @@ import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { AppContext } from 'providers/App/App';
import { IAppContext, IUser } from 'providers/App/types';
import {
@@ -21,6 +22,19 @@ import { ROLES, USER_ROLES } from 'types/roles';
import PrivateRoute from '../Private';
// Mock history module
jest.mock('lib/history', () => ({
__esModule: true,
default: {
push: jest.fn(),
location: { pathname: '/', search: '', hash: '' },
listen: jest.fn(),
createHref: jest.fn(),
},
}));
const mockHistoryPush = history.push as jest.Mock;
// Mock localStorage APIs
const mockLocalStorage: Record<string, string> = {};
jest.mock('api/browser/localstorage/get', () => ({
@@ -53,12 +67,9 @@ jest.mock('hooks/useGetTenantLicense', () => ({
// Mock react-query for users fetch
let mockUsersData: { email: string }[] = [];
jest.mock('api/generated/services/users', () => ({
...jest.requireActual('api/generated/services/users'),
useListUsers: jest.fn(() => ({
data: { data: mockUsersData },
isFetching: false,
})),
jest.mock('api/v1/user/get', () => ({
__esModule: true,
default: jest.fn(() => Promise.resolve({ data: mockUsersData })),
}));
const queryClient = new QueryClient({
@@ -225,18 +236,20 @@ function renderPrivateRoute(options: RenderPrivateRouteOptions = {}): void {
}
// Generic assertion helpers for navigation behavior
// Using location-based assertions since Private.tsx now uses Redirect component
// Using these allows easier refactoring when switching from history.push to Redirect component
async function assertRedirectsTo(targetRoute: string): Promise<void> {
await waitFor(() => {
expect(screen.getByTestId('location-display')).toHaveTextContent(targetRoute);
expect(mockHistoryPush).toHaveBeenCalledWith(targetRoute);
});
}
function assertStaysOnRoute(expectedRoute: string): void {
expect(screen.getByTestId('location-display')).toHaveTextContent(
expectedRoute,
);
function assertNoRedirect(): void {
expect(mockHistoryPush).not.toHaveBeenCalled();
}
function assertDoesNotRedirectTo(targetRoute: string): void {
expect(mockHistoryPush).not.toHaveBeenCalledWith(targetRoute);
}
function assertRendersChildren(): void {
@@ -334,7 +347,7 @@ describe('PrivateRoute', () => {
});
assertRendersChildren();
assertStaysOnRoute('/public/dashboard/abc123');
assertNoRedirect();
});
it('should render children for public dashboard route when logged in without redirecting', () => {
@@ -346,7 +359,7 @@ describe('PrivateRoute', () => {
assertRendersChildren();
// Critical: without the isPublicDashboard early return, logged-in users
// would be redirected to HOME due to the non-private route handling
assertStaysOnRoute('/public/dashboard/abc123');
assertNoRedirect();
});
});
@@ -404,7 +417,7 @@ describe('PrivateRoute', () => {
});
assertRendersChildren();
assertStaysOnRoute(ROUTES.HOME);
assertNoRedirect();
});
it('should redirect to unauthorized when VIEWER tries to access admin-only route /alerts/new', async () => {
@@ -513,7 +526,7 @@ describe('PrivateRoute', () => {
appContext: { isLoggedIn: true },
});
assertStaysOnRoute(ROUTES.SOMETHING_WENT_WRONG);
assertDoesNotRedirectTo(ROUTES.HOME);
});
});
@@ -525,7 +538,7 @@ describe('PrivateRoute', () => {
});
// Should not redirect - login page handles its own routing
assertStaysOnRoute(ROUTES.LOGIN);
assertNoRedirect();
});
it('should not redirect when not logged in user visits signup page', () => {
@@ -534,7 +547,7 @@ describe('PrivateRoute', () => {
appContext: { isLoggedIn: false },
});
assertStaysOnRoute(ROUTES.SIGN_UP);
assertNoRedirect();
});
it('should not redirect when not logged in user visits password reset page', () => {
@@ -543,7 +556,7 @@ describe('PrivateRoute', () => {
appContext: { isLoggedIn: false },
});
assertStaysOnRoute(ROUTES.PASSWORD_RESET);
assertNoRedirect();
});
it('should not redirect when not logged in user visits forgot password page', () => {
@@ -552,7 +565,7 @@ describe('PrivateRoute', () => {
appContext: { isLoggedIn: false },
});
assertStaysOnRoute(ROUTES.FORGOT_PASSWORD);
assertNoRedirect();
});
});
@@ -641,7 +654,7 @@ describe('PrivateRoute', () => {
});
// Admin should be able to access settings even when workspace is blocked
assertStaysOnRoute(ROUTES.SETTINGS);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
});
it('should allow ADMIN to access /settings/billing when workspace is blocked', () => {
@@ -657,7 +670,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
assertStaysOnRoute(ROUTES.BILLING);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
});
it('should allow ADMIN to access /settings/org-settings when workspace is blocked', () => {
@@ -673,7 +686,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
assertStaysOnRoute(ROUTES.ORG_SETTINGS);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
});
it('should allow ADMIN to access /settings/members when workspace is blocked', () => {
@@ -689,7 +702,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
assertStaysOnRoute(ROUTES.MEMBERS_SETTINGS);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
});
it('should allow ADMIN to access /settings/my-settings when workspace is blocked', () => {
@@ -705,7 +718,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
assertStaysOnRoute(ROUTES.MY_SETTINGS);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
});
it('should redirect VIEWER to workspace locked even when trying to access settings', async () => {
@@ -816,7 +829,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
assertStaysOnRoute(ROUTES.WORKSPACE_LOCKED);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
});
it('should not redirect self-hosted users to workspace locked even when workSpaceBlock is true', () => {
@@ -833,7 +846,7 @@ describe('PrivateRoute', () => {
isCloudUser: false,
});
assertStaysOnRoute(ROUTES.HOME);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
});
});
@@ -903,7 +916,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
assertStaysOnRoute(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
});
it('should not redirect self-hosted users to workspace access restricted when license is terminated', () => {
@@ -920,7 +933,7 @@ describe('PrivateRoute', () => {
isCloudUser: false,
});
assertStaysOnRoute(ROUTES.HOME);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
});
it('should not redirect when license is ACTIVE', () => {
@@ -937,7 +950,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
assertStaysOnRoute(ROUTES.HOME);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
});
it('should not redirect when license is EVALUATING', () => {
@@ -954,7 +967,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
assertStaysOnRoute(ROUTES.HOME);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
});
});
@@ -990,7 +1003,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
assertStaysOnRoute(ROUTES.WORKSPACE_SUSPENDED);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_SUSPENDED);
});
it('should not redirect self-hosted users to workspace suspended when license is defaulted', () => {
@@ -1007,7 +1020,7 @@ describe('PrivateRoute', () => {
isCloudUser: false,
});
assertStaysOnRoute(ROUTES.HOME);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_SUSPENDED);
});
});
@@ -1027,11 +1040,6 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
// Wait for the users query to complete and trigger re-render
await act(async () => {
await Promise.resolve();
});
await assertRedirectsTo(ROUTES.ONBOARDING);
});
@@ -1047,7 +1055,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
assertStaysOnRoute(ROUTES.HOME);
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
});
it('should not redirect to onboarding when onboarding is already complete', async () => {
@@ -1073,7 +1081,7 @@ describe('PrivateRoute', () => {
// Critical: if isOnboardingComplete check is broken (always false),
// this test would fail because all other conditions for redirect ARE met
assertStaysOnRoute(ROUTES.HOME);
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
});
it('should not redirect to onboarding for non-cloud users', () => {
@@ -1088,7 +1096,7 @@ describe('PrivateRoute', () => {
isCloudUser: false,
});
assertStaysOnRoute(ROUTES.HOME);
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
});
it('should not redirect to onboarding when on /workspace-locked route', () => {
@@ -1103,7 +1111,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
assertStaysOnRoute(ROUTES.WORKSPACE_LOCKED);
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
});
it('should not redirect to onboarding when on /workspace-suspended route', () => {
@@ -1118,7 +1126,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
assertStaysOnRoute(ROUTES.WORKSPACE_SUSPENDED);
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
});
it('should not redirect to onboarding when workspace is blocked and accessing billing', async () => {
@@ -1145,7 +1153,7 @@ describe('PrivateRoute', () => {
});
// Should NOT redirect to onboarding - user needs to access billing to fix payment
assertStaysOnRoute(ROUTES.BILLING);
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
});
it('should not redirect to onboarding when workspace is blocked and accessing settings', async () => {
@@ -1169,7 +1177,7 @@ describe('PrivateRoute', () => {
await Promise.resolve();
});
assertStaysOnRoute(ROUTES.SETTINGS);
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
});
it('should not redirect to onboarding when workspace is suspended (DEFAULTED)', async () => {
@@ -1196,7 +1204,7 @@ describe('PrivateRoute', () => {
});
// Should redirect to WORKSPACE_SUSPENDED, not ONBOARDING
await assertRedirectsTo(ROUTES.WORKSPACE_SUSPENDED);
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
});
it('should not redirect to onboarding when workspace is access restricted (TERMINATED)', async () => {
@@ -1223,7 +1231,7 @@ describe('PrivateRoute', () => {
});
// Should redirect to WORKSPACE_ACCESS_RESTRICTED, not ONBOARDING
await assertRedirectsTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
});
it('should not redirect to onboarding when workspace is access restricted (EXPIRED)', async () => {
@@ -1249,7 +1257,7 @@ describe('PrivateRoute', () => {
await Promise.resolve();
});
await assertRedirectsTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
});
});
@@ -1291,7 +1299,7 @@ describe('PrivateRoute', () => {
},
});
assertStaysOnRoute(ROUTES.GET_STARTED);
assertDoesNotRedirectTo(ROUTES.GET_STARTED_WITH_CLOUD);
});
it('should not redirect when on GET_STARTED and ONBOARDING_V3 feature flag is not present', () => {
@@ -1303,7 +1311,7 @@ describe('PrivateRoute', () => {
},
});
assertStaysOnRoute(ROUTES.GET_STARTED);
assertDoesNotRedirectTo(ROUTES.GET_STARTED_WITH_CLOUD);
});
it('should not redirect when on different route even if ONBOARDING_V3 is active', () => {
@@ -1323,7 +1331,7 @@ describe('PrivateRoute', () => {
},
});
assertStaysOnRoute(ROUTES.HOME);
assertDoesNotRedirectTo(ROUTES.GET_STARTED_WITH_CLOUD);
});
});
@@ -1339,7 +1347,7 @@ describe('PrivateRoute', () => {
},
});
assertStaysOnRoute(ROUTES.HOME);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
});
it('should not fetch users when org data is not available', () => {
@@ -1382,7 +1390,9 @@ describe('PrivateRoute', () => {
},
});
assertStaysOnRoute(ROUTES.HOME);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_SUSPENDED);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
});
});
@@ -1423,40 +1433,22 @@ describe('PrivateRoute', () => {
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
});
it('should allow ADMIN to access /services route', () => {
renderPrivateRoute({
initialRoute: ROUTES.APPLICATION,
appContext: {
isLoggedIn: true,
user: createMockUser({ role: USER_ROLES.ADMIN as ROLES }),
},
it('should allow all roles to access /services route', () => {
const roles = [USER_ROLES.ADMIN, USER_ROLES.EDITOR, USER_ROLES.VIEWER];
roles.forEach((role) => {
jest.clearAllMocks();
renderPrivateRoute({
initialRoute: ROUTES.APPLICATION,
appContext: {
isLoggedIn: true,
user: createMockUser({ role: role as ROLES }),
},
});
assertDoesNotRedirectTo(ROUTES.UN_AUTHORIZED);
});
assertStaysOnRoute(ROUTES.APPLICATION);
});
it('should allow EDITOR to access /services route', () => {
renderPrivateRoute({
initialRoute: ROUTES.APPLICATION,
appContext: {
isLoggedIn: true,
user: createMockUser({ role: USER_ROLES.EDITOR as ROLES }),
},
});
assertStaysOnRoute(ROUTES.APPLICATION);
});
it('should allow VIEWER to access /services route', () => {
renderPrivateRoute({
initialRoute: ROUTES.APPLICATION,
appContext: {
isLoggedIn: true,
user: createMockUser({ role: USER_ROLES.VIEWER as ROLES }),
},
});
assertStaysOnRoute(ROUTES.APPLICATION);
});
it('should redirect VIEWER from /onboarding route (admin only)', async () => {
@@ -1486,7 +1478,7 @@ describe('PrivateRoute', () => {
});
assertRendersChildren();
assertStaysOnRoute(ROUTES.CHANNELS_NEW);
assertDoesNotRedirectTo(ROUTES.UN_AUTHORIZED);
});
it('should allow EDITOR to access /get-started route', () => {
@@ -1498,7 +1490,7 @@ describe('PrivateRoute', () => {
},
});
assertStaysOnRoute(ROUTES.GET_STARTED);
assertDoesNotRedirectTo(ROUTES.UN_AUTHORIZED);
});
});

View File

@@ -18,7 +18,7 @@ import AppLayout from 'container/AppLayout';
import Hex from 'crypto-js/enc-hex';
import HmacSHA256 from 'crypto-js/hmac-sha256';
import { KeyboardHotkeysProvider } from 'hooks/hotkeys/useKeyboardHotkeys';
import { useIsDarkMode, useThemeConfig } from 'hooks/useDarkMode';
import { useThemeConfig } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { NotificationProvider } from 'hooks/useNotifications';
import { ResourceProvider } from 'hooks/useResourceAttribute';
@@ -212,12 +212,6 @@ function App(): JSX.Element {
activeLicenseFetchError,
]);
const isDarkMode = useIsDarkMode();
useEffect(() => {
window.Pylon?.('setTheme', isDarkMode ? 'dark' : 'light');
}, [isDarkMode]);
useEffect(() => {
if (
pathname === ROUTES.ONBOARDING ||

View File

@@ -244,18 +244,12 @@ export const ShortcutsPage = Loadable(
() => import(/* webpackChunkName: "ShortcutsPage" */ 'pages/Settings'),
);
export const Integrations = Loadable(
export const InstalledIntegrations = Loadable(
() =>
import(
/* webpackChunkName: "InstalledIntegrations" */ 'pages/IntegrationsModulePage'
),
);
export const IntegrationsDetailsPage = Loadable(
() =>
import(
/* webpackChunkName: "IntegrationsDetailsPage" */ 'pages/IntegrationsDetailsPage'
),
);
export const MessagingQueuesMainPage = Loadable(
() =>

View File

@@ -18,8 +18,7 @@ import {
ForgotPassword,
Home,
InfrastructureMonitoring,
Integrations,
IntegrationsDetailsPage,
InstalledIntegrations,
LicensePage,
ListAllALertsPage,
LiveLogs,
@@ -390,17 +389,10 @@ const routes: AppRoutes[] = [
isPrivate: true,
key: 'WORKSPACE_ACCESS_RESTRICTED',
},
{
path: ROUTES.INTEGRATIONS_DETAIL,
exact: true,
component: IntegrationsDetailsPage,
isPrivate: true,
key: 'INTEGRATIONS_DETAIL',
},
{
path: ROUTES.INTEGRATIONS,
exact: true,
component: Integrations,
component: InstalledIntegrations,
isPrivate: true,
key: 'INTEGRATIONS',
},

View File

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

View File

@@ -425,6 +425,39 @@ export interface AuthtypesSessionContextDTO {
orgs?: AuthtypesOrgSessionContextDTO[] | null;
}
export interface AuthtypesStorableRoleDTO {
/**
* @type string
* @format date-time
*/
createdAt?: Date;
/**
* @type string
*/
description?: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
orgId?: string;
/**
* @type string
*/
type?: string;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
}
export interface AuthtypesTransactionDTO {
object: AuthtypesObjectDTO;
/**
@@ -442,25 +475,25 @@ export interface AuthtypesUserRoleDTO {
* @type string
* @format date-time
*/
createdAt: Date;
createdAt?: Date;
/**
* @type string
*/
id: string;
role: AuthtypesRoleDTO;
role?: AuthtypesStorableRoleDTO;
/**
* @type string
*/
roleId: string;
roleId?: string;
/**
* @type string
* @format date-time
*/
updatedAt: Date;
updatedAt?: Date;
/**
* @type string
*/
userId: string;
userId?: string;
}
export interface AuthtypesUserWithRolesDTO {
@@ -512,58 +545,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 +576,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 +643,7 @@ export interface CloudintegrationtypesAWSServiceLogsConfigDTO {
/**
* @type object
*/
s3Buckets?: CloudintegrationtypesAWSServiceLogsConfigDTOS3Buckets;
s3_buckets?: CloudintegrationtypesAWSServiceLogsConfigDTOS3Buckets;
}
export interface CloudintegrationtypesAWSServiceMetricsConfigDTO {
@@ -601,19 +653,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 +726,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 +760,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 +801,7 @@ export interface CloudintegrationtypesDataCollectedDTO {
metrics?: CloudintegrationtypesCollectedMetricDTO[] | null;
}
export interface CloudintegrationtypesGettableAccountWithConnectionArtifactDTO {
export interface CloudintegrationtypesGettableAccountWithArtifactDTO {
connectionArtifact: CloudintegrationtypesConnectionArtifactDTO;
/**
* @type string
@@ -814,7 +816,7 @@ export interface CloudintegrationtypesGettableAccountsDTO {
accounts: CloudintegrationtypesAccountDTO[];
}
export interface CloudintegrationtypesGettableAgentCheckInDTO {
export interface CloudintegrationtypesGettableAgentCheckInResponseDTO {
/**
* @type string
*/
@@ -862,85 +864,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 +891,7 @@ export interface CloudintegrationtypesPostableAgentCheckInDTO {
* @type object
* @nullable true
*/
data: CloudintegrationtypesPostableAgentCheckInDTOData;
data: CloudintegrationtypesPostableAgentCheckInRequestDTOData;
/**
* @type string
*/
@@ -970,7 +904,6 @@ export interface CloudintegrationtypesProviderIntegrationConfigDTO {
export interface CloudintegrationtypesServiceDTO {
assets: CloudintegrationtypesAssetsDTO;
cloudIntegrationService: CloudintegrationtypesCloudIntegrationServiceDTO;
dataCollected: CloudintegrationtypesDataCollectedDTO;
/**
* @type string
@@ -984,8 +917,9 @@ export interface CloudintegrationtypesServiceDTO {
* @type string
*/
overview: string;
supportedSignals: CloudintegrationtypesSupportedSignalsDTO;
telemetryCollectionStrategy: CloudintegrationtypesTelemetryCollectionStrategyDTO;
serviceConfig?: CloudintegrationtypesServiceConfigDTO;
supported_signals: CloudintegrationtypesSupportedSignalsDTO;
telemetryCollectionStrategy: CloudintegrationtypesCollectionStrategyDTO;
/**
* @type string
*/
@@ -996,21 +930,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 +960,6 @@ export interface CloudintegrationtypesSupportedSignalsDTO {
metrics?: boolean;
}
export interface CloudintegrationtypesTelemetryCollectionStrategyDTO {
aws: CloudintegrationtypesAWSTelemetryCollectionStrategyDTO;
}
export interface CloudintegrationtypesUpdatableAccountDTO {
config: CloudintegrationtypesAccountConfigDTO;
}
@@ -2795,6 +2710,14 @@ export interface RenderErrorResponseDTO {
status: string;
}
export enum RulestatehistorytypesAlertStateDTO {
inactive = 'inactive',
pending = 'pending',
recovering = 'recovering',
firing = 'firing',
nodata = 'nodata',
disabled = 'disabled',
}
export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
/**
* @type integer
@@ -2806,7 +2729,7 @@ export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
* @nullable true
*/
labels: Querybuildertypesv5LabelDTO[] | null;
overallState: RuletypesAlertStateDTO;
overallState: RulestatehistorytypesAlertStateDTO;
/**
* @type boolean
*/
@@ -2814,12 +2737,12 @@ export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
/**
* @type string
*/
ruleId: string;
ruleID: string;
/**
* @type string
*/
ruleName: string;
state: RuletypesAlertStateDTO;
state: RulestatehistorytypesAlertStateDTO;
/**
* @type boolean
*/
@@ -2917,17 +2840,9 @@ export interface RulestatehistorytypesGettableRuleStateWindowDTO {
* @format int64
*/
start: number;
state: RuletypesAlertStateDTO;
state: RulestatehistorytypesAlertStateDTO;
}
export enum RuletypesAlertStateDTO {
inactive = 'inactive',
pending = 'pending',
recovering = 'recovering',
firing = 'firing',
nodata = 'nodata',
disabled = 'disabled',
}
export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
/**
* @type string
@@ -3568,7 +3483,7 @@ export type AgentCheckInDeprecatedPathParameters = {
cloudProvider: string;
};
export type AgentCheckInDeprecated200 = {
data: CloudintegrationtypesGettableAgentCheckInDTO;
data: CloudintegrationtypesGettableAgentCheckInResponseDTO;
/**
* @type string
*/
@@ -3589,8 +3504,8 @@ export type ListAccounts200 = {
export type CreateAccountPathParameters = {
cloudProvider: string;
};
export type CreateAccount201 = {
data: CloudintegrationtypesGettableAccountWithConnectionArtifactDTO;
export type CreateAccount200 = {
data: CloudintegrationtypesGettableAccountWithArtifactDTO;
/**
* @type string
*/
@@ -3617,27 +3532,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 +3546,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 +3558,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 +3566,10 @@ export type GetService200 = {
status: string;
};
export type UpdateServicePathParameters = {
cloudProvider: string;
serviceId: string;
};
export type CreateSessionByGoogleCallback303 = {
data: AuthtypesGettableTokenDTO;
/**
@@ -4726,7 +4613,7 @@ export type GetRuleHistoryTimelineParams = {
/**
* @description undefined
*/
state?: RuletypesAlertStateDTO;
state?: RulestatehistorytypesAlertStateDTO;
/**
* @type string
* @description undefined

View File

@@ -13,9 +13,7 @@ export interface HostListPayload {
orderBy?: {
columnName: string;
order: 'asc' | 'desc';
} | null;
start?: number;
end?: number;
};
}
export interface TimeSeriesValue {

View File

@@ -5,10 +5,11 @@ import {
ServiceData,
UpdateServiceConfigPayload,
UpdateServiceConfigResponse,
} from 'container/Integrations/CloudIntegration/AmazonWebServices/types';
} from 'container/CloudIntegrationPage/ServicesSection/types';
import {
AccountConfigPayload,
AccountConfigResponse,
AWSAccountConfigPayload,
ConnectionParams,
ConnectionUrlResponse,
} from 'types/api/integrations/aws';
@@ -59,7 +60,7 @@ export const generateConnectionUrl = async (params: {
export const updateAccountConfig = async (
accountId: string,
payload: AWSAccountConfigPayload,
payload: AccountConfigPayload,
): Promise<AccountConfigResponse> => {
const response = await axios.post<AccountConfigResponse>(
`/cloud-integrations/aws/accounts/${accountId}/config`,
@@ -78,3 +79,10 @@ export const updateServiceConfig = async (
);
return response.data;
};
export const getConnectionParams = async (): Promise<ConnectionParams> => {
const response = await axios.get(
'/cloud-integrations/aws/accounts/generate-connection-params',
);
return response.data.data;
};

View File

@@ -28,7 +28,6 @@ import '@signozhq/resizable';
import '@signozhq/sonner';
import '@signozhq/switch';
import '@signozhq/table';
import '@signozhq/tabs';
import '@signozhq/toggle-group';
import '@signozhq/tooltip';
import '@signozhq/ui';

View File

@@ -0,0 +1,97 @@
.announcement-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-4);
padding: var(--padding-2) var(--padding-4);
height: 40px;
font-family: var(--font-sans), sans-serif;
font-size: var(--label-base-500-font-size);
line-height: var(--label-base-500-line-height);
font-weight: var(--label-base-500-font-weight);
letter-spacing: -0.065px;
&--warning {
background-color: var(--callout-warning-background);
color: var(--callout-warning-description);
.announcement-banner__action,
.announcement-banner__dismiss {
background: var(--callout-warning-border);
}
}
&--info {
background-color: var(--callout-primary-background);
color: var(--callout-primary-description);
.announcement-banner__action,
.announcement-banner__dismiss {
background: var(--callout-primary-border);
}
}
&--error {
background-color: var(--callout-error-background);
color: var(--callout-error-description);
.announcement-banner__action,
.announcement-banner__dismiss {
background: var(--callout-error-border);
}
}
&--success {
background-color: var(--callout-success-background);
color: var(--callout-success-description);
.announcement-banner__action,
.announcement-banner__dismiss {
background: var(--callout-success-border);
}
}
&__body {
display: flex;
align-items: center;
gap: var(--spacing-4);
flex: 1;
min-width: 0;
}
&__icon {
display: flex;
align-items: center;
flex-shrink: 0;
}
&__message {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: var(--line-height-normal);
strong {
font-weight: var(--font-weight-semibold);
}
}
&__action {
height: 24px;
font-size: var(--label-small-500-font-size);
color: currentColor;
&:hover {
opacity: 0.8;
}
}
&__dismiss {
width: 24px;
height: 24px;
padding: 0;
color: currentColor;
&:hover {
opacity: 0.8;
}
}
}

View File

@@ -0,0 +1,89 @@
import { render, screen, userEvent } from 'tests/test-utils';
import {
AnnouncementBanner,
AnnouncementBannerProps,
PersistedAnnouncementBanner,
} from './index';
const STORAGE_KEY = 'test-banner-dismissed';
function renderBanner(props: Partial<AnnouncementBannerProps> = {}): void {
render(<AnnouncementBanner message="Test message" {...props} />);
}
afterEach(() => {
localStorage.removeItem(STORAGE_KEY);
});
describe('AnnouncementBanner', () => {
it('renders message and default warning variant', () => {
renderBanner({ message: <strong>Heads up</strong> });
const alert = screen.getByRole('alert');
expect(alert).toHaveClass('announcement-banner--warning');
expect(alert).toHaveTextContent('Heads up');
});
it.each(['warning', 'info', 'success', 'error'] as const)(
'renders %s variant correctly',
(type) => {
renderBanner({ type, message: 'Test message' });
const alert = screen.getByRole('alert');
expect(alert).toHaveClass(`announcement-banner--${type}`);
},
);
it('calls action onClick when action button is clicked', async () => {
const onClick = jest.fn() as jest.MockedFunction<() => void>;
renderBanner({ action: { label: 'Go to Settings', onClick } });
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(screen.getByRole('button', { name: /go to settings/i }));
expect(onClick).toHaveBeenCalledTimes(1);
});
it('hides dismiss button when onClose is not provided and hides icon when icon is null', () => {
renderBanner({ onClose: undefined, icon: null });
expect(
screen.queryByRole('button', { name: /dismiss/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('alert')?.querySelector('.announcement-banner__icon'),
).not.toBeInTheDocument();
});
});
describe('PersistedAnnouncementBanner', () => {
it('dismisses on click, calls onDismiss, and persists to localStorage', async () => {
const onDismiss = jest.fn() as jest.MockedFunction<() => void>;
render(
<PersistedAnnouncementBanner
message="Test message"
storageKey={STORAGE_KEY}
onDismiss={onDismiss}
/>,
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(screen.getByRole('button', { name: /dismiss/i }));
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(onDismiss).toHaveBeenCalledTimes(1);
expect(localStorage.getItem(STORAGE_KEY)).toBe('true');
});
it('does not render when storageKey is already set in localStorage', () => {
localStorage.setItem(STORAGE_KEY, 'true');
render(
<PersistedAnnouncementBanner
message="Test message"
storageKey={STORAGE_KEY}
/>,
);
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,84 @@
import { ReactNode } from 'react';
import { Button } from '@signozhq/button';
import {
CircleAlert,
CircleCheckBig,
Info,
TriangleAlert,
X,
} from '@signozhq/icons';
import cx from 'classnames';
import './AnnouncementBanner.styles.scss';
export type AnnouncementBannerType = 'warning' | 'info' | 'error' | 'success';
export interface AnnouncementBannerAction {
label: string;
onClick: () => void;
}
export interface AnnouncementBannerProps {
message: ReactNode;
type?: AnnouncementBannerType;
icon?: ReactNode | null;
action?: AnnouncementBannerAction;
onClose?: () => void;
className?: string;
}
const DEFAULT_ICONS: Record<AnnouncementBannerType, ReactNode> = {
warning: <TriangleAlert size={14} />,
info: <Info size={14} />,
error: <CircleAlert size={14} />,
success: <CircleCheckBig size={14} />,
};
export default function AnnouncementBanner({
message,
type = 'warning',
icon,
action,
onClose,
className,
}: AnnouncementBannerProps): JSX.Element {
const resolvedIcon = icon === null ? null : icon ?? DEFAULT_ICONS[type];
return (
<div
role="alert"
className={cx(
'announcement-banner',
`announcement-banner--${type}`,
className,
)}
>
<div className="announcement-banner__body">
{resolvedIcon && (
<span className="announcement-banner__icon">{resolvedIcon}</span>
)}
<span className="announcement-banner__message">{message}</span>
{action && (
<Button
type="button"
className="announcement-banner__action"
onClick={action.onClick}
>
{action.label}
</Button>
)}
</div>
{onClose && (
<Button
type="button"
aria-label="Dismiss"
className="announcement-banner__dismiss"
onClick={onClose}
>
<X size={14} />
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { useState } from 'react';
import AnnouncementBanner, {
AnnouncementBannerProps,
} from './AnnouncementBanner';
interface PersistedAnnouncementBannerProps extends AnnouncementBannerProps {
storageKey: string;
onDismiss?: () => void;
}
function isDismissed(storageKey: string): boolean {
return localStorage.getItem(storageKey) === 'true';
}
export default function PersistedAnnouncementBanner({
storageKey,
onDismiss,
...props
}: PersistedAnnouncementBannerProps): JSX.Element | null {
const [visible, setVisible] = useState(() => !isDismissed(storageKey));
if (!visible) {
return null;
}
const handleClose = (): void => {
localStorage.setItem(storageKey, 'true');
setVisible(false);
onDismiss?.();
};
return <AnnouncementBanner {...props} onClose={handleClose} />;
}

View File

@@ -0,0 +1,12 @@
import AnnouncementBanner from './AnnouncementBanner';
import PersistedAnnouncementBanner from './PersistedAnnouncementBanner';
export type {
AnnouncementBannerAction,
AnnouncementBannerProps,
AnnouncementBannerType,
} from './AnnouncementBanner';
export { AnnouncementBanner, PersistedAnnouncementBanner };
export default AnnouncementBanner;

View File

@@ -1,34 +0,0 @@
.cloud-service-data-collected {
display: flex;
flex-direction: column;
gap: 16px;
.cloud-service-data-collected-table {
display: flex;
flex-direction: column;
gap: 8px;
.cloud-service-data-collected-table-heading {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
color: var(--l2-foreground);
/* Bifrost (Ancient)/Content/sm */
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.cloud-service-data-collected-table-logs {
border-radius: 6px;
border: 1px solid var(--l3-background);
background: var(--l1-background);
}
}
}

View File

@@ -1,39 +0,0 @@
.code-block-container {
position: relative;
border-radius: 4px;
overflow: hidden;
.code-block-copy-btn {
position: absolute;
top: 50%;
right: 8px;
transform: translateY(-50%);
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
padding: 6px 8px;
color: var(--bg-vanilla-100);
transition: color 0.15s ease;
&.copied {
background-color: var(--bg-robin-500);
}
}
// CodeMirror wrapper
.code-block-editor {
border-radius: 4px;
.cm-editor {
border-radius: 4px;
font-size: 13px;
line-height: 1.5;
font-family: 'Space Mono', monospace;
}
.cm-scroller {
font-family: 'Space Mono', monospace;
}
}
}

View File

@@ -1,146 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { CheckOutlined, CopyOutlined } from '@ant-design/icons';
import { javascript } from '@codemirror/lang-javascript';
import { Button } from '@signozhq/button';
import { dracula } from '@uiw/codemirror-theme-dracula';
import { githubLight } from '@uiw/codemirror-theme-github';
import CodeMirror, {
EditorState,
EditorView,
Extension,
} from '@uiw/react-codemirror';
import cx from 'classnames';
import { useIsDarkMode } from 'hooks/useDarkMode';
import './CodeBlock.styles.scss';
export type CodeBlockLanguage =
| 'javascript'
| 'typescript'
| 'js'
| 'ts'
| 'json'
| 'bash'
| 'shell'
| 'text';
export type CodeBlockTheme = 'light' | 'dark' | 'auto';
interface CodeBlockProps {
/** The code content to display */
value: string;
/** Language for syntax highlighting */
language?: CodeBlockLanguage;
/** Theme: 'light' | 'dark' | 'auto' (follows app dark mode when 'auto') */
theme?: CodeBlockTheme;
/** Show line numbers */
lineNumbers?: boolean;
/** Show copy button */
showCopyButton?: boolean;
/** Custom class name for the container */
className?: string;
/** Max height in pixels - enables scrolling when content exceeds */
maxHeight?: number | string;
/** Callback when copy is clicked */
onCopy?: (copiedText: string) => void;
}
const LANGUAGE_EXTENSION_MAP: Record<
CodeBlockLanguage,
ReturnType<typeof javascript> | undefined
> = {
javascript: javascript({ jsx: true }),
typescript: javascript({ jsx: true }),
js: javascript({ jsx: true }),
ts: javascript({ jsx: true }),
json: javascript(), // JSON is valid JS; proper json() would require @codemirror/lang-json
bash: undefined,
shell: undefined,
text: undefined,
};
function CodeBlock({
value,
language = 'text',
theme: themeProp = 'auto',
lineNumbers = true,
showCopyButton = true,
className,
maxHeight,
onCopy,
}: CodeBlockProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const [isCopied, setIsCopied] = useState(false);
const [, copyToClipboard] = useCopyToClipboard();
const resolvedDark = themeProp === 'auto' ? isDarkMode : themeProp === 'dark';
const theme = resolvedDark ? dracula : githubLight;
const extensions = useMemo((): Extension[] => {
const langExtension = LANGUAGE_EXTENSION_MAP[language];
return [
EditorState.readOnly.of(true),
EditorView.editable.of(false),
EditorView.lineWrapping,
...(langExtension ? [langExtension] : []),
];
}, [language]);
const handleCopy = useCallback((): void => {
copyToClipboard(value);
setIsCopied(true);
onCopy?.(value);
setTimeout(() => setIsCopied(false), 2000);
}, [value, onCopy, copyToClipboard]);
return (
<div className={cx('code-block-container', className)}>
{showCopyButton && (
<Button
variant="solid"
size="xs"
color="secondary"
className={cx('code-block-copy-btn', { copied: isCopied })}
onClick={handleCopy}
aria-label={isCopied ? 'Copied' : 'Copy code'}
title={isCopied ? 'Copied' : 'Copy code'}
>
{isCopied ? <CheckOutlined /> : <CopyOutlined />}
</Button>
)}
<CodeMirror
className="code-block-editor"
value={value}
theme={theme}
readOnly
editable={false}
extensions={extensions}
basicSetup={{
lineNumbers,
highlightActiveLineGutter: false,
highlightActiveLine: false,
highlightSelectionMatches: true,
drawSelection: true,
syntaxHighlighting: true,
bracketMatching: true,
history: false,
foldGutter: false,
autocompletion: false,
defaultKeymap: false,
searchKeymap: true,
historyKeymap: false,
foldKeymap: false,
completionKeymap: false,
closeBrackets: false,
indentOnInput: false,
}}
style={{
maxHeight: maxHeight ?? 'auto',
}}
/>
</div>
);
}
export default CodeBlock;

View File

@@ -1,2 +0,0 @@
export type { CodeBlockLanguage, CodeBlockTheme } from './CodeBlock';
export { default as CodeBlock } from './CodeBlock';

View File

@@ -14,8 +14,6 @@ import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schem
import { AxiosError } from 'axios';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import './CreateServiceAccountModal.styles.scss';
@@ -30,8 +28,6 @@ function CreateServiceAccountModal(): JSX.Element {
parseAsBoolean.withDefault(false),
);
const { showErrorModal, isErrorModalVisible } = useErrorModal();
const {
control,
handleSubmit,
@@ -58,10 +54,13 @@ function CreateServiceAccountModal(): JSX.Element {
await invalidateListServiceAccounts(queryClient);
},
onError: (err) => {
const errMessage = convertToApiError(
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
);
showErrorModal(errMessage as APIError);
const errMessage =
convertToApiError(
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'An error occurred';
toast.error(`Failed to create service account: ${errMessage}`, {
richColors: true,
});
},
},
});
@@ -91,7 +90,7 @@ function CreateServiceAccountModal(): JSX.Element {
showCloseButton
width="narrow"
className="create-sa-modal"
disableOutsideClick={isErrorModalVisible}
disableOutsideClick={false}
>
<div className="create-sa-modal__content">
<form

View File

@@ -11,16 +11,6 @@ jest.mock('@signozhq/sonner', () => ({
const mockToast = jest.mocked(toast);
const showErrorModal = jest.fn();
jest.mock('providers/ErrorModalProvider', () => ({
__esModule: true,
...jest.requireActual('providers/ErrorModalProvider'),
useErrorModal: jest.fn(() => ({
showErrorModal,
isErrorModalVisible: false,
})),
}));
const SERVICE_ACCOUNTS_ENDPOINT = '*/api/v1/service_accounts';
function renderModal(): ReturnType<typeof render> {
@@ -102,13 +92,10 @@ describe('CreateServiceAccountModal', () => {
await user.click(submitBtn);
await waitFor(() => {
expect(showErrorModal).toHaveBeenCalledWith(
expect.objectContaining({
getErrorMessage: expect.any(Function),
}),
expect(mockToast.error).toHaveBeenCalledWith(
expect.stringMatching(/Failed to create service account/i),
expect.anything(),
);
const passedError = showErrorModal.mock.calls[0][0] as any;
expect(passedError.getErrorMessage()).toBe('Internal Server Error');
});
expect(

View File

@@ -1,75 +0,0 @@
import { Button } from '@signozhq/button';
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
import { Trash2, X } from '@signozhq/icons';
import { MemberRow } from 'components/MembersTable/MembersTable';
interface DeleteMemberDialogProps {
open: boolean;
isInvited: boolean;
member: MemberRow | null;
isDeleting: boolean;
onClose: () => void;
onConfirm: () => void;
}
function DeleteMemberDialog({
open,
isInvited,
member,
isDeleting,
onClose,
onConfirm,
}: DeleteMemberDialogProps): JSX.Element {
const title = isInvited ? 'Revoke Invite' : 'Delete Member';
const body = isInvited ? (
<>
Are you sure you want to revoke the invite for{' '}
<strong>{member?.email}</strong>? They will no longer be able to join the
workspace using this invite.
</>
) : (
<>
Are you sure you want to delete{' '}
<strong>{member?.name || member?.email}</strong>? This will remove their
access to the workspace.
</>
);
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title={title}
width="narrow"
className="alert-dialog delete-dialog"
showCloseButton={false}
disableOutsideClick={false}
>
<p className="delete-dialog__body">{body}</p>
<DialogFooter className="delete-dialog__footer">
<Button variant="solid" color="secondary" size="sm" onClick={onClose}>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="destructive"
size="sm"
disabled={isDeleting}
onClick={onConfirm}
>
<Trash2 size={12} />
{isDeleting ? 'Processing...' : title}
</Button>
</DialogFooter>
</DialogWrapper>
);
}
export default DeleteMemberDialog;

View File

@@ -45,8 +45,8 @@
display: flex;
align-items: center;
justify-content: space-between;
min-height: 32px;
padding: var(--padding-1) var(--padding-2);
height: 32px;
padding: 0 var(--padding-2);
border-radius: 2px;
background: var(--l2-background);
border: 1px solid var(--border);
@@ -57,13 +57,6 @@
}
}
&__disabled-roles {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
flex: 1;
}
&__email-text {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
@@ -83,6 +76,35 @@
opacity: 0.6;
}
&__role-select {
width: 100%;
height: 32px;
.ant-select-selector {
background-color: var(--l2-background) !important;
border-color: var(--border) !important;
border-radius: 2px;
padding: 0 var(--padding-2) !important;
display: flex;
align-items: center;
}
.ant-select-selection-item {
font-size: var(--font-size-sm);
color: var(--l1-foreground);
line-height: 32px;
letter-spacing: -0.07px;
}
.ant-select-arrow {
color: var(--foreground);
}
&:not(.ant-select-disabled):hover .ant-select-selector {
border-color: var(--foreground);
}
}
&__meta {
display: flex;
flex-direction: column;
@@ -146,10 +168,6 @@
flex-shrink: 0;
}
&__tooltip-wrapper {
display: inline-flex;
}
&__footer-btn {
display: inline-flex;
align-items: center;

View File

@@ -1,67 +1,39 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { Badge } from '@signozhq/badge';
import { Button } from '@signozhq/button';
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
import { DrawerWrapper } from '@signozhq/drawer';
import { LockKeyhole, RefreshCw, Trash2, X } from '@signozhq/icons';
import {
Check,
ChevronDown,
Copy,
LockKeyhole,
RefreshCw,
Trash2,
X,
} from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { toast } from '@signozhq/sonner';
import { Skeleton, Tooltip } from 'antd';
import { Select } from 'antd';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import {
getResetPasswordToken,
useDeleteUser,
useGetUser,
useUpdateMyUserV2,
useUpdateUser,
useUpdateUserDeprecated,
} from 'api/generated/services/users';
import { AxiosError } from 'axios';
import { MemberRow } from 'components/MembersTable/MembersTable';
import RolesSelect, { useRoles } from 'components/RolesSelect';
import SaveErrorItem from 'components/ServiceAccountDrawer/SaveErrorItem';
import type { SaveError } from 'components/ServiceAccountDrawer/utils';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { MemberStatus } from 'container/MembersSettings/utils';
import {
MemberRoleUpdateFailure,
useMemberRoleManager,
} from 'hooks/member/useMemberRoleManager';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { capitalize } from 'lodash-es';
import { useTimezone } from 'providers/Timezone';
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
import DeleteMemberDialog from './DeleteMemberDialog';
import ResetLinkDialog from './ResetLinkDialog';
import { ROLES } from 'types/roles';
import { popupContainer } from 'utils/selectPopupContainer';
import './EditMemberDrawer.styles.scss';
const ROOT_USER_TOOLTIP = 'This operation is not supported for the root user';
const SELF_DELETE_TOOLTIP =
'You cannot perform this action on your own account';
function getDeleteTooltip(
isRootUser: boolean,
isSelf: boolean,
): string | undefined {
if (isRootUser) {
return ROOT_USER_TOOLTIP;
}
if (isSelf) {
return SELF_DELETE_TOOLTIP;
}
return undefined;
}
function toSaveApiError(err: unknown): APIError {
return (
convertToApiError(err as AxiosError<RenderErrorResponseDTO>) ??
toAPIError(err as AxiosError<RenderErrorResponseDTO>)
);
}
export interface EditMemberDrawerProps {
member: MemberRow | null;
open: boolean;
@@ -77,12 +49,9 @@ function EditMemberDrawer({
onComplete,
}: EditMemberDrawerProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const { user: currentUser } = useAppContext();
const [localDisplayName, setLocalDisplayName] = useState('');
const [localRole, setLocalRole] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [saveErrors, setSaveErrors] = useState<SaveError[]>([]);
const [displayName, setDisplayName] = useState('');
const [selectedRole, setSelectedRole] = useState<ROLES>('VIEWER');
const [isGeneratingLink, setIsGeneratingLink] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [resetLink, setResetLink] = useState<string | null>(null);
@@ -91,247 +60,92 @@ function EditMemberDrawer({
const [linkType, setLinkType] = useState<'invite' | 'reset' | null>(null);
const isInvited = member?.status === MemberStatus.Invited;
const isDeleted = member?.status === MemberStatus.Deleted;
const isSelf = !!member?.id && member.id === currentUser?.id;
const { showErrorModal } = useErrorModal();
const {
data: fetchedUser,
isLoading: isFetchingUser,
refetch: refetchUser,
} = useGetUser(
{ id: member?.id ?? '' },
{ query: { enabled: open && !!member?.id } },
);
const isRootUser = !!fetchedUser?.data?.isRoot;
const {
roles: availableRoles,
isLoading: rolesLoading,
isError: rolesError,
error: rolesErrorObj,
refetch: refetchRoles,
} = useRoles();
const {
fetchedRoleIds,
isLoading: isMemberRolesLoading,
applyDiff,
} = useMemberRoleManager(member?.id ?? '', open && !!member?.id);
const fetchedDisplayName =
fetchedUser?.data?.displayName ?? member?.name ?? '';
const fetchedUserId = fetchedUser?.data?.id;
const fetchedUserDisplayName = fetchedUser?.data?.displayName;
const roleSessionRef = useRef<string | null>(null);
useEffect(() => {
if (fetchedUserId) {
setLocalDisplayName(fetchedUserDisplayName ?? member?.name ?? '');
}
}, [fetchedUserId, fetchedUserDisplayName, member?.name]);
useEffect(() => {
if (fetchedUserId) {
setSaveErrors([]);
}
}, [fetchedUserId]);
useEffect(() => {
if (!member?.id) {
roleSessionRef.current = null;
} else if (member.id !== roleSessionRef.current && !isMemberRolesLoading) {
setLocalRole(fetchedRoleIds[0] ?? '');
roleSessionRef.current = member.id;
}
}, [member?.id, fetchedRoleIds, isMemberRolesLoading]);
const isDirty =
member !== null &&
fetchedUser != null &&
(localDisplayName !== fetchedDisplayName ||
localRole !== (fetchedRoleIds[0] ?? ''));
const { mutateAsync: updateMyUser } = useUpdateMyUserV2();
const { mutateAsync: updateUser } = useUpdateUser();
const { mutate: updateUser, isLoading: isSaving } = useUpdateUserDeprecated({
mutation: {
onSuccess: (): void => {
toast.success('Member details updated successfully', { richColors: true });
onComplete();
onClose();
},
onError: (err): void => {
const errMessage =
convertToApiError(
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'An error occurred';
toast.error(`Failed to update member details: ${errMessage}`, {
richColors: true,
});
},
},
});
const { mutate: deleteUser, isLoading: isDeleting } = useDeleteUser({
mutation: {
onSuccess: (): void => {
toast.success(
isInvited ? 'Invite revoked successfully' : 'Member deleted successfully',
{ richColors: true, position: 'top-right' },
{ richColors: true },
);
setShowDeleteConfirm(false);
onComplete();
onClose();
},
onError: (err): void => {
const errMessage = convertToApiError(
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
);
showErrorModal(errMessage as APIError);
const errMessage =
convertToApiError(
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'An error occurred';
const prefix = isInvited
? 'Failed to revoke invite'
: 'Failed to delete member';
toast.error(`${prefix}: ${errMessage}`, { richColors: true });
},
},
});
const makeRoleRetry = useCallback(
(
context: string,
rawRetry: () => Promise<void>,
) => async (): Promise<void> => {
try {
await rawRetry();
setSaveErrors((prev) => prev.filter((e) => e.context !== context));
refetchUser();
} catch (err) {
setSaveErrors((prev) =>
prev.map((e) =>
e.context === context ? { ...e, apiError: toSaveApiError(err) } : e,
),
);
useEffect(() => {
if (member) {
setDisplayName(member.name ?? '');
setSelectedRole(member.role);
}
}, [member]);
const isDirty =
member !== null &&
(displayName !== (member.name ?? '') || selectedRole !== member.role);
const formatTimestamp = useCallback(
(ts: string | null | undefined): string => {
if (!ts) {
return '—';
}
const d = new Date(ts);
if (Number.isNaN(d.getTime())) {
return '—';
}
return formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.DASH_DATETIME);
},
[refetchUser],
[formatTimezoneAdjustedTimestamp],
);
const retryNameUpdate = useCallback(async (): Promise<void> => {
if (!member) {
return;
}
try {
if (isSelf) {
await updateMyUser({ data: { displayName: localDisplayName } });
} else {
await updateUser({
pathParams: { id: member.id },
data: { displayName: localDisplayName },
});
}
setSaveErrors((prev) => prev.filter((e) => e.context !== 'Name update'));
refetchUser();
} catch (err) {
setSaveErrors((prev) =>
prev.map((e) =>
e.context === 'Name update' ? { ...e, apiError: toSaveApiError(err) } : e,
),
);
}
}, [member, isSelf, localDisplayName, updateMyUser, updateUser, refetchUser]);
const handleSave = useCallback(async (): Promise<void> => {
const handleSave = useCallback((): void => {
if (!member || !isDirty) {
return;
}
setSaveErrors([]);
setIsSaving(true);
try {
const nameChanged = localDisplayName !== fetchedDisplayName;
const rolesChanged = localRole !== (fetchedRoleIds[0] ?? '');
const namePromise = nameChanged
? isSelf
? updateMyUser({ data: { displayName: localDisplayName } })
: updateUser({
pathParams: { id: member.id },
data: { displayName: localDisplayName },
})
: Promise.resolve();
const [nameResult, rolesResult] = await Promise.allSettled([
namePromise,
rolesChanged
? applyDiff([localRole].filter(Boolean), availableRoles)
: Promise.resolve([]),
]);
const errors: SaveError[] = [];
if (nameResult.status === 'rejected') {
errors.push({
context: 'Name update',
apiError: toSaveApiError(nameResult.reason),
onRetry: retryNameUpdate,
});
}
if (rolesResult.status === 'rejected') {
errors.push({
context: 'Roles update',
apiError: toSaveApiError(rolesResult.reason),
onRetry: async (): Promise<void> => {
const failures = await applyDiff(
[localRole].filter(Boolean),
availableRoles,
);
setSaveErrors((prev) => {
const rest = prev.filter((e) => e.context !== 'Roles update');
return [
...rest,
...failures.map((f: MemberRoleUpdateFailure) => {
const ctx = `Role '${f.roleName}'`;
return {
context: ctx,
apiError: toSaveApiError(f.error),
onRetry: makeRoleRetry(ctx, f.onRetry),
};
}),
];
});
refetchUser();
},
});
} else {
for (const failure of rolesResult.value ?? []) {
const context = `Role '${failure.roleName}'`;
errors.push({
context,
apiError: toSaveApiError(failure.error),
onRetry: makeRoleRetry(context, failure.onRetry),
});
}
}
if (errors.length > 0) {
setSaveErrors(errors);
} else {
toast.success('Member details updated successfully', {
richColors: true,
position: 'top-right',
});
onComplete();
}
refetchUser();
} finally {
setIsSaving(false);
}
}, [
member,
isDirty,
isSelf,
localDisplayName,
localRole,
fetchedDisplayName,
fetchedRoleIds,
updateMyUser,
updateUser,
applyDiff,
availableRoles,
refetchUser,
retryNameUpdate,
makeRoleRetry,
onComplete,
]);
updateUser({
pathParams: { id: member.id },
data: { id: member.id, displayName, role: selectedRole },
});
}, [member, isDirty, displayName, selectedRole, updateUser]);
const handleDelete = useCallback((): void => {
if (!member) {
return;
}
deleteUser({ pathParams: { id: member.id } });
deleteUser({
pathParams: { id: member.id },
});
}, [member, deleteUser]);
const handleGenerateResetLink = useCallback(async (): Promise<void> => {
@@ -354,36 +168,37 @@ function EditMemberDrawer({
position: 'top-right',
});
}
} catch (err) {
const errMsg = convertToApiError(
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
);
showErrorModal(errMsg as APIError);
} catch {
toast.error('Failed to generate password reset link', {
richColors: true,
position: 'top-right',
});
} finally {
setIsGeneratingLink(false);
}
}, [member, isInvited, onClose, showErrorModal]);
}, [member, isInvited, setLinkType, onClose]);
const [copyState, copyToClipboard] = useCopyToClipboard();
const handleCopyResetLink = useCallback((): void => {
const handleCopyResetLink = useCallback(async (): Promise<void> => {
if (!resetLink) {
return;
}
copyToClipboard(resetLink);
setHasCopiedResetLink(true);
setTimeout(() => setHasCopiedResetLink(false), 2000);
const message =
toast.success(
linkType === 'invite'
? 'Invite link copied to clipboard'
: 'Reset link copied to clipboard';
toast.success(message, { richColors: true, position: 'top-right' });
: 'Reset link copied to clipboard',
{ richColors: true },
);
}, [resetLink, copyToClipboard, linkType]);
useEffect(() => {
if (copyState.error) {
toast.error('Failed to copy link', {
richColors: true,
position: 'top-right',
});
}
}, [copyState.error]);
@@ -395,211 +210,140 @@ function EditMemberDrawer({
const joinedOnLabel = isInvited ? 'Invited On' : 'Joined On';
const formatTimestamp = useCallback(
(ts: string | null | undefined): string => {
if (!ts) {
return '—';
}
const d = new Date(ts);
if (Number.isNaN(d.getTime())) {
return '—';
}
return formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.DASH_DATETIME);
},
[formatTimezoneAdjustedTimestamp],
);
const drawerBody = isFetchingUser ? (
<Skeleton active paragraph={{ rows: 6 }} />
) : (
<>
<div className="edit-member-drawer__field">
<label className="edit-member-drawer__label" htmlFor="member-name">
Name
</label>
<Tooltip title={isRootUser ? ROOT_USER_TOOLTIP : undefined}>
<Input
id="member-name"
value={localDisplayName}
onChange={(e): void => {
setLocalDisplayName(e.target.value);
setSaveErrors((prev) =>
prev.filter((err) => err.context !== 'Name update'),
);
}}
className="edit-member-drawer__input"
placeholder="Enter name"
disabled={isRootUser || isDeleted}
/>
</Tooltip>
</div>
<div className="edit-member-drawer__field">
<label className="edit-member-drawer__label" htmlFor="member-email">
Email Address
</label>
<div className="edit-member-drawer__input-wrapper edit-member-drawer__input-wrapper--disabled">
<span className="edit-member-drawer__email-text">
{member?.email || '—'}
</span>
<LockKeyhole size={16} className="edit-member-drawer__lock-icon" />
</div>
</div>
<div className="edit-member-drawer__field">
<label className="edit-member-drawer__label" htmlFor="member-role">
Roles
</label>
{isSelf || isRootUser || isDeleted ? (
<Tooltip
title={
isRootUser
? ROOT_USER_TOOLTIP
: isDeleted
? undefined
: 'You cannot modify your own role'
}
>
<div className="edit-member-drawer__input-wrapper edit-member-drawer__input-wrapper--disabled">
<div className="edit-member-drawer__disabled-roles">
{localRole ? (
<Badge color="vanilla">
{availableRoles.find((r) => r.id === localRole)?.name ?? localRole}
</Badge>
) : (
<span className="edit-member-drawer__email-text"></span>
)}
</div>
<LockKeyhole size={16} className="edit-member-drawer__lock-icon" />
</div>
</Tooltip>
) : (
<RolesSelect
id="member-role"
roles={availableRoles}
loading={rolesLoading}
isError={rolesError}
error={rolesErrorObj}
onRefetch={refetchRoles}
value={localRole}
onChange={(role): void => {
setLocalRole(role ?? '');
setSaveErrors((prev) =>
prev.filter(
(err) =>
err.context !== 'Roles update' && !err.context.startsWith("Role '"),
),
);
}}
placeholder="Select role"
allowClear={false}
/>
)}
</div>
<div className="edit-member-drawer__meta">
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">Status</span>
{member?.status === MemberStatus.Active ? (
<Badge color="forest" variant="outline">
ACTIVE
</Badge>
) : member?.status === MemberStatus.Deleted ? (
<Badge color="cherry" variant="outline">
DELETED
</Badge>
) : (
<Badge color="amber" variant="outline">
INVITED
</Badge>
)}
</div>
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">{joinedOnLabel}</span>
<Badge color="vanilla">{formatTimestamp(member?.joinedOn)}</Badge>
</div>
{!isInvited && (
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">Last Modified</span>
<Badge color="vanilla">{formatTimestamp(member?.updatedAt)}</Badge>
</div>
)}
</div>
{saveErrors.length > 0 && (
<div className="edit-member-drawer__save-errors">
{saveErrors.map((e) => (
<SaveErrorItem
key={e.context}
context={e.context}
apiError={e.apiError}
onRetry={e.onRetry}
/>
))}
</div>
)}
</>
);
const drawerContent = (
<div className="edit-member-drawer__layout">
<div className="edit-member-drawer__body">{drawerBody}</div>
<div className="edit-member-drawer__body">
<div className="edit-member-drawer__field">
<label className="edit-member-drawer__label" htmlFor="member-name">
Name
</label>
<Input
id="member-name"
value={displayName}
onChange={(e): void => setDisplayName(e.target.value)}
className="edit-member-drawer__input"
placeholder="Enter name"
/>
</div>
{!isDeleted && (
<div className="edit-member-drawer__footer">
<div className="edit-member-drawer__footer-left">
<Tooltip title={getDeleteTooltip(isRootUser, isSelf)}>
<span className="edit-member-drawer__tooltip-wrapper">
<Button
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--danger"
onClick={(): void => setShowDeleteConfirm(true)}
disabled={isRootUser || isSelf}
>
<Trash2 size={12} />
{isInvited ? 'Revoke Invite' : 'Delete Member'}
</Button>
</span>
</Tooltip>
<div className="edit-member-drawer__footer-divider" />
<Tooltip title={isRootUser ? ROOT_USER_TOOLTIP : undefined}>
<span className="edit-member-drawer__tooltip-wrapper">
<Button
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
onClick={handleGenerateResetLink}
disabled={isGeneratingLink || isRootUser}
>
<RefreshCw size={12} />
{isGeneratingLink && 'Generating...'}
{!isGeneratingLink && isInvited && 'Copy Invite Link'}
{!isGeneratingLink && !isInvited && 'Generate Password Reset Link'}
</Button>
</span>
</Tooltip>
</div>
<div className="edit-member-drawer__footer-right">
<Button variant="solid" color="secondary" size="sm" onClick={handleClose}>
<X size={14} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="sm"
disabled={!isDirty || isSaving || isRootUser}
onClick={handleSave}
>
{isSaving ? 'Saving...' : 'Save Member Details'}
</Button>
<div className="edit-member-drawer__field">
<label className="edit-member-drawer__label" htmlFor="member-email">
Email Address
</label>
<div className="edit-member-drawer__input-wrapper edit-member-drawer__input-wrapper--disabled">
<span className="edit-member-drawer__email-text">
{member?.email || '—'}
</span>
<LockKeyhole size={16} className="edit-member-drawer__lock-icon" />
</div>
</div>
)}
<div className="edit-member-drawer__field">
<label className="edit-member-drawer__label" htmlFor="member-role">
Roles
</label>
<Select
id="member-role"
value={selectedRole}
onChange={(role): void => setSelectedRole(role as ROLES)}
className="edit-member-drawer__role-select"
suffixIcon={<ChevronDown size={14} />}
getPopupContainer={popupContainer}
>
<Select.Option value="ADMIN">{capitalize('ADMIN')}</Select.Option>
<Select.Option value="EDITOR">{capitalize('EDITOR')}</Select.Option>
<Select.Option value="VIEWER">{capitalize('VIEWER')}</Select.Option>
</Select>
</div>
<div className="edit-member-drawer__meta">
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">Status</span>
{member?.status === MemberStatus.Active ? (
<Badge color="forest" variant="outline">
ACTIVE
</Badge>
) : (
<Badge color="amber" variant="outline">
INVITED
</Badge>
)}
</div>
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">{joinedOnLabel}</span>
<Badge color="vanilla">{formatTimestamp(member?.joinedOn)}</Badge>
</div>
{!isInvited && (
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">Last Modified</span>
<Badge color="vanilla">{formatTimestamp(member?.updatedAt)}</Badge>
</div>
)}
</div>
</div>
<div className="edit-member-drawer__footer">
<div className="edit-member-drawer__footer-left">
<Button
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--danger"
onClick={(): void => setShowDeleteConfirm(true)}
>
<Trash2 size={12} />
{isInvited ? 'Revoke Invite' : 'Delete Member'}
</Button>
<div className="edit-member-drawer__footer-divider" />
<Button
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
onClick={handleGenerateResetLink}
disabled={isGeneratingLink}
>
<RefreshCw size={12} />
{isGeneratingLink
? 'Generating...'
: isInvited
? 'Copy Invite Link'
: 'Generate Password Reset Link'}
</Button>
</div>
<div className="edit-member-drawer__footer-right">
<Button variant="solid" color="secondary" size="sm" onClick={handleClose}>
<X size={14} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="sm"
disabled={!isDirty || isSaving}
onClick={handleSave}
>
{isSaving ? 'Saving...' : 'Save Member Details'}
</Button>
</div>
</div>
</div>
);
const deleteDialogTitle = isInvited ? 'Revoke Invite' : 'Delete Member';
const deleteDialogBody = isInvited ? (
<>
Are you sure you want to revoke the invite for{' '}
<strong>{member?.email}</strong>? They will no longer be able to join the
workspace using this invite.
</>
) : (
<>
Are you sure you want to delete{' '}
<strong>{member?.name || member?.email}</strong>? This will remove their
access to the workspace.
</>
);
const deleteConfirmLabel = isInvited ? 'Revoke Invite' : 'Delete Member';
return (
<>
<DrawerWrapper
@@ -619,25 +363,82 @@ function EditMemberDrawer({
className="edit-member-drawer"
/>
<ResetLinkDialog
<DialogWrapper
open={showResetLinkDialog}
linkType={linkType}
resetLink={resetLink}
hasCopied={hasCopiedResetLink}
onClose={(): void => {
setShowResetLinkDialog(false);
onOpenChange={(isOpen): void => {
if (!isOpen) {
setShowResetLinkDialog(false);
setLinkType(null);
}
}}
onCopy={handleCopyResetLink}
/>
title={linkType === 'invite' ? 'Invite Link' : 'Password Reset Link'}
showCloseButton
width="base"
className="reset-link-dialog"
>
<div className="reset-link-dialog__content">
<p className="reset-link-dialog__description">
{linkType === 'invite'
? 'Share this one-time link with the team member to complete their account setup.'
: 'This creates a one-time link the team member can use to set a new password for their SigNoz account.'}
</p>
<div className="reset-link-dialog__link-row">
<div className="reset-link-dialog__link-text-wrap">
<span className="reset-link-dialog__link-text">{resetLink}</span>
</div>
<Button
variant="outlined"
color="secondary"
size="sm"
onClick={handleCopyResetLink}
prefixIcon={
hasCopiedResetLink ? <Check size={12} /> : <Copy size={12} />
}
className="reset-link-dialog__copy-btn"
>
{hasCopiedResetLink ? 'Copied!' : 'Copy'}
</Button>
</div>
</div>
</DialogWrapper>
<DeleteMemberDialog
<DialogWrapper
open={showDeleteConfirm}
isInvited={isInvited}
member={member}
isDeleting={isDeleting}
onClose={(): void => setShowDeleteConfirm(false)}
onConfirm={handleDelete}
/>
onOpenChange={(isOpen): void => {
if (!isOpen) {
setShowDeleteConfirm(false);
}
}}
title={deleteDialogTitle}
width="narrow"
className="alert-dialog delete-dialog"
showCloseButton={false}
disableOutsideClick={false}
>
<p className="delete-dialog__body">{deleteDialogBody}</p>
<DialogFooter className="delete-dialog__footer">
<Button
variant="solid"
color="secondary"
size="sm"
onClick={(): void => setShowDeleteConfirm(false)}
>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="destructive"
size="sm"
disabled={isDeleting}
onClick={handleDelete}
>
<Trash2 size={12} />
{isDeleting ? 'Processing...' : deleteConfirmLabel}
</Button>
</DialogFooter>
</DialogWrapper>
</>
);
}

View File

@@ -1,61 +0,0 @@
import { Button } from '@signozhq/button';
import { DialogWrapper } from '@signozhq/dialog';
import { Check, Copy } from '@signozhq/icons';
interface ResetLinkDialogProps {
open: boolean;
linkType: 'invite' | 'reset' | null;
resetLink: string | null;
hasCopied: boolean;
onClose: () => void;
onCopy: () => void;
}
function ResetLinkDialog({
open,
linkType,
resetLink,
hasCopied,
onClose,
onCopy,
}: ResetLinkDialogProps): JSX.Element {
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title={linkType === 'invite' ? 'Invite Link' : 'Password Reset Link'}
showCloseButton
width="base"
className="reset-link-dialog"
>
<div className="reset-link-dialog__content">
<p className="reset-link-dialog__description">
{linkType === 'invite'
? 'Share this one-time link with the team member to complete their account setup.'
: 'This creates a one-time link the team member can use to set a new password for their SigNoz account.'}
</p>
<div className="reset-link-dialog__link-row">
<div className="reset-link-dialog__link-text-wrap">
<span className="reset-link-dialog__link-text">{resetLink}</span>
</div>
<Button
variant="outlined"
color="secondary"
size="sm"
onClick={onCopy}
prefixIcon={hasCopied ? <Check size={12} /> : <Copy size={12} />}
className="reset-link-dialog__copy-btn"
>
{hasCopied ? 'Copied!' : 'Copy'}
</Button>
</div>
</div>
</DialogWrapper>
);
}
export default ResetLinkDialog;

View File

@@ -4,18 +4,11 @@ import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
getResetPasswordToken,
useDeleteUser,
useGetUser,
useSetRoleByUserID,
useUpdateMyUserV2,
useUpdateUser,
useUpdateUserDeprecated,
} from 'api/generated/services/users';
import { MemberStatus } from 'container/MembersSettings/utils';
import {
listRolesSuccessResponse,
managedRoles,
} from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { ROLES } from 'types/roles';
import EditMemberDrawer, { EditMemberDrawerProps } from '../EditMemberDrawer';
@@ -51,10 +44,7 @@ jest.mock('@signozhq/dialog', () => ({
jest.mock('api/generated/services/users', () => ({
useDeleteUser: jest.fn(),
useGetUser: jest.fn(),
useUpdateUser: jest.fn(),
useUpdateMyUserV2: jest.fn(),
useSetRoleByUserID: jest.fn(),
useUpdateUserDeprecated: jest.fn(),
getResetPasswordToken: jest.fn(),
}));
@@ -79,63 +69,25 @@ jest.mock('react-use', () => ({
],
}));
const ROLES_ENDPOINT = '*/api/v1/roles';
const mockUpdateMutate = jest.fn();
const mockDeleteMutate = jest.fn();
const mockGetResetPasswordToken = jest.mocked(getResetPasswordToken);
const showErrorModal = jest.fn();
jest.mock('providers/ErrorModalProvider', () => ({
__esModule: true,
...jest.requireActual('providers/ErrorModalProvider'),
useErrorModal: jest.fn(() => ({
showErrorModal,
isErrorModalVisible: false,
})),
}));
const mockFetchedUser = {
data: {
id: 'user-1',
displayName: 'Alice Smith',
email: 'alice@signoz.io',
status: 'active',
userRoles: [
{
id: 'ur-1',
roleId: managedRoles[0].id,
role: managedRoles[0], // signoz-admin
},
],
},
};
const activeMember = {
id: 'user-1',
name: 'Alice Smith',
email: 'alice@signoz.io',
role: 'ADMIN' as ROLES,
status: MemberStatus.Active,
joinedOn: '1700000000000',
updatedAt: '1710000000000',
};
const selfMember = {
...activeMember,
id: 'some-user-id',
};
const rootMockFetchedUser = {
data: {
...mockFetchedUser.data,
id: 'root-user-1',
isRoot: true,
},
};
const invitedMember = {
id: 'abc123',
name: '',
email: 'bob@signoz.io',
role: 'VIEWER' as ROLES,
status: MemberStatus.Invited,
joinedOn: '1700000000000',
};
@@ -157,27 +109,8 @@ function renderDrawer(
describe('EditMemberDrawer', () => {
beforeEach(() => {
jest.clearAllMocks();
showErrorModal.mockClear();
server.use(
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
(useGetUser as jest.Mock).mockReturnValue({
data: mockFetchedUser,
isLoading: false,
refetch: jest.fn(),
});
(useUpdateUser as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue({}),
isLoading: false,
});
(useUpdateMyUserV2 as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue({}),
isLoading: false,
});
(useSetRoleByUserID as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue({}),
(useUpdateUserDeprecated as jest.Mock).mockReturnValue({
mutate: mockUpdateMutate,
isLoading: false,
});
(useDeleteUser as jest.Mock).mockReturnValue({
@@ -186,10 +119,6 @@ describe('EditMemberDrawer', () => {
});
});
afterEach(() => {
server.resetHandlers();
});
it('renders active member details and disables Save when form is not dirty', () => {
renderDrawer();
@@ -201,15 +130,16 @@ describe('EditMemberDrawer', () => {
).toBeDisabled();
});
it('enables Save after editing name and calls updateUser on confirm', async () => {
it('enables Save after editing name and calls update API on confirm', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockMutateAsync = jest.fn().mockResolvedValue({});
(useUpdateUser as jest.Mock).mockReturnValue({
mutateAsync: mockMutateAsync,
(useUpdateUserDeprecated as jest.Mock).mockImplementation((options) => ({
mutate: mockUpdateMutate.mockImplementation(() => {
options?.mutation?.onSuccess?.();
}),
isLoading: false,
});
}));
renderDrawer({ onComplete });
@@ -223,90 +153,12 @@ describe('EditMemberDrawer', () => {
await user.click(saveBtn);
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith({
pathParams: { id: 'user-1' },
data: { displayName: 'Alice Updated' },
});
expect(onComplete).toHaveBeenCalled();
});
});
it('does not close the drawer after a successful save', async () => {
const onClose = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderDrawer({ onClose });
const nameInput = screen.getByDisplayValue('Alice Smith');
await user.clear(nameInput);
await user.type(nameInput, 'Alice Updated');
const saveBtn = screen.getByRole('button', { name: /save member details/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await user.click(saveBtn);
await waitFor(() => {
expect(
screen.getByRole('button', { name: /save member details/i }),
).toBeInTheDocument();
});
expect(onClose).not.toHaveBeenCalled();
});
it('selecting a different role calls setRole with the new role name', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockSet = jest.fn().mockResolvedValue({});
(useSetRoleByUserID as jest.Mock).mockReturnValue({
mutateAsync: mockSet,
isLoading: false,
});
renderDrawer({ onComplete });
// Open the roles dropdown and select signoz-editor
await user.click(screen.getByLabelText('Roles'));
await user.click(await screen.findByTitle('signoz-editor'));
const saveBtn = screen.getByRole('button', { name: /save member details/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await user.click(saveBtn);
await waitFor(() => {
expect(mockSet).toHaveBeenCalledWith({
pathParams: { id: 'user-1' },
data: { name: 'signoz-editor' },
});
expect(onComplete).toHaveBeenCalled();
});
});
it('does not call removeRole when the role is changed', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockSet = jest.fn().mockResolvedValue({});
(useSetRoleByUserID as jest.Mock).mockReturnValue({
mutateAsync: mockSet,
isLoading: false,
});
renderDrawer({ onComplete });
// Switch from signoz-admin to signoz-viewer using single-select
await user.click(screen.getByLabelText('Roles'));
await user.click(await screen.findByTitle('signoz-viewer'));
const saveBtn = screen.getByRole('button', { name: /save member details/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await user.click(saveBtn);
await waitFor(() => {
expect(mockSet).toHaveBeenCalledWith({
pathParams: { id: 'user-1' },
data: { name: 'signoz-viewer' },
});
expect(mockUpdateMutate).toHaveBeenCalledWith(
expect.objectContaining({
pathParams: { id: 'user-1' },
data: expect.objectContaining({ displayName: 'Alice Updated' }),
}),
);
expect(onComplete).toHaveBeenCalled();
});
});
@@ -387,33 +239,16 @@ describe('EditMemberDrawer', () => {
});
});
it('calls updateUser when saving name change for an invited member', async () => {
it('calls update API when saving changes for an invited member', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockMutateAsync = jest.fn().mockResolvedValue({});
(useGetUser as jest.Mock).mockReturnValue({
data: {
data: {
...mockFetchedUser.data,
id: 'abc123',
displayName: 'Bob',
userRoles: [
{
id: 'ur-2',
roleId: managedRoles[2].id,
role: managedRoles[2], // signoz-viewer
},
],
},
},
(useUpdateUserDeprecated as jest.Mock).mockImplementation((options) => ({
mutate: mockUpdateMutate.mockImplementation(() => {
options?.mutation?.onSuccess?.();
}),
isLoading: false,
refetch: jest.fn(),
});
(useUpdateUser as jest.Mock).mockReturnValue({
mutateAsync: mockMutateAsync,
isLoading: false,
});
}));
renderDrawer({ member: { ...invitedMember, name: 'Bob' }, onComplete });
@@ -426,10 +261,12 @@ describe('EditMemberDrawer', () => {
await user.click(saveBtn);
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith({
pathParams: { id: 'abc123' },
data: { displayName: 'Bob Updated' },
});
expect(mockUpdateMutate).toHaveBeenCalledWith(
expect.objectContaining({
pathParams: { id: 'abc123' },
data: expect.objectContaining({ displayName: 'Bob Updated' }),
}),
);
expect(onComplete).toHaveBeenCalled();
});
});
@@ -443,13 +280,16 @@ describe('EditMemberDrawer', () => {
} as ReturnType<typeof convertToApiError>);
});
it('shows SaveErrorItem when updateUser fails for name change', async () => {
it('shows API error message when updateUser fails', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockToast = jest.mocked(toast);
(useUpdateUser as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockRejectedValue(new Error('server error')),
(useUpdateUserDeprecated as jest.Mock).mockImplementation((options) => ({
mutate: mockUpdateMutate.mockImplementation(() => {
options?.mutation?.onError?.({});
}),
isLoading: false,
});
}));
renderDrawer();
@@ -462,14 +302,16 @@ describe('EditMemberDrawer', () => {
await user.click(saveBtn);
await waitFor(() => {
expect(
screen.getByText('Name update: Something went wrong on server'),
).toBeInTheDocument();
expect(mockToast.error).toHaveBeenCalledWith(
'Failed to update member details: Something went wrong on server',
expect.anything(),
);
});
});
it('shows API error message when deleteUser fails for active member', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockToast = jest.mocked(toast);
(useDeleteUser as jest.Mock).mockImplementation((options) => ({
mutate: mockDeleteMutate.mockImplementation(() => {
@@ -487,20 +329,16 @@ describe('EditMemberDrawer', () => {
await user.click(confirmBtns[confirmBtns.length - 1]);
await waitFor(() => {
expect(showErrorModal).toHaveBeenCalledWith(
expect.objectContaining({
getErrorMessage: expect.any(Function),
}),
);
const passedError = showErrorModal.mock.calls[0][0] as any;
expect(passedError.getErrorMessage()).toBe(
'Something went wrong on server',
expect(mockToast.error).toHaveBeenCalledWith(
'Failed to delete member: Something went wrong on server',
expect.anything(),
);
});
});
it('shows API error message when deleteUser fails for invited member', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockToast = jest.mocked(toast);
(useDeleteUser as jest.Mock).mockImplementation((options) => ({
mutate: mockDeleteMutate.mockImplementation(() => {
@@ -518,109 +356,14 @@ describe('EditMemberDrawer', () => {
await user.click(confirmBtns[confirmBtns.length - 1]);
await waitFor(() => {
expect(showErrorModal).toHaveBeenCalledWith(
expect.objectContaining({
getErrorMessage: expect.any(Function),
}),
);
const passedError = showErrorModal.mock.calls[0][0] as any;
expect(passedError.getErrorMessage()).toBe(
'Something went wrong on server',
expect(mockToast.error).toHaveBeenCalledWith(
'Failed to revoke invite: Something went wrong on server',
expect.anything(),
);
});
});
});
describe('self user (isSelf)', () => {
it('disables Delete button when viewing own profile', () => {
renderDrawer({ member: selfMember });
expect(
screen.getByRole('button', { name: /delete member/i }),
).toBeDisabled();
});
it('does not open delete confirm dialog when Delete is clicked while disabled (isSelf)', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderDrawer({ member: selfMember });
await user.click(screen.getByRole('button', { name: /delete member/i }));
expect(
screen.queryByText(/are you sure you want to delete/i),
).not.toBeInTheDocument();
});
it('keeps name input enabled when viewing own profile', () => {
renderDrawer({ member: selfMember });
expect(screen.getByDisplayValue('Alice Smith')).not.toBeDisabled();
});
it('keeps Reset Link button enabled when viewing own profile', () => {
renderDrawer({ member: selfMember });
expect(
screen.getByRole('button', { name: /generate password reset link/i }),
).not.toBeDisabled();
});
});
describe('root user', () => {
beforeEach(() => {
(useGetUser as jest.Mock).mockReturnValue({
data: rootMockFetchedUser,
isLoading: false,
refetch: jest.fn(),
});
});
it('disables name input for root user', () => {
renderDrawer();
expect(screen.getByDisplayValue('Alice Smith')).toBeDisabled();
});
it('disables Delete button for root user', () => {
renderDrawer();
expect(
screen.getByRole('button', { name: /delete member/i }),
).toBeDisabled();
});
it('disables Reset Link button for root user', () => {
renderDrawer();
expect(
screen.getByRole('button', { name: /generate password reset link/i }),
).toBeDisabled();
});
it('disables Save button for root user', () => {
renderDrawer();
expect(
screen.getByRole('button', { name: /save member details/i }),
).toBeDisabled();
});
it('does not open delete confirm dialog when Delete is clicked while disabled (root)', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderDrawer();
await user.click(screen.getByRole('button', { name: /delete member/i }));
expect(
screen.queryByText(/are you sure you want to delete/i),
).not.toBeInTheDocument();
});
it('does not call getResetPasswordToken when Reset Link is clicked while disabled (root)', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderDrawer();
await user.click(
screen.getByRole('button', { name: /generate password reset link/i }),
);
expect(mockGetResetPasswordToken).not.toHaveBeenCalled();
});
});
describe('Generate Password Reset Link', () => {
beforeEach(() => {
mockCopyToClipboard.mockClear();

View File

@@ -10,12 +10,7 @@ import APIError from 'types/api/error';
import './ErrorContent.styles.scss';
interface ErrorContentProps {
error:
| APIError
| {
code: number;
message: string;
};
error: APIError;
icon?: ReactNode;
}
@@ -25,15 +20,7 @@ function ErrorContent({ error, icon }: ErrorContentProps): JSX.Element {
errors: errorMessages,
code: errorCode,
message: errorMessage,
} =
error && 'error' in error
? error?.error?.error || {}
: {
url: undefined,
errors: [],
code: error.code || 500,
message: error.message || 'Something went wrong',
};
} = error?.error?.error || {};
return (
<section className="error-content">
{/* Summary Header */}

View File

@@ -1,52 +0,0 @@
import { useEffect } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { refreshIntervalOptions } from 'container/TopNav/AutoRefreshV2/constants';
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import { useGlobalTimeStore } from 'store/globalTime/globalTimeStore';
import { createCustomTimeRange } from 'store/globalTime/utils';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
/**
* Adapter component that syncs Redux global time state to Zustand store.
* This component should be rendered once at the app level.
*
* It reads from the Redux globalTime reducer and updates the Zustand store
* to provide a migration path from Redux to Zustand.
*/
export function GlobalTimeStoreAdapter(): null {
const globalTime = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const setSelectedTime = useGlobalTimeStore((s) => s.setSelectedTime);
useEffect(() => {
// Convert the selectedTime to the new format
// If it's 'custom', store the min/max times in the custom format
const selectedTime =
globalTime.selectedTime === 'custom'
? createCustomTimeRange(globalTime.minTime, globalTime.maxTime)
: (globalTime.selectedTime as Time);
// Find refresh interval from Redux state
const refreshOption = refreshIntervalOptions.find(
(option) => option.key === globalTime.selectedAutoRefreshInterval,
);
const refreshInterval =
!globalTime.isAutoRefreshDisabled && refreshOption ? refreshOption.value : 0;
setSelectedTime(selectedTime, refreshInterval);
}, [
globalTime.selectedTime,
globalTime.isAutoRefreshDisabled,
globalTime.selectedAutoRefreshInterval,
globalTime.minTime,
globalTime.maxTime,
setSelectedTime,
]);
return null;
}

View File

@@ -1,227 +0,0 @@
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { act, render, renderHook } from '@testing-library/react';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import configureStore, { MockStoreEnhanced } from 'redux-mock-store';
import { useGlobalTimeStore } from 'store/globalTime/globalTimeStore';
import { createCustomTimeRange } from 'store/globalTime/utils';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { GlobalTimeStoreAdapter } from '../GlobalTimeStoreAdapter';
const mockStore = configureStore<Partial<AppState>>([]);
const randomTime = 1700000000000000000;
describe('GlobalTimeStoreAdapter', () => {
let store: MockStoreEnhanced<Partial<AppState>>;
const createGlobalTimeState = (
overrides: Partial<GlobalReducer> = {},
): GlobalReducer => ({
minTime: randomTime,
maxTime: randomTime,
loading: false,
selectedTime: '15m',
isAutoRefreshDisabled: true,
selectedAutoRefreshInterval: 'off',
...overrides,
});
beforeEach(() => {
// Reset Zustand store before each test
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime(DEFAULT_TIME_RANGE, 0);
});
});
it('should render null because it just an adapter', () => {
store = mockStore({
globalTime: createGlobalTimeState(),
});
const { container } = render(
<Provider store={store}>
<GlobalTimeStoreAdapter />
</Provider>,
);
expect(container.firstChild).toBeNull();
});
it('should sync relative time from Redux to Zustand store', () => {
store = mockStore({
globalTime: createGlobalTimeState({
selectedTime: '15m',
isAutoRefreshDisabled: true,
selectedAutoRefreshInterval: 'off',
}),
});
render(
<Provider store={store}>
<GlobalTimeStoreAdapter />
</Provider>,
);
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.selectedTime).toBe('15m');
expect(result.current.refreshInterval).toBe(0);
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should sync custom time from Redux to Zustand store', () => {
store = mockStore({
globalTime: createGlobalTimeState({
selectedTime: 'custom',
minTime: randomTime,
maxTime: randomTime,
isAutoRefreshDisabled: true,
}),
});
render(
<Provider store={store}>
<GlobalTimeStoreAdapter />
</Provider>,
);
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.selectedTime).toBe(
createCustomTimeRange(randomTime, randomTime),
);
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should sync refresh interval when auto refresh is enabled', () => {
store = mockStore({
globalTime: createGlobalTimeState({
selectedTime: '15m',
isAutoRefreshDisabled: false,
selectedAutoRefreshInterval: '5s',
}),
});
render(
<Provider store={store}>
<GlobalTimeStoreAdapter />
</Provider>,
);
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.selectedTime).toBe('15m');
expect(result.current.refreshInterval).toBe(5000); // 5s = 5000ms
expect(result.current.isRefreshEnabled).toBe(true);
});
it('should set refreshInterval to 0 when auto refresh is disabled', () => {
store = mockStore({
globalTime: createGlobalTimeState({
selectedTime: '15m',
isAutoRefreshDisabled: true,
selectedAutoRefreshInterval: '5s', // Even with interval set, should be 0 when disabled
}),
});
render(
<Provider store={store}>
<GlobalTimeStoreAdapter />
</Provider>,
);
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.refreshInterval).toBe(0);
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should update Zustand store when Redux state changes', () => {
store = mockStore({
globalTime: createGlobalTimeState({
selectedTime: '15m',
isAutoRefreshDisabled: true,
}),
});
const { rerender } = render(
<Provider store={store}>
<GlobalTimeStoreAdapter />
</Provider>,
);
// Verify initial state
let zustandState = renderHook(() => useGlobalTimeStore());
expect(zustandState.result.current.selectedTime).toBe('15m');
// Update Redux store
const newStore = mockStore({
globalTime: createGlobalTimeState({
selectedTime: '1h',
isAutoRefreshDisabled: false,
selectedAutoRefreshInterval: '30s',
}),
});
rerender(
<Provider store={newStore}>
<GlobalTimeStoreAdapter />
</Provider>,
);
// Verify updated state
zustandState = renderHook(() => useGlobalTimeStore());
expect(zustandState.result.current.selectedTime).toBe('1h');
expect(zustandState.result.current.refreshInterval).toBe(30000); // 30s = 30000ms
expect(zustandState.result.current.isRefreshEnabled).toBe(true);
});
it('should handle various refresh interval options', () => {
const testCases = [
{ key: '5s', expectedValue: 5000 },
{ key: '10s', expectedValue: 10000 },
{ key: '30s', expectedValue: 30000 },
{ key: '1m', expectedValue: 60000 },
{ key: '5m', expectedValue: 300000 },
];
testCases.forEach(({ key, expectedValue }) => {
store = mockStore({
globalTime: createGlobalTimeState({
selectedTime: '15m',
isAutoRefreshDisabled: false,
selectedAutoRefreshInterval: key,
}),
});
render(
<Provider store={store}>
<GlobalTimeStoreAdapter />
</Provider>,
);
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.refreshInterval).toBe(expectedValue);
});
});
it('should handle unknown refresh interval by setting 0', () => {
store = mockStore({
globalTime: createGlobalTimeState({
selectedTime: '15m',
isAutoRefreshDisabled: false,
selectedAutoRefreshInterval: 'unknown-interval',
}),
});
render(
<Provider store={store}>
<GlobalTimeStoreAdapter />
</Provider>,
);
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.refreshInterval).toBe(0);
expect(result.current.isRefreshEnabled).toBe(false);
});
});

View File

@@ -3,8 +3,8 @@ import { useLocation } from 'react-router-dom';
import { toast } from '@signozhq/sonner';
import { Button, Input, Radio, RadioChangeEvent, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import { handleContactSupport } from 'container/Integrations/utils';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { handleContactSupport } from 'pages/Integrations/utils';
function FeedbackModal({ onClose }: { onClose: () => void }): JSX.Element {
const [activeTab, setActiveTab] = useState('feedback');

View File

@@ -4,8 +4,8 @@ import { toast } from '@signozhq/sonner';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import logEvent from 'api/common/logEvent';
import { handleContactSupport } from 'container/Integrations/utils';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { handleContactSupport } from 'pages/Integrations/utils';
import FeedbackModal from '../FeedbackModal';
@@ -30,7 +30,7 @@ jest.mock('hooks/useGetTenantLicense', () => ({
useGetTenantLicense: jest.fn(),
}));
jest.mock('container/Integrations/utils', () => ({
jest.mock('pages/Integrations/utils', () => ({
handleContactSupport: jest.fn(),
}));

View File

@@ -10,7 +10,6 @@ import { Select } from 'antd';
import inviteUsers from 'api/v1/invite/bulk/create';
import sendInvite from 'api/v1/invite/create';
import { cloneDeep, debounce } from 'lodash-es';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { ROLES } from 'types/roles';
import { EMAIL_REGEX } from 'utils/app';
@@ -41,8 +40,6 @@ function InviteMembersModal({
onClose,
onComplete,
}: InviteMembersModalProps): JSX.Element {
const { showErrorModal, isErrorModalVisible } = useErrorModal();
const [rows, setRows] = useState<InviteRow[]>(() => [
EMPTY_ROW(),
EMPTY_ROW(),
@@ -200,18 +197,17 @@ function InviteMembersModal({
})),
});
}
toast.success('Invites sent successfully', {
richColors: true,
position: 'top-right',
});
toast.success('Invites sent successfully', { richColors: true });
resetAndClose();
onComplete?.();
} catch (err) {
showErrorModal(err as APIError);
const apiErr = err as APIError;
const errorMessage = apiErr?.getErrorMessage?.() ?? 'An error occurred';
toast.error(errorMessage, { richColors: true });
} finally {
setIsSubmitting(false);
}
}, [validateAllUsers, rows, resetAndClose, onComplete, showErrorModal]);
}, [rows, onComplete, resetAndClose, validateAllUsers]);
const touchedRows = rows.filter(isRowTouched);
const isSubmitDisabled = isSubmitting || touchedRows.length === 0;
@@ -228,7 +224,7 @@ function InviteMembersModal({
showCloseButton
width="wide"
className="invite-members-modal"
disableOutsideClick={isErrorModalVisible}
disableOutsideClick={false}
>
<div className="invite-members-modal__content">
<div className="invite-members-modal__table">
@@ -330,7 +326,6 @@ function InviteMembersModal({
size="sm"
onClick={handleSubmit}
disabled={isSubmitDisabled}
loading={isSubmitting}
>
{isSubmitting ? 'Inviting...' : 'Invite Team Members'}
</Button>

View File

@@ -1,3 +1,4 @@
import { toast } from '@signozhq/sonner';
import inviteUsers from 'api/v1/invite/bulk/create';
import sendInvite from 'api/v1/invite/create';
import { StatusCodes } from 'http-status-codes';
@@ -21,16 +22,6 @@ jest.mock('@signozhq/sonner', () => ({
},
}));
const showErrorModal = jest.fn();
jest.mock('providers/ErrorModalProvider', () => ({
__esModule: true,
...jest.requireActual('providers/ErrorModalProvider'),
useErrorModal: jest.fn(() => ({
showErrorModal,
isErrorModalVisible: false,
})),
}));
const mockSendInvite = jest.mocked(sendInvite);
const mockInviteUsers = jest.mocked(inviteUsers);
@@ -43,7 +34,6 @@ const defaultProps = {
describe('InviteMembersModal', () => {
beforeEach(() => {
jest.clearAllMocks();
showErrorModal.mockClear();
mockSendInvite.mockResolvedValue({
httpStatusCode: 200,
data: { data: 'test', status: 'success' },
@@ -164,10 +154,9 @@ describe('InviteMembersModal', () => {
describe('error handling', () => {
it('shows BE message on single invite 409', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const error = makeApiError(
'An invite already exists for this email: single@signoz.io',
mockSendInvite.mockRejectedValue(
makeApiError('An invite already exists for this email: single@signoz.io'),
);
mockSendInvite.mockRejectedValue(error);
render(<InviteMembersModal {...defaultProps} />);
@@ -182,16 +171,18 @@ describe('InviteMembersModal', () => {
);
await waitFor(() => {
expect(showErrorModal).toHaveBeenCalledWith(error);
expect(toast.error).toHaveBeenCalledWith(
'An invite already exists for this email: single@signoz.io',
expect.anything(),
);
});
});
it('shows BE message on bulk invite 409', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const error = makeApiError(
'An invite already exists for this email: alice@signoz.io',
mockInviteUsers.mockRejectedValue(
makeApiError('An invite already exists for this email: alice@signoz.io'),
);
mockInviteUsers.mockRejectedValue(error);
render(<InviteMembersModal {...defaultProps} />);
@@ -210,17 +201,18 @@ describe('InviteMembersModal', () => {
);
await waitFor(() => {
expect(showErrorModal).toHaveBeenCalledWith(error);
expect(toast.error).toHaveBeenCalledWith(
'An invite already exists for this email: alice@signoz.io',
expect.anything(),
);
});
});
it('shows BE message on generic error', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const error = makeApiError(
'Internal server error',
StatusCodes.INTERNAL_SERVER_ERROR,
mockSendInvite.mockRejectedValue(
makeApiError('Internal server error', StatusCodes.INTERNAL_SERVER_ERROR),
);
mockSendInvite.mockRejectedValue(error);
render(<InviteMembersModal {...defaultProps} />);
@@ -235,7 +227,10 @@ describe('InviteMembersModal', () => {
);
await waitFor(() => {
expect(showErrorModal).toHaveBeenCalledWith(error);
expect(toast.error).toHaveBeenCalledWith(
'Internal server error',
expect.anything(),
);
});
});
});

View File

@@ -28,22 +28,14 @@
}
}
// 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);
background: var(--bg-vanilla-400);
.ant-btn-default {
}
.copy-log-btn {
border-left: 1px solid var(--bg-vanilla-400);
border-color: var(--bg-vanilla-400) !important;

View File

@@ -15,12 +15,13 @@ export function getDefaultCellStyle(isDarkMode?: boolean): CSSProperties {
letterSpacing: '-0.07px',
marginBottom: '0px',
minWidth: '10rem',
width: 'auto',
width: '10rem',
};
}
export const defaultTableStyle: CSSProperties = {
minWidth: '40rem',
maxWidth: '90rem',
};
export const defaultListViewPanelStyle: CSSProperties = {

View File

@@ -33,7 +33,7 @@
display: flex;
align-items: center;
p {
.ant-typography {
margin-bottom: 0;
}
}
@@ -45,7 +45,6 @@
}
.paragraph {
margin: 0;
padding: 0px !important;
&.small {
font-size: 11px !important;

View File

@@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { TableColumnsType as ColumnsType } from 'antd';
import { TableColumnsType as ColumnsType, Typography } from 'antd';
import cx from 'classnames';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
@@ -43,7 +43,7 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
const bodyColumnStyle = useMemo(
() => ({
...defaultTableStyle,
...(fields.length > 2 ? { width: 'auto' } : {}),
...(fields.length > 2 ? { width: '50rem' } : {}),
}),
[fields.length],
);
@@ -59,18 +59,18 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
key: name,
render: (field): ColumnTypeRender<Record<string, unknown>> => ({
props: {
style: {
...(isListViewPanel
? defaultListViewPanelStyle
: getDefaultCellStyle(isDarkMode)),
display: '-webkit-box',
WebkitLineClamp: linesPerRow,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
wordBreak: 'break-all',
},
style: isListViewPanel
? defaultListViewPanelStyle
: getDefaultCellStyle(isDarkMode),
},
children: <p className={cx('paragraph', fontSize)}>{field}</p>,
children: (
<Typography.Paragraph
ellipsis={{ rows: linesPerRow }}
className={cx('paragraph', fontSize)}
>
{field}
</Typography.Paragraph>
),
}),
}));
@@ -123,7 +123,9 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
return {
children: (
<div className="table-timestamp">
<p className={cx('text', fontSize)}>{date}</p>
<Typography.Paragraph ellipsis className={cx('text', fontSize)}>
{date}
</Typography.Paragraph>
</div>
),
};

View File

@@ -210,7 +210,7 @@ function MembersTable({
index % 2 === 0 ? 'members-table-row--tinted' : ''
}
onRow={(record): React.HTMLAttributes<HTMLElement> => {
const isClickable = !!onRowClick;
const isClickable = onRowClick && record.status !== MemberStatus.Deleted;
return {
onClick: (): void => {
if (isClickable) {

View File

@@ -1,5 +1,6 @@
import { MemberStatus } from 'container/MembersSettings/utils';
import { render, screen, userEvent } from 'tests/test-utils';
import { ROLES } from 'types/roles';
import MembersTable, { MemberRow } from '../MembersTable';
@@ -8,6 +9,7 @@ const mockActiveMembers: MemberRow[] = [
id: 'user-1',
name: 'Alice Smith',
email: 'alice@signoz.io',
role: 'ADMIN' as ROLES,
status: MemberStatus.Active,
joinedOn: '1700000000000',
},
@@ -15,6 +17,7 @@ const mockActiveMembers: MemberRow[] = [
id: 'user-2',
name: 'Bob Jones',
email: 'bob@signoz.io',
role: 'VIEWER' as ROLES,
status: MemberStatus.Active,
joinedOn: null,
},
@@ -24,6 +27,7 @@ const mockInvitedMember: MemberRow = {
id: 'inv-abc',
name: '',
email: 'charlie@signoz.io',
role: 'EDITOR' as ROLES,
status: MemberStatus.Invited,
joinedOn: null,
};
@@ -43,11 +47,12 @@ describe('MembersTable', () => {
jest.clearAllMocks();
});
it('renders member rows with name, email, and ACTIVE status', () => {
it('renders member rows with name, email, role badge, and ACTIVE status', () => {
render(<MembersTable {...defaultProps} data={mockActiveMembers} />);
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
expect(screen.getByText('alice@signoz.io')).toBeInTheDocument();
expect(screen.getByText('Admin')).toBeInTheDocument();
expect(screen.getAllByText('ACTIVE')).toHaveLength(2);
});
@@ -62,6 +67,7 @@ describe('MembersTable', () => {
expect(screen.getByText('INVITED')).toBeInTheDocument();
expect(screen.getByText('charlie@signoz.io')).toBeInTheDocument();
expect(screen.getByText('Editor')).toBeInTheDocument();
});
it('calls onRowClick with the member data when a row is clicked', async () => {
@@ -86,13 +92,14 @@ describe('MembersTable', () => {
);
});
it('renders DELETED badge and calls onRowClick when a deleted member row is clicked', async () => {
it('renders DELETED badge and does not call onRowClick when a deleted member row is clicked', async () => {
const onRowClick = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const deletedMember: MemberRow = {
id: 'user-del',
name: 'Dave Deleted',
email: 'dave@signoz.io',
role: 'VIEWER' as ROLES,
status: MemberStatus.Deleted,
joinedOn: null,
};
@@ -108,7 +115,7 @@ describe('MembersTable', () => {
expect(screen.getByText('DELETED')).toBeInTheDocument();
await user.click(screen.getByText('Dave Deleted'));
expect(onRowClick).toHaveBeenCalledWith(
expect(onRowClick).not.toHaveBeenCalledWith(
expect.objectContaining({ id: 'user-del' }),
);
});

View File

@@ -1,35 +1,21 @@
.quick-filters-container {
display: flex;
flex-direction: row;
height: 100%;
min-height: 0;
position: relative;
.quick-filters-settings-container {
flex: 0 0 0;
width: 0;
min-width: 0;
overflow: visible;
position: relative;
align-self: stretch;
}
}
.quick-filters {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-width: 0;
height: 100%;
min-height: 0;
width: 100%;
border-right: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
.overlay-scrollbar {
flex: 1;
min-height: 0;
}
.header {
display: flex;
align-items: center;

View File

@@ -2,8 +2,6 @@
display: flex;
flex-direction: column;
position: absolute;
top: 0;
left: 0;
z-index: 999;
width: 342px;
background: var(--bg-slate-500);

View File

@@ -88,13 +88,3 @@
color: var(--destructive);
}
}
.roles-single-select {
.ant-select-selector {
min-height: 32px;
background-color: var(--l2-background) !important;
border: 1px solid var(--border) !important;
border-radius: 2px;
padding: 2px var(--padding-2) !important;
}
}

View File

@@ -85,8 +85,7 @@ interface BaseProps {
interface SingleProps extends BaseProps {
mode?: 'single';
value?: string;
onChange?: (role: string | undefined) => void;
allowClear?: boolean;
onChange?: (role: string) => void;
}
interface MultipleProps extends BaseProps {
@@ -155,15 +154,14 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
);
}
const { value, onChange, allowClear = true } = props as SingleProps;
const { value, onChange } = props as SingleProps;
return (
<Select
id={id}
value={value || undefined}
value={value}
onChange={onChange}
placeholder={placeholder}
allowClear={allowClear}
className={cx('roles-single-select', className)}
className={cx('roles-select', className)}
loading={loading}
notFoundContent={notFoundContent}
options={options}

View File

@@ -17,8 +17,6 @@ import { AxiosError } from 'axios';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import KeyCreatedPhase from './KeyCreatedPhase';
import KeyFormPhase from './KeyFormPhase';
@@ -29,7 +27,6 @@ import './AddKeyModal.styles.scss';
function AddKeyModal(): JSX.Element {
const queryClient = useQueryClient();
const { showErrorModal, isErrorModalVisible } = useErrorModal();
const [accountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
const [isAddKeyOpen, setIsAddKeyOpen] = useQueryState(
SA_QUERY_PARAMS.ADD_KEY,
@@ -84,11 +81,11 @@ function AddKeyModal(): JSX.Element {
}
},
onError: (error) => {
showErrorModal(
const errMessage =
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
) as APIError,
);
)?.getErrorMessage() || 'Failed to create key';
toast.error(errMessage, { richColors: true });
},
},
});
@@ -154,7 +151,7 @@ function AddKeyModal(): JSX.Element {
width="base"
className="add-key-modal"
showCloseButton
disableOutsideClick={isErrorModalVisible}
disableOutsideClick={false}
>
{phase === Phase.FORM && (
<KeyFormPhase

View File

@@ -16,12 +16,9 @@ import type {
import { AxiosError } from 'axios';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
function DeleteAccountModal(): JSX.Element {
const queryClient = useQueryClient();
const { showErrorModal, isErrorModalVisible } = useErrorModal();
const [accountId, setAccountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
const [isDeleteOpen, setIsDeleteOpen] = useQueryState(
SA_QUERY_PARAMS.DELETE_SA,
@@ -48,11 +45,11 @@ function DeleteAccountModal(): JSX.Element {
await invalidateListServiceAccounts(queryClient);
},
onError: (error) => {
showErrorModal(
const errMessage =
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
) as APIError,
);
)?.getErrorMessage() || 'Failed to delete service account';
toast.error(errMessage, { richColors: true });
},
},
});
@@ -82,7 +79,7 @@ function DeleteAccountModal(): JSX.Element {
width="narrow"
className="alert-dialog sa-delete-dialog"
showCloseButton={false}
disableOutsideClick={isErrorModalVisible}
disableOutsideClick={false}
>
<p className="sa-delete-dialog__body">
Are you sure you want to delete <strong>{accountName}</strong>? This action

View File

@@ -17,9 +17,7 @@ import { AxiosError } from 'axios';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import dayjs from 'dayjs';
import { parseAsString, useQueryState } from 'nuqs';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useTimezone } from 'providers/Timezone';
import APIError from 'types/api/error';
import { RevokeKeyContent } from '../RevokeKeyModal';
import EditKeyForm from './EditKeyForm';
@@ -43,7 +41,6 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
const open = !!editKeyId && !!selectedAccountId;
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const { showErrorModal, isErrorModalVisible } = useErrorModal();
const [isRevokeConfirmOpen, setIsRevokeConfirmOpen] = useState(false);
const {
@@ -81,11 +78,11 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
}
},
onError: (error) => {
showErrorModal(
const errMessage =
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
) as APIError,
);
)?.getErrorMessage() || 'Failed to update key';
toast.error(errMessage, { richColors: true });
},
},
});
@@ -105,13 +102,12 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
});
}
},
// eslint-disable-next-line sonarjs/no-identical-functions
onError: (error) => {
showErrorModal(
const errMessage =
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
) as APIError,
);
)?.getErrorMessage() || 'Failed to revoke key';
toast.error(errMessage, { richColors: true });
},
},
});
@@ -164,7 +160,7 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
isRevokeConfirmOpen ? 'alert-dialog delete-dialog' : 'edit-key-modal'
}
showCloseButton={!isRevokeConfirmOpen}
disableOutsideClick={isErrorModalVisible}
disableOutsideClick={false}
>
{isRevokeConfirmOpen ? (
<RevokeKeyContent

View File

@@ -165,17 +165,7 @@ function KeysTab({
return (
<div className="keys-tab__empty">
<KeyRound size={24} className="keys-tab__empty-icon" />
<p className="keys-tab__empty-text">
No keys. Start by creating one.{' '}
<a
href="https://signoz.io/docs/manage/administrator-guide/iam/service-accounts/#step-3-generate-an-api-key"
target="_blank"
rel="noopener noreferrer"
className="keys-tab__learn-more"
>
Learn more
</a>
</p>
<p className="keys-tab__empty-text">No keys. Start by creating one.</p>
<Button
type="button"
className="keys-tab__learn-more"

View File

@@ -16,8 +16,8 @@ interface OverviewTabProps {
account: ServiceAccountRow;
localName: string;
onNameChange: (v: string) => void;
localRole: string;
onRoleChange: (v: string | undefined) => void;
localRoles: string[];
onRolesChange: (v: string[]) => void;
isDisabled: boolean;
availableRoles: AuthtypesRoleDTO[];
rolesLoading?: boolean;
@@ -31,8 +31,8 @@ function OverviewTab({
account,
localName,
onNameChange,
localRole,
onRoleChange,
localRoles,
onRolesChange,
isDisabled,
availableRoles,
rolesLoading,
@@ -96,10 +96,15 @@ function OverviewTab({
{isDisabled ? (
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
<div className="sa-drawer__disabled-roles">
{localRole ? (
<Badge color="vanilla">
{availableRoles.find((r) => r.id === localRole)?.name ?? localRole}
</Badge>
{localRoles.length > 0 ? (
localRoles.map((roleId) => {
const role = availableRoles.find((r) => r.id === roleId);
return (
<Badge key={roleId} color="vanilla">
{role?.name ?? roleId}
</Badge>
);
})
) : (
<span className="sa-drawer__input-text"></span>
)}
@@ -109,14 +114,15 @@ function OverviewTab({
) : (
<RolesSelect
id="sa-roles"
mode="multiple"
roles={availableRoles}
loading={rolesLoading}
isError={rolesError}
error={rolesErrorObj}
onRefetch={onRefetchRoles}
value={localRole}
onChange={onRoleChange}
placeholder="Select role"
value={localRoles}
onChange={onRolesChange}
placeholder="Select roles"
/>
)}
</div>

View File

@@ -16,8 +16,6 @@ import type {
import { AxiosError } from 'axios';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import { parseAsString, useQueryState } from 'nuqs';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
export interface RevokeKeyContentProps {
isRevoking: boolean;
@@ -58,7 +56,6 @@ export function RevokeKeyContent({
function RevokeKeyModal(): JSX.Element {
const queryClient = useQueryClient();
const { showErrorModal, isErrorModalVisible } = useErrorModal();
const [accountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
const [revokeKeyId, setRevokeKeyId] = useQueryState(
SA_QUERY_PARAMS.REVOKE_KEY,
@@ -86,11 +83,11 @@ function RevokeKeyModal(): JSX.Element {
}
},
onError: (error) => {
showErrorModal(
const errMessage =
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
) as APIError,
);
)?.getErrorMessage() || 'Failed to revoke key';
toast.error(errMessage, { richColors: true });
},
},
});
@@ -118,7 +115,7 @@ function RevokeKeyModal(): JSX.Element {
width="narrow"
className="alert-dialog delete-dialog"
showCloseButton={false}
disableOutsideClick={isErrorModalVisible}
disableOutsideClick={false}
>
<RevokeKeyContent
isRevoking={isRevoking}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQueryClient } from 'react-query';
import { Button } from '@signozhq/button';
import { DrawerWrapper } from '@signozhq/drawer';
@@ -8,9 +8,7 @@ import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
import { Pagination, Skeleton } from 'antd';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
getGetServiceAccountRolesQueryKey,
getListServiceAccountsQueryKey,
useDeleteServiceAccountRole,
useGetServiceAccount,
useListServiceAccountKeys,
useUpdateServiceAccount,
@@ -25,10 +23,7 @@ import {
ServiceAccountStatus,
toServiceAccountRow,
} from 'container/ServiceAccountsSettings/utils';
import {
RoleUpdateFailure,
useServiceAccountRoleManager,
} from 'hooks/serviceAccount/useServiceAccountRoleManager';
import { useServiceAccountRoleManager } from 'hooks/serviceAccount/useServiceAccountRoleManager';
import {
parseAsBoolean,
parseAsInteger,
@@ -37,7 +32,7 @@ import {
useQueryState,
} from 'nuqs';
import APIError from 'types/api/error';
import { retryOn429, toAPIError } from 'utils/errorUtils';
import { toAPIError } from 'utils/errorUtils';
import AddKeyModal from './AddKeyModal';
import DeleteAccountModal from './DeleteAccountModal';
@@ -54,13 +49,6 @@ export interface ServiceAccountDrawerProps {
const PAGE_SIZE = 15;
function toSaveApiError(err: unknown): APIError {
return (
convertToApiError(err as AxiosError<RenderErrorResponseDTO>) ??
toAPIError(err as AxiosError<RenderErrorResponseDTO>)
);
}
// eslint-disable-next-line sonarjs/cognitive-complexity
function ServiceAccountDrawer({
onSuccess,
@@ -92,7 +80,7 @@ function ServiceAccountDrawer({
parseAsBoolean.withDefault(false),
);
const [localName, setLocalName] = useState('');
const [localRole, setLocalRole] = useState('');
const [localRoles, setLocalRoles] = useState<string[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [saveErrors, setSaveErrors] = useState<SaveError[]>([]);
@@ -115,35 +103,21 @@ function ServiceAccountDrawer({
[accountData],
);
const {
currentRoles,
isLoading: isRolesLoading,
applyDiff,
} = useServiceAccountRoleManager(selectedAccountId ?? '');
const roleSessionRef = useRef<string | null>(null);
const { currentRoles, applyDiff } = useServiceAccountRoleManager(
selectedAccountId ?? '',
);
useEffect(() => {
if (account?.id) {
setLocalName(account?.name ?? '');
setKeysPage(1);
}
setSaveErrors([]);
}, [account?.id, account?.name, setKeysPage]);
useEffect(() => {
if (account?.id) {
setSaveErrors([]);
}
}, [account?.id]);
useEffect(() => {
if (!account?.id) {
roleSessionRef.current = null;
} else if (account.id !== roleSessionRef.current && !isRolesLoading) {
setLocalRole(currentRoles[0]?.id ?? '');
roleSessionRef.current = account.id;
}
}, [account?.id, currentRoles, isRolesLoading]);
setLocalRoles(currentRoles.map((r) => r.id).filter(Boolean) as string[]);
}, [currentRoles]);
const isDeleted =
account?.status?.toUpperCase() === ServiceAccountStatus.Deleted;
@@ -151,7 +125,8 @@ function ServiceAccountDrawer({
const isDirty =
account !== null &&
(localName !== (account.name ?? '') ||
localRole !== (currentRoles[0]?.id ?? ''));
JSON.stringify([...localRoles].sort()) !==
JSON.stringify([...currentRoles.map((r) => r.id).filter(Boolean)].sort()));
const {
roles: availableRoles,
@@ -179,26 +154,12 @@ function ServiceAccountDrawer({
// the retry for this mutation is safe due to the api being idempotent on backend
const { mutateAsync: updateMutateAsync } = useUpdateServiceAccount();
const { mutateAsync: deleteRole } = useDeleteServiceAccountRole({
mutation: {
retry: retryOn429,
},
});
const executeRolesOperation = useCallback(
async (accountId: string): Promise<RoleUpdateFailure[]> => {
if (localRole === '' && currentRoles[0]?.id) {
await deleteRole({
pathParams: { id: accountId, rid: currentRoles[0].id },
});
await queryClient.invalidateQueries(
getGetServiceAccountRolesQueryKey({ id: accountId }),
);
return [];
}
return applyDiff([localRole].filter(Boolean), availableRoles);
},
[localRole, currentRoles, availableRoles, applyDiff, deleteRole, queryClient],
const toSaveApiError = useCallback(
(err: unknown): APIError =>
convertToApiError(err as AxiosError<RenderErrorResponseDTO>) ??
toAPIError(err as AxiosError<RenderErrorResponseDTO>),
[],
);
const retryNameUpdate = useCallback(async (): Promise<void> => {
@@ -220,7 +181,14 @@ function ServiceAccountDrawer({
),
);
}
}, [account, localName, updateMutateAsync, refetchAccount, queryClient]);
}, [
account,
localName,
updateMutateAsync,
refetchAccount,
queryClient,
toSaveApiError,
]);
const handleNameChange = useCallback((name: string): void => {
setLocalName(name);
@@ -243,39 +211,26 @@ function ServiceAccountDrawer({
);
}
},
[],
);
const clearRoleErrors = useCallback((): void => {
setSaveErrors((prev) =>
prev.filter(
(e) => e.context !== 'Roles update' && !e.context.startsWith("Role '"),
),
);
}, []);
const failuresToSaveErrors = useCallback(
(failures: RoleUpdateFailure[]): SaveError[] =>
failures.map((f) => {
const ctx = `Role '${f.roleName}'`;
return {
context: ctx,
apiError: toSaveApiError(f.error),
onRetry: makeRoleRetry(ctx, f.onRetry),
};
}),
[makeRoleRetry],
[toSaveApiError],
);
const retryRolesUpdate = useCallback(async (): Promise<void> => {
try {
const failures = await executeRolesOperation(selectedAccountId ?? '');
const failures = await applyDiff(localRoles, availableRoles);
if (failures.length === 0) {
setSaveErrors((prev) => prev.filter((e) => e.context !== 'Roles update'));
} else {
setSaveErrors((prev) => {
const rest = prev.filter((e) => e.context !== 'Roles update');
return [...rest, ...failuresToSaveErrors(failures)];
const roleErrors = failures.map((f) => {
const ctx = `Role '${f.roleName}'`;
return {
context: ctx,
apiError: toSaveApiError(f.error),
onRetry: makeRoleRetry(ctx, f.onRetry),
};
});
return [...rest, ...roleErrors];
});
}
} catch (err) {
@@ -285,7 +240,7 @@ function ServiceAccountDrawer({
),
);
}
}, [selectedAccountId, executeRolesOperation, failuresToSaveErrors]);
}, [localRoles, availableRoles, applyDiff, toSaveApiError, makeRoleRetry]);
const handleSave = useCallback(async (): Promise<void> => {
if (!account || !isDirty) {
@@ -304,7 +259,7 @@ function ServiceAccountDrawer({
const [nameResult, rolesResult] = await Promise.allSettled([
namePromise,
executeRolesOperation(account.id),
applyDiff(localRoles, availableRoles),
]);
const errors: SaveError[] = [];
@@ -324,7 +279,14 @@ function ServiceAccountDrawer({
onRetry: retryRolesUpdate,
});
} else {
errors.push(...failuresToSaveErrors(rolesResult.value));
for (const failure of rolesResult.value) {
const context = `Role '${failure.roleName}'`;
errors.push({
context,
apiError: toSaveApiError(failure.error),
onRetry: makeRoleRetry(context, failure.onRetry),
});
}
}
if (errors.length > 0) {
@@ -332,7 +294,6 @@ function ServiceAccountDrawer({
} else {
toast.success('Service account updated successfully', {
richColors: true,
position: 'top-right',
});
onSuccess({ closeDrawer: false });
}
@@ -346,14 +307,17 @@ function ServiceAccountDrawer({
account,
isDirty,
localName,
localRoles,
availableRoles,
updateMutateAsync,
executeRolesOperation,
applyDiff,
refetchAccount,
onSuccess,
queryClient,
toSaveApiError,
retryNameUpdate,
makeRoleRetry,
retryRolesUpdate,
failuresToSaveErrors,
]);
const handleClose = useCallback((): void => {
@@ -445,11 +409,8 @@ function ServiceAccountDrawer({
account={account}
localName={localName}
onNameChange={handleNameChange}
localRole={localRole}
onRoleChange={(role): void => {
setLocalRole(role ?? '');
clearRoleErrors();
}}
localRoles={localRoles}
onRolesChange={setLocalRoles}
isDisabled={isDeleted}
availableRoles={availableRoles}
rolesLoading={rolesLoading}

View File

@@ -139,18 +139,18 @@ describe('ServiceAccountDrawer', () => {
});
});
it('changing roles enables Save; clicking Save sends role add request without delete', async () => {
it('changing roles enables Save; clicking Save sends updated roles in payload', async () => {
const updateSpy = jest.fn();
const roleSpy = jest.fn();
const deleteSpy = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.post(SA_ROLES_ENDPOINT, async (req, res, ctx) => {
roleSpy(await req.json());
rest.put(SA_ENDPOINT, async (req, res, ctx) => {
updateSpy(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
}),
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) => {
deleteSpy();
rest.post(SA_ROLES_ENDPOINT, async (req, res, ctx) => {
roleSpy(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
}),
);
@@ -167,12 +167,12 @@ describe('ServiceAccountDrawer', () => {
await user.click(saveBtn);
await waitFor(() => {
expect(updateSpy).not.toHaveBeenCalled();
expect(roleSpy).toHaveBeenCalledWith(
expect.objectContaining({
id: '019c24aa-2248-7585-a129-4188b3473c27',
}),
);
expect(deleteSpy).not.toHaveBeenCalled();
});
});
@@ -350,7 +350,7 @@ describe('ServiceAccountDrawer save-error UX', () => {
).toBeInTheDocument();
});
it('role add failure shows SaveErrorItem with the role name context', async () => {
it('role update failure shows SaveErrorItem with the role name context', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
@@ -390,42 +390,6 @@ describe('ServiceAccountDrawer save-error UX', () => {
).toBeInTheDocument();
});
it('role add retries on 429 then succeeds without showing an error', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let roleAddCallCount = 0;
// First call → 429, second call → 200
server.use(
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) => {
roleAddCallCount += 1;
if (roleAddCallCount === 1) {
return res(ctx.status(429), ctx.json({ message: 'Too Many Requests' }));
}
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
}),
);
renderDrawer();
await screen.findByDisplayValue('CI Bot');
await user.click(screen.getByLabelText('Roles'));
await user.click(await screen.findByTitle('signoz-viewer'));
const saveBtn = screen.getByRole('button', { name: /Save Changes/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await user.click(saveBtn);
// Retried after 429 — at least 2 calls, no error shown
await waitFor(
() => {
expect(roleAddCallCount).toBeGreaterThanOrEqual(2);
},
{ timeout: 5000 },
);
expect(screen.queryByText(/role assign failed/i)).not.toBeInTheDocument();
});
it('clicking Retry on a name-update error re-triggers the request; on success the error item is removed', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });

View File

@@ -12,7 +12,6 @@ export enum LOCALSTORAGE {
GRAPH_VISIBILITY_STATES = 'GRAPH_VISIBILITY_STATES',
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',
LOGGED_IN_USER_EMAIL = 'LOGGED_IN_USER_EMAIL',
CHAT_SUPPORT = 'CHAT_SUPPORT',

View File

@@ -1,11 +1,4 @@
export const REACT_QUERY_KEY = {
/**
* For any query that should support AutoRefresh and min/max time is from DateTimeSelectionV2
* You can prefix the query with this KEY, it will allow the queries to be automatically refreshed
* when the user clicks in the refresh button, or alert the user when the data is being refreshed.
*/
AUTO_REFRESH_QUERY: 'AUTO_REFRESH_QUERY',
GET_PUBLIC_DASHBOARD: 'GET_PUBLIC_DASHBOARD',
GET_PUBLIC_DASHBOARD_META: 'GET_PUBLIC_DASHBOARD_META',
GET_PUBLIC_DASHBOARD_WIDGET_DATA: 'GET_PUBLIC_DASHBOARD_WIDGET_DATA',

View File

@@ -65,7 +65,6 @@ const ROUTES = {
WORKSPACE_SUSPENDED: '/workspace-suspended',
SHORTCUTS: '/settings/shortcuts',
INTEGRATIONS: '/integrations',
INTEGRATIONS_DETAIL: '/integrations/:integrationId',
MESSAGING_QUEUES_BASE: '/messaging-queues',
MESSAGING_QUEUES_KAFKA: '/messaging-queues/kafka',
MESSAGING_QUEUES_KAFKA_DETAIL: '/messaging-queues/kafka/detail',

View File

@@ -248,35 +248,5 @@ export function createShortcutActions(deps: ActionDeps): CmdAction[] {
roles: ['ADMIN', 'EDITOR'],
perform: (): void => navigate(ROUTES.BILLING),
},
{
id: 'my-settings-service-accounts',
name: 'Go to Service Accounts',
shortcut: [GlobalShortcutsName.NavigateToSettingsServiceAccounts],
keywords: 'settings service accounts',
section: 'Settings',
icon: <Settings size={14} />,
roles: ['ADMIN'],
perform: (): void => navigate(ROUTES.SERVICE_ACCOUNTS_SETTINGS),
},
{
id: 'my-settings-roles',
name: 'Go to Roles',
shortcut: [GlobalShortcutsName.NavigateToSettingsRoles],
keywords: 'settings roles',
section: 'Settings',
icon: <Settings size={14} />,
roles: ['ADMIN'],
perform: (): void => navigate(ROUTES.ROLES_SETTINGS),
},
{
id: 'my-settings-members',
name: 'Go to Members',
shortcut: [GlobalShortcutsName.NavigateToSettingsMembers],
keywords: 'settings members',
section: 'Settings',
icon: <Settings size={14} />,
roles: ['ADMIN'],
perform: (): void => navigate(ROUTES.MEMBERS_SETTINGS),
},
];
}

View File

@@ -27,9 +27,6 @@ export const GlobalShortcuts = {
NavigateToSettingsIngestion: 'shift+g+i',
NavigateToSettingsBilling: 'shift+g+b',
NavigateToSettingsNotificationChannels: 'shift+g+n',
NavigateToSettingsServiceAccounts: 'shift+g+k',
NavigateToSettingsRoles: 'shift+g+r',
NavigateToSettingsMembers: 'shift+g+m',
};
export const GlobalShortcutsName = {
@@ -50,9 +47,6 @@ export const GlobalShortcutsName = {
NavigateToSettingsIngestion: 'shift+g+i',
NavigateToSettingsBilling: 'shift+g+b',
NavigateToSettingsNotificationChannels: 'shift+g+n',
NavigateToSettingsServiceAccounts: 'shift+g+k',
NavigateToSettingsRoles: 'shift+g+r',
NavigateToSettingsMembers: 'shift+g+m',
NavigateToLogs: 'shift+l',
NavigateToLogsPipelines: 'shift+l+p',
NavigateToLogsViews: 'shift+l+v',
@@ -80,7 +74,4 @@ export const GlobalShortcutsDescription = {
'Navigate to Notification Channels Settings',
NavigateToLogsPipelines: 'Navigate to Logs Pipelines',
NavigateToLogsViews: 'Navigate to Logs Views',
NavigateToSettingsServiceAccounts: 'Navigate to Service Accounts Settings',
NavigateToSettingsRoles: 'Navigate to Roles Settings',
NavigateToSettingsMembers: 'Navigate to Members Settings',
};

View File

@@ -0,0 +1,24 @@
import {
IntegrationType,
RequestIntegrationBtn,
} from 'pages/Integrations/RequestIntegrationBtn';
import Header from './Header/Header';
import HeroSection from './HeroSection/HeroSection';
import ServicesTabs from './ServicesSection/ServicesTabs';
function CloudIntegrationPage(): JSX.Element {
return (
<div>
<Header />
<HeroSection />
<RequestIntegrationBtn
type={IntegrationType.AWS_SERVICES}
message="Can't find the AWS service you're looking for? Request more integrations"
/>
<ServicesTabs />
</div>
);
}
export default CloudIntegrationPage;

View File

@@ -48,7 +48,7 @@
.lightMode {
.cloud-header {
border-bottom: 1px solid var(--bg-vanilla-300);
border-bottom: 1px solid var(--bg-slate-300);
&__breadcrumb-title {
color: var(--bg-ink-400);

View File

@@ -1,13 +1,11 @@
import { Link } from 'react-router-dom';
import { Button } from '@signozhq/button';
import Breadcrumb from 'antd/es/breadcrumb';
import { Breadcrumb } from 'antd';
import ROUTES from 'constants/routes';
import { IntegrationType } from 'container/Integrations/types';
import { Blocks, LifeBuoy } from 'lucide-react';
import './Header.styles.scss';
function Header({ title }: { title: IntegrationType }): JSX.Element {
function Header(): JSX.Element {
return (
<div className="cloud-header">
<div className="cloud-header__navigation">
@@ -25,26 +23,25 @@ function Header({ title }: { title: IntegrationType }): JSX.Element {
),
},
{
title: <div className="cloud-header__breadcrumb-title">{title}</div>,
title: (
<div className="cloud-header__breadcrumb-title">
Amazon Web Services
</div>
),
},
]}
/>
</div>
<div className="cloud-header__actions">
<Button
variant="solid"
size="sm"
color="secondary"
onClick={(): void => {
window.open(
'https://signoz.io/blog/native-aws-integrations-with-autodiscovery/',
'_blank',
);
}}
prefixIcon={<LifeBuoy size={12} />}
<a
href="https://signoz.io/blog/native-aws-integrations-with-autodiscovery/"
target="_blank"
rel="noopener noreferrer"
className="cloud-header__help"
>
<LifeBuoy size={12} />
Get Help
</Button>
</a>
</div>
</div>
);

View File

@@ -1,10 +1,8 @@
.hero-section {
padding: 16px;
height: 308px;
padding: 26px 16px;
display: flex;
flex-direction: column;
gap: 16px;
gap: 24px;
position: relative;
overflow: hidden;
background-position: right;
@@ -12,37 +10,35 @@
background-repeat: no-repeat;
border-bottom: 1px solid var(--bg-slate-500);
&__icon {
height: fit-content;
background-color: var(--bg-ink-400);
padding: 12px;
border: 1px solid var(--bg-ink-300);
border-radius: 6px;
width: 60px;
height: 60px;
display: flex;
align-items: center;
img {
width: 100%;
}
}
&__details {
display: flex;
flex-direction: column;
gap: 12px;
&-header {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
&__icon {
height: fit-content;
background-color: var(--bg-ink-400);
padding: 12px;
border: 1px solid var(--bg-ink-300);
border-radius: 6px;
width: 60px;
height: 60px;
}
&__title {
color: var(--bg-vanilla-100);
font-size: 24px;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.12px;
}
.title {
color: var(--bg-vanilla-100);
font-size: 24px;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.12px;
}
&__description {
.description {
color: var(--bg-vanilla-400);
font-size: 12px;
font-weight: 400;

View File

@@ -0,0 +1,34 @@
import { useIsDarkMode } from 'hooks/useDarkMode';
import AccountActions from './components/AccountActions';
import './HeroSection.style.scss';
function HeroSection(): JSX.Element {
const isDarkMode = useIsDarkMode();
return (
<div
className="hero-section"
style={
isDarkMode
? {
backgroundImage: `url('/Images/integrations-hero-bg.png')`,
}
: {}
}
>
<div className="hero-section__icon">
<img src="/Logos/aws-dark.svg" alt="aws-logo" />
</div>
<div className="hero-section__details">
<div className="title">Amazon Web Services</div>
<div className="description">
One-click setup for AWS monitoring with SigNoz
</div>
<AccountActions />
</div>
</div>
);
}
export default HeroSection;

View File

@@ -4,57 +4,14 @@
&-with-account {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
border-radius: 4px;
border: 1px solid var(--l3-background);
background: var(--l1-background);
.selected-cloud-integration-account-status {
display: flex;
align-items: center;
justify-content: center;
border-right: 1px solid var(--l3-background);
border-radius: none;
width: 32px;
}
&-selector-container {
display: flex;
flex-direction: row;
align-items: center;
.account-selector-label {
color: var(--bg-vanilla-400);
font-size: 12px;
font-weight: 400;
line-height: 16px;
padding: 8px 16px;
}
.account-selector {
.ant-select {
background-color: transparent !important;
border: none !important;
box-shadow: none !important;
.ant-select-selector {
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
}
}
}
flex-direction: column;
gap: 10px;
}
}
&__input-skeleton {
width: 300px;
margin-bottom: 16px;
}
&__new-account-button-skeleton {
@@ -65,13 +22,11 @@
&__account-settings-button-skeleton {
width: 140px;
}
&__action-buttons {
display: flex;
align-items: center;
gap: 8px;
}
&__action-button {
font-family: 'Inter';
border-radius: 2px;
@@ -99,6 +54,44 @@
}
}
.cloud-account-selector {
border-radius: 2px;
border: 1px solid var(--bg-ink-300);
background: linear-gradient(
139deg,
rgba(18, 19, 23, 0.8) 0%,
rgba(18, 19, 23, 0.9) 98.68%
);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
.ant-select-selector {
border-color: var(--bg-slate-400) !important;
background: var(--bg-ink-300) !important;
padding: 6px 8px !important;
}
.ant-select-selection-item {
color: var(--bg-vanilla-400);
font-size: 12px;
font-weight: 400;
line-height: 16px;
}
.account-option-item {
display: flex;
align-items: center;
justify-content: space-between;
&__selected {
display: flex;
align-items: center;
justify-content: center;
height: 14px;
width: 14px;
background-color: rgba(192, 193, 195, 0.2); /* #C0C1C3 with 0.2 opacity */
border-radius: 2px;
}
}
}
.lightMode {
.hero-section__action-button {
&.primary {

View File

@@ -1,23 +1,58 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom-v5-compat';
import { Button } from '@signozhq/button';
import { Color } from '@signozhq/design-tokens';
import { Select, Skeleton } from 'antd';
import { SelectProps } from 'antd/lib';
import { Button, Select, Skeleton } from 'antd';
import type { SelectProps } from 'antd/lib';
import logEvent from 'api/common/logEvent';
import { useListAccounts } from 'api/generated/services/cloudintegration';
import { getAccountById } from 'container/Integrations/CloudIntegration/utils';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import { useAwsAccounts } from 'hooks/integration/aws/useAwsAccounts';
import useUrlQuery from 'hooks/useUrlQuery';
import { ChevronDown, Dot, PencilLine, Plug, Plus } from 'lucide-react';
import { Check, ChevronDown } from 'lucide-react';
import { mapAccountDtoToAwsCloudAccount } from '../../mapAwsCloudAccountFromDto';
import { CloudAccount } from '../../types';
import { CloudAccount } from '../../ServicesSection/types';
import AccountSettingsModal from './AccountSettingsModal';
import CloudAccountSetupModal from './CloudAccountSetupModal';
import './AccountActions.style.scss';
interface AccountOptionItemProps {
label: React.ReactNode;
isSelected: boolean;
}
function AccountOptionItem({
label,
isSelected,
}: AccountOptionItemProps): JSX.Element {
return (
<div className="account-option-item">
{label}
{isSelected && (
<div className="account-option-item__selected">
<Check size={12} color={Color.BG_VANILLA_100} />
</div>
)}
</div>
);
}
function renderOption(
option: any,
activeAccountId: string | undefined,
): JSX.Element {
return (
<AccountOptionItem
label={option.label}
isSelected={option.value === activeAccountId}
/>
);
}
const getAccountById = (
accounts: CloudAccount[],
accountId: string,
): CloudAccount | null =>
accounts.find((account) => account.cloud_account_id === accountId) || null;
function AccountActionsRenderer({
accounts,
isLoading,
@@ -38,51 +73,55 @@ function AccountActionsRenderer({
if (isLoading) {
return (
<div className="hero-section__actions-with-account">
<Skeleton.Input active block className="hero-section__input-skeleton" />
<Skeleton.Input
active
size="large"
block
className="hero-section__input-skeleton"
/>
<div className="hero-section__action-buttons">
<Skeleton.Button
active
size="large"
className="hero-section__new-account-button-skeleton"
/>
<Skeleton.Button
active
size="large"
className="hero-section__account-settings-button-skeleton"
/>
</div>
</div>
);
}
if (accounts?.length) {
return (
<div className="hero-section__actions-with-account">
<div className="hero-section__actions-with-account-selector-container">
<div className="selected-cloud-integration-account-status">
<Dot size={24} color={Color.BG_FOREST_500} />
</div>
<div className="account-selector-label">Account:</div>
<span className="account-selector">
<Select
value={activeAccount?.providerAccountId}
options={selectOptions}
rootClassName="cloud-account-selector"
placeholder="Select AWS Account"
suffixIcon={<ChevronDown size={16} color={Color.BG_VANILLA_400} />}
onChange={onAccountChange}
/>
</span>
</div>
<Select
value={`Account: ${activeAccount?.cloud_account_id}`}
options={selectOptions}
rootClassName="cloud-account-selector"
placeholder="Select AWS Account"
suffixIcon={<ChevronDown size={16} color={Color.BG_VANILLA_400} />}
optionRender={(option): JSX.Element =>
renderOption(option, activeAccount?.cloud_account_id)
}
onChange={onAccountChange}
/>
<div className="hero-section__action-buttons">
<Button
variant="link"
size="sm"
color="secondary"
prefixIcon={<PencilLine size={14} />}
type="primary"
className="hero-section__action-button primary"
onClick={onIntegrationModalOpen}
>
Add New AWS Account
</Button>
<Button
type="default"
className="hero-section__action-button secondary"
onClick={onAccountSettingsModalOpen}
>
Edit Account
</Button>
<Button
variant="link"
size="sm"
color="secondary"
onClick={onIntegrationModalOpen}
prefixIcon={<Plus size={14} />}
>
Add New Account
Account Settings
</Button>
</div>
</div>
@@ -90,11 +129,8 @@ function AccountActionsRenderer({
}
return (
<Button
variant="solid"
color="primary"
prefixIcon={<Plug size={14} />}
className="hero-section__action-button primary"
onClick={onIntegrationModalOpen}
size="sm"
>
Integrate Now
</Button>
@@ -104,18 +140,7 @@ function AccountActionsRenderer({
function AccountActions(): JSX.Element {
const urlQuery = useUrlQuery();
const navigate = useNavigate();
const { data: listAccountsResponse, isLoading } = useListAccounts({
cloudProvider: INTEGRATION_TYPES.AWS,
});
const accounts = useMemo((): CloudAccount[] | undefined => {
const raw = listAccountsResponse?.data?.accounts;
if (!raw) {
return undefined;
}
return raw
.map(mapAccountDtoToAwsCloudAccount)
.filter((account): account is CloudAccount => account !== null);
}, [listAccountsResponse]);
const { data: accounts, isLoading } = useAwsAccounts();
const initialAccount = useMemo(
() =>
@@ -137,13 +162,7 @@ function AccountActions(): JSX.Element {
const latestUrlQuery = new URLSearchParams(window.location.search);
latestUrlQuery.set('cloudAccountId', initialAccount.cloud_account_id);
navigate({ search: latestUrlQuery.toString() });
return;
}
setActiveAccount(null);
const latestUrlQuery = new URLSearchParams(window.location.search);
latestUrlQuery.delete('cloudAccountId');
navigate({ search: latestUrlQuery.toString() });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialAccount]);
@@ -179,7 +198,7 @@ function AccountActions(): JSX.Element {
accounts?.length
? accounts.map((account) => ({
value: account.cloud_account_id,
label: account.providerAccountId,
label: account.cloud_account_id,
}))
: [],
[accounts],

View File

@@ -14,13 +14,8 @@
border-radius: 3px;
border: 1px solid var(--bg-slate-500);
padding: 14px;
&-account-info {
&-connected-account-details {
display: flex;
flex-direction: column;
gap: 8px;
&-title {
color: var(--bg-vanilla-100);
font-size: 14px;
@@ -43,36 +38,40 @@
}
}
}
&-region-selector {
&-regions-switch {
display: flex;
flex-direction: column;
gap: 4px;
gap: 10px;
&-title {
color: var(--l1-foreground);
color: var(--bg-vanilla-100);
font-size: 14px;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.07px;
}
&-description {
color: var(--l2-foreground);
font-size: 12px;
line-height: 18px;
letter-spacing: -0.06px;
&-switch {
display: flex;
align-items: center;
gap: 10px;
&-label {
color: var(--bg-vanilla-400);
background-color: transparent;
border: none;
font-family: Inter;
font-size: 12px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.005em;
cursor: pointer;
}
}
}
&-regions-select {
margin-top: 8px;
}
}
&__footer {
padding: 16px;
display: flex;
flex-direction: row;
gap: 10px;
justify-content: space-between;
&-close-button,
&-save-button {
color: var(--bg-vanilla-100);

View File

@@ -0,0 +1,213 @@
import { Dispatch, SetStateAction, useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { Form, Select, Switch } from 'antd';
import SignozModal from 'components/SignozModal/SignozModal';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
getRegionPreviewText,
useAccountSettingsModal,
} from 'hooks/integration/aws/useAccountSettingsModal';
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import logEvent from '../../../../api/common/logEvent';
import { CloudAccount } from '../../ServicesSection/types';
import { RegionSelector } from './RegionSelector';
import RemoveIntegrationAccount from './RemoveIntegrationAccount';
import './AccountSettingsModal.style.scss';
interface AccountSettingsModalProps {
onClose: () => void;
account: CloudAccount;
setActiveAccount: Dispatch<SetStateAction<CloudAccount | null>>;
}
function AccountSettingsModal({
onClose,
account,
setActiveAccount,
}: AccountSettingsModalProps): JSX.Element {
const {
form,
isLoading,
selectedRegions,
includeAllRegions,
isRegionSelectOpen,
isSaveDisabled,
setSelectedRegions,
setIncludeAllRegions,
setIsRegionSelectOpen,
handleIncludeAllRegionsChange,
handleSubmit,
handleClose,
} = useAccountSettingsModal({ onClose, account, setActiveAccount });
const queryClient = useQueryClient();
const urlQuery = useUrlQuery();
const handleRemoveIntegrationAccountSuccess = (): void => {
queryClient.invalidateQueries([REACT_QUERY_KEY.AWS_ACCOUNTS]);
urlQuery.delete('cloudAccountId');
handleClose();
history.replace({ search: urlQuery.toString() });
logEvent('AWS Integration: Account removed', {
id: account?.id,
cloudAccountId: account?.cloud_account_id,
});
};
const handleRegionDeselect = useCallback(
(item: string): void => {
if (selectedRegions.includes(item)) {
setSelectedRegions(selectedRegions.filter((region) => region !== item));
if (includeAllRegions) {
setIncludeAllRegions(false);
}
}
},
[
selectedRegions,
includeAllRegions,
setSelectedRegions,
setIncludeAllRegions,
],
);
const renderRegionSelector = useCallback(() => {
if (isRegionSelectOpen) {
return (
<RegionSelector
selectedRegions={selectedRegions}
setSelectedRegions={setSelectedRegions}
setIncludeAllRegions={setIncludeAllRegions}
/>
);
}
return (
<>
<div className="account-settings-modal__body-regions-switch-switch ">
<Switch
checked={includeAllRegions}
onChange={handleIncludeAllRegionsChange}
/>
<button
className="account-settings-modal__body-regions-switch-switch-label"
type="button"
onClick={(): void => handleIncludeAllRegionsChange(!includeAllRegions)}
>
Include all regions
</button>
</div>
<Select
suffixIcon={null}
placeholder="Select Region(s)"
className="cloud-account-setup-form__select account-settings-modal__body-regions-select integrations-select"
onClick={(): void => setIsRegionSelectOpen(true)}
mode="multiple"
maxTagCount={3}
value={getRegionPreviewText(selectedRegions)}
open={false}
onDeselect={handleRegionDeselect}
/>
</>
);
}, [
isRegionSelectOpen,
includeAllRegions,
handleIncludeAllRegionsChange,
selectedRegions,
handleRegionDeselect,
setSelectedRegions,
setIncludeAllRegions,
setIsRegionSelectOpen,
]);
const renderAccountDetails = useCallback(
() => (
<div className="account-settings-modal__body-account-info">
<div className="account-settings-modal__body-account-info-connected-account-details">
<div className="account-settings-modal__body-account-info-connected-account-details-title">
Connected Account details
</div>
<div className="account-settings-modal__body-account-info-connected-account-details-account-id">
AWS Account:{' '}
<span className="account-settings-modal__body-account-info-connected-account-details-account-id-account-id">
{account?.id}
</span>
</div>
</div>
</div>
),
[account?.id],
);
const modalTitle = (
<div className="account-settings-modal__title">
Account settings for{' '}
<span className="account-settings-modal__title-account-id">
{account?.id}
</span>
</div>
);
return (
<SignozModal
open
title={modalTitle}
onCancel={handleClose}
onOk={handleSubmit}
okText="Save"
okButtonProps={{
disabled: isSaveDisabled,
className: 'account-settings-modal__footer-save-button',
loading: isLoading,
}}
cancelButtonProps={{
className: 'account-settings-modal__footer-close-button',
}}
width={672}
rootClassName="account-settings-modal"
>
<Form
form={form}
layout="vertical"
initialValues={{
selectedRegions,
includeAllRegions,
}}
>
<div className="account-settings-modal__body">
{renderAccountDetails()}
<Form.Item
name="selectedRegions"
rules={[
{
validator: async (): Promise<void> => {
if (selectedRegions.length === 0) {
throw new Error('Please select at least one region to monitor');
}
},
message: 'Please select at least one region to monitor',
},
]}
>
{renderRegionSelector()}
</Form.Item>
<div className="integration-detail-content">
<RemoveIntegrationAccount
accountId={account?.id}
onRemoveIntegrationAccountSuccess={handleRemoveIntegrationAccountSuccess}
/>
</div>
</div>
</Form>
</SignozModal>
);
}
export default AccountSettingsModal;

View File

@@ -1,26 +1,4 @@
.cloud-account-setup-modal {
> div {
display: flex;
flex-direction: column;
overflow: hidden;
}
&__content {
flex: 1;
overflow-y: auto;
min-height: 0;
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 2px;
}
}
&__footer {
padding: 16px;
margin-bottom: 16px;
}
.account-setup-modal-footer {
&__confirm-button {
background: var(--bg-robin-500);
@@ -42,8 +20,6 @@
}
.cloud-account-setup-form {
padding: 16px;
.disabled {
opacity: 0.4;
}

View File

@@ -1,7 +1,8 @@
import { useCallback } from 'react';
import { Button } from '@signozhq/button';
import { useQueryClient } from 'react-query';
import { Color } from '@signozhq/design-tokens';
import { DrawerWrapper } from '@signozhq/drawer';
import SignozModal from 'components/SignozModal/SignozModal';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useIntegrationModal } from 'hooks/integration/aws/useIntegrationModal';
import { SquareArrowOutUpRight } from 'lucide-react';
@@ -11,15 +12,19 @@ import {
ModalStateEnum,
} from '../types';
import { RegionForm } from './RegionForm';
import { RegionSelector } from './RegionSelector';
import { SuccessView } from './SuccessView';
import './CloudAccountSetupModal.style.scss';
function CloudAccountSetupModal({
onClose,
}: IntegrationModalProps): JSX.Element {
const queryClient = useQueryClient();
const {
form,
modalState,
setModalState,
isLoading,
activeView,
selectedRegions,
@@ -27,86 +32,97 @@ function CloudAccountSetupModal({
isGeneratingUrl,
setSelectedRegions,
setIncludeAllRegions,
handleIncludeAllRegionsChange,
handleRegionSelect,
handleSubmit,
handleClose,
setActiveView,
allRegions,
accountId,
selectedDeploymentRegion,
handleRegionChange,
connectionParams,
isConnectionParamsLoading,
handleConnectionSuccess,
handleConnectionTimeout,
handleConnectionError,
} = useIntegrationModal({ onClose });
const renderContent = useCallback(() => {
return (
<div className="cloud-account-setup-modal__content">
<RegionForm
form={form}
modalState={modalState}
if (modalState === ModalStateEnum.SUCCESS) {
return <SuccessView />;
}
if (activeView === ActiveViewEnum.SELECT_REGIONS) {
return (
<RegionSelector
selectedRegions={selectedRegions}
includeAllRegions={includeAllRegions}
onRegionSelect={handleRegionSelect}
onSubmit={handleSubmit}
accountId={accountId}
handleRegionChange={handleRegionChange}
connectionParams={connectionParams}
isConnectionParamsLoading={isConnectionParamsLoading}
setSelectedRegions={setSelectedRegions}
setIncludeAllRegions={setIncludeAllRegions}
onConnectionSuccess={handleConnectionSuccess}
onConnectionTimeout={handleConnectionTimeout}
onConnectionError={handleConnectionError}
/>
);
}
<div className="cloud-account-setup-modal__footer">
<Button
variant="solid"
color="primary"
prefixIcon={
<SquareArrowOutUpRight size={17} color={Color.BG_VANILLA_100} />
}
onClick={handleSubmit}
disabled={
selectedRegions.length === 0 ||
isLoading ||
isGeneratingUrl ||
modalState === ModalStateEnum.WAITING
}
>
Launch Cloud Formation Template
</Button>
</div>
</div>
return (
<RegionForm
form={form}
modalState={modalState}
setModalState={setModalState}
selectedRegions={selectedRegions}
includeAllRegions={includeAllRegions}
onIncludeAllRegionsChange={handleIncludeAllRegionsChange}
onRegionSelect={handleRegionSelect}
onSubmit={handleSubmit}
accountId={accountId}
selectedDeploymentRegion={selectedDeploymentRegion}
handleRegionChange={handleRegionChange}
connectionParams={connectionParams}
isConnectionParamsLoading={isConnectionParamsLoading}
/>
);
}, [
modalState,
activeView,
form,
setModalState,
selectedRegions,
includeAllRegions,
handleIncludeAllRegionsChange,
handleRegionSelect,
handleSubmit,
accountId,
selectedDeploymentRegion,
handleRegionChange,
connectionParams,
isConnectionParamsLoading,
setSelectedRegions,
setIncludeAllRegions,
isLoading,
isGeneratingUrl,
handleConnectionSuccess,
handleConnectionTimeout,
handleConnectionError,
]);
const getSelectedRegionsCount = useCallback(
(): number => selectedRegions.length,
[selectedRegions],
(): number =>
selectedRegions.includes('all') ? allRegions.length : selectedRegions.length,
[selectedRegions, allRegions],
);
const getModalConfig = useCallback(() => {
// Handle success state first
if (modalState === ModalStateEnum.SUCCESS) {
return {
title: 'AWS Integration',
okText: (
<div className="cloud-account-setup-success-view__footer-button">
Continue
</div>
),
block: true,
onOk: (): void => {
queryClient.invalidateQueries([REACT_QUERY_KEY.AWS_ACCOUNTS]);
handleClose();
},
cancelButtonProps: { style: { display: 'none' } },
disabled: false,
};
}
// Handle other views
const viewConfigs = {
[ActiveViewEnum.FORM]: {
title: 'Add AWS Account',
@@ -139,44 +155,35 @@ function CloudAccountSetupModal({
isLoading,
isGeneratingUrl,
activeView,
handleClose,
setActiveView,
queryClient,
]);
const modalConfig = getModalConfig();
const handleDrawerOpenChange = (open: boolean): void => {
if (!open) {
handleClose();
}
};
return (
<DrawerWrapper
open={true}
type="panel"
<SignozModal
open
className="cloud-account-setup-modal"
// allowOutsideClick={false}
content={renderContent()}
onOpenChange={handleDrawerOpenChange}
direction="right"
showCloseButton
header={{
title: modalConfig.title,
title={modalConfig.title}
onCancel={handleClose}
onOk={modalConfig.onOk}
okText={modalConfig.okText}
okButtonProps={{
loading: isLoading,
disabled: selectedRegions.length === 0 || modalConfig.disabled,
className:
activeView === ActiveViewEnum.FORM
? 'cloud-account-setup-form__submit-button'
: 'account-setup-modal-footer__confirm-button',
block: activeView === ActiveViewEnum.FORM,
}}
// onCancel={handleClose}
// onOk={modalConfig.onOk}
// okText={modalConfig.okText}
// okButtonProps={{
// loading: isLoading,
// disabled: selectedRegions.length === 0 || modalConfig.disabled,
// className:
// activeView === ActiveViewEnum.FORM
// ? 'cloud-account-setup-form__submit-button'
// : 'account-setup-modal-footer__confirm-button',
// block: activeView === ActiveViewEnum.FORM,
// }}
// cancelButtonProps={modalConfig.cancelButtonProps}
/>
cancelButtonProps={modalConfig.cancelButtonProps}
width={672}
>
{renderContent()}
</SignozModal>
);
}

View File

@@ -1,19 +1,17 @@
import { Dispatch, SetStateAction } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Form, Select } from 'antd';
import { Form, Select, Switch } from 'antd';
import { ChevronDown } from 'lucide-react';
import { Region } from 'utils/regions';
import { popupContainer } from 'utils/selectPopupContainer';
import { RegionSelector } from './RegionSelector';
// Form section components
function RegionDeploymentSection({
regions,
selectedDeploymentRegion,
handleRegionChange,
isFormDisabled,
}: {
regions: Region[];
selectedDeploymentRegion: string | undefined;
handleRegionChange: (value: string) => void;
isFormDisabled: boolean;
}): JSX.Element {
@@ -35,8 +33,8 @@ function RegionDeploymentSection({
suffixIcon={<ChevronDown size={16} color={Color.BG_VANILLA_400} />}
className="cloud-account-setup-form__select integrations-select"
onChange={handleRegionChange}
value={selectedDeploymentRegion}
disabled={isFormDisabled}
getPopupContainer={popupContainer}
>
{regions.flatMap((region) =>
region.subRegions.map((subRegion) => (
@@ -52,13 +50,19 @@ function RegionDeploymentSection({
}
function MonitoringRegionsSection({
includeAllRegions,
selectedRegions,
setSelectedRegions,
setIncludeAllRegions,
onIncludeAllRegionsChange,
getRegionPreviewText,
onRegionSelect,
isFormDisabled,
}: {
includeAllRegions: boolean;
selectedRegions: string[];
setSelectedRegions: Dispatch<SetStateAction<string[]>>;
setIncludeAllRegions: Dispatch<SetStateAction<boolean>>;
onIncludeAllRegionsChange: (checked: boolean) => void;
getRegionPreviewText: (regions: string[]) => string[];
onRegionSelect: () => void;
isFormDisabled: boolean;
}): JSX.Element {
return (
<div className="cloud-account-setup-form__form-group">
@@ -69,12 +73,51 @@ function MonitoringRegionsSection({
Choose only the regions you want SigNoz to monitor. You can enable all at
once, or pick specific ones:
</div>
<RegionSelector
selectedRegions={selectedRegions}
setSelectedRegions={setSelectedRegions}
setIncludeAllRegions={setIncludeAllRegions}
/>
<Form.Item
name="monitorRegions"
rules={[
{
validator: async (): Promise<void> => {
if (selectedRegions.length === 0) {
return Promise.reject();
}
return Promise.resolve();
},
message: 'Please select at least one region to monitor',
},
]}
className="cloud-account-setup-form__form-item"
>
<div className="cloud-account-setup-form__include-all-regions-switch">
<Switch
size="small"
checked={includeAllRegions}
onChange={onIncludeAllRegionsChange}
disabled={isFormDisabled}
/>
<button
className="cloud-account-setup-form__include-all-regions-switch-label"
type="button"
onClick={(): void =>
!isFormDisabled
? onIncludeAllRegionsChange(!includeAllRegions)
: undefined
}
>
Include all regions
</button>
</div>
<Select
suffixIcon={null}
placeholder="Select Region(s)"
className="cloud-account-setup-form__select integrations-select"
onClick={!isFormDisabled ? onRegionSelect : undefined}
mode="multiple"
maxTagCount={3}
value={getRegionPreviewText(selectedRegions)}
open={false}
/>
</Form.Item>
</div>
);
}

View File

@@ -0,0 +1,109 @@
import { useRef } from 'react';
import { Form } from 'antd';
import cx from 'classnames';
import { useAccountStatus } from 'hooks/integration/aws/useAccountStatus';
import { AccountStatusResponse } from 'types/api/integrations/aws';
import { regions } from 'utils/regions';
import logEvent from '../../../../api/common/logEvent';
import { ModalStateEnum, RegionFormProps } from '../types';
import AlertMessage from './AlertMessage';
import {
ComplianceNote,
MonitoringRegionsSection,
RegionDeploymentSection,
} from './IntegrateNowFormSections';
import RenderConnectionFields from './RenderConnectionParams';
const allRegions = (): string[] =>
regions.flatMap((r) => r.subRegions.map((sr) => sr.name));
const getRegionPreviewText = (regions: string[]): string[] => {
if (regions.includes('all')) {
return allRegions();
}
return regions;
};
export function RegionForm({
form,
modalState,
setModalState,
selectedRegions,
includeAllRegions,
onIncludeAllRegionsChange,
onRegionSelect,
onSubmit,
accountId,
selectedDeploymentRegion,
handleRegionChange,
connectionParams,
isConnectionParamsLoading,
}: RegionFormProps): JSX.Element {
const startTimeRef = useRef(Date.now());
const refetchInterval = 10 * 1000;
const errorTimeout = 10 * 60 * 1000;
const { isLoading: isAccountStatusLoading } = useAccountStatus(accountId, {
refetchInterval,
enabled: !!accountId && modalState === ModalStateEnum.WAITING,
onSuccess: (data: AccountStatusResponse) => {
if (data.data.status.integration.last_heartbeat_ts_ms !== null) {
setModalState(ModalStateEnum.SUCCESS);
logEvent('AWS Integration: Account connected', {
cloudAccountId: data?.data?.cloud_account_id,
status: data?.data?.status,
});
} else if (Date.now() - startTimeRef.current >= errorTimeout) {
setModalState(ModalStateEnum.ERROR);
logEvent('AWS Integration: Account connection attempt timed out', {
id: accountId,
});
}
},
onError: () => {
setModalState(ModalStateEnum.ERROR);
},
});
const isFormDisabled =
modalState === ModalStateEnum.WAITING || isAccountStatusLoading;
return (
<Form
form={form}
className="cloud-account-setup-form"
layout="vertical"
onFinish={onSubmit}
>
<AlertMessage modalState={modalState} />
<div
className={cx(`cloud-account-setup-form__content`, {
disabled: isFormDisabled,
})}
>
<RegionDeploymentSection
regions={regions}
handleRegionChange={handleRegionChange}
isFormDisabled={isFormDisabled}
selectedDeploymentRegion={selectedDeploymentRegion}
/>
<MonitoringRegionsSection
includeAllRegions={includeAllRegions}
selectedRegions={selectedRegions}
onIncludeAllRegionsChange={onIncludeAllRegionsChange}
getRegionPreviewText={getRegionPreviewText}
onRegionSelect={onRegionSelect}
isFormDisabled={isFormDisabled}
/>
<ComplianceNote />
<RenderConnectionFields
isConnectionParamsLoading={isConnectionParamsLoading}
connectionParams={connectionParams}
isFormDisabled={isFormDisabled}
/>
</div>
</Form>
);
}

View File

@@ -1,6 +1,5 @@
.select-all {
margin-top: 16px;
margin-bottom: 16px;
margin-bottom: 20px;
}
.regions-grid {
@@ -20,11 +19,3 @@
gap: 10px;
align-items: center;
}
.region-selector-footer {
margin-top: 36px;
display: flex;
justify-content: flex-end;
gap: 8px;
}

View File

@@ -28,12 +28,10 @@ export function RegionSelector({
<div className="region-selector">
<div className="select-all">
<Checkbox
checked={
allRegionIds.length > 0 &&
allRegionIds.every((regionId) => selectedRegions.includes(regionId))
}
checked={selectedRegions.includes('all')}
indeterminate={
selectedRegions.length > 0 && selectedRegions.length < allRegionIds.length
selectedRegions.length > 20 &&
selectedRegions.length < allRegionIds.length
}
onChange={(e): void => handleSelectAll(e.target.checked)}
>
@@ -48,7 +46,10 @@ export function RegionSelector({
{region.subRegions.map((subRegion) => (
<Checkbox
key={subRegion.id}
checked={selectedRegions.includes(subRegion.id)}
checked={
selectedRegions.includes('all') ||
selectedRegions.includes(subRegion.id)
}
onChange={(): void => handleRegionSelect(subRegion.id)}
>
{subRegion.name}

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