mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-05 08:30:26 +01:00
Compare commits
6 Commits
issue_5123
...
issue_5201
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53ebd22a64 | ||
|
|
198734be1f | ||
|
|
be27cf48ed | ||
|
|
530e01cf5c | ||
|
|
77b9df5f3a | ||
|
|
3d8224e1fe |
4
.github/CODEOWNERS
vendored
4
.github/CODEOWNERS
vendored
@@ -188,7 +188,3 @@ go.mod @therealpandey
|
||||
/frontend/src/container/ListAlertRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/TriggeredAlerts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/AnomalyAlertEvaluationView/ @SigNoz/pulse-frontend
|
||||
|
||||
## OpenAPI Schema - Generated
|
||||
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv
|
||||
/docs/api/openapi.yml @therealpandey @vikrantgupta25 @srikanthccv
|
||||
|
||||
2
.github/workflows/build-enterprise.yaml
vendored
2
.github/workflows/build-enterprise.yaml
vendored
@@ -69,8 +69,6 @@ jobs:
|
||||
echo 'VITE_APPCUES_APP_ID="${{ secrets.APPCUES_APP_ID }}"' >> frontend/.env
|
||||
echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.PYLON_IDENTITY_SECRET }}"' >> frontend/.env
|
||||
echo 'VITE_DOCS_BASE_URL="https://signoz.io"' >> frontend/.env
|
||||
echo 'VITE_ENVIRONMENT="production"' >> frontend/.env
|
||||
echo 'VITE_VERSION="${{ steps.build-info.outputs.version }}"' >> frontend/.env
|
||||
- name: cache-dotenv
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
|
||||
2
.github/workflows/build-staging.yaml
vendored
2
.github/workflows/build-staging.yaml
vendored
@@ -70,8 +70,6 @@ jobs:
|
||||
echo 'VITE_APPCUES_APP_ID="${{ secrets.NP_APPCUES_APP_ID }}"' >> frontend/.env
|
||||
echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.NP_PYLON_IDENTITY_SECRET }}"' >> frontend/.env
|
||||
echo 'VITE_DOCS_BASE_URL="https://staging.signoz.io"' >> frontend/.env
|
||||
echo 'VITE_ENVIRONMENT="staging"' >> frontend/.env
|
||||
echo 'VITE_VERSION="${{ steps.build-info.outputs.version }}"' >> frontend/.env
|
||||
- name: cache-dotenv
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
|
||||
2
.github/workflows/gor-signoz.yaml
vendored
2
.github/workflows/gor-signoz.yaml
vendored
@@ -35,8 +35,6 @@ jobs:
|
||||
echo 'VITE_APPCUES_APP_ID="${{ secrets.APPCUES_APP_ID }}"' >> .env
|
||||
echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.PYLON_IDENTITY_SECRET }}"' >> .env
|
||||
echo 'VITE_DOCS_BASE_URL="https://signoz.io"' >> .env
|
||||
echo 'VITE_ENVIRONMENT="production"' >> .env
|
||||
echo 'VITE_VERSION="${{ github.ref_name }}"' >> .env
|
||||
- name: node-setup
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
|
||||
@@ -870,6 +870,14 @@ components:
|
||||
- timestampMillis
|
||||
- data
|
||||
type: object
|
||||
CloudintegrationtypesAssets:
|
||||
properties:
|
||||
dashboards:
|
||||
items:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesDashboard'
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
CloudintegrationtypesAzureAccountConfig:
|
||||
properties:
|
||||
deploymentRegion:
|
||||
@@ -1017,6 +1025,17 @@ components:
|
||||
- ingestionUrl
|
||||
- ingestionKey
|
||||
type: object
|
||||
CloudintegrationtypesDashboard:
|
||||
properties:
|
||||
definition:
|
||||
$ref: '#/components/schemas/DashboardtypesStorableDashboardData'
|
||||
description:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
type: object
|
||||
CloudintegrationtypesDataCollected:
|
||||
properties:
|
||||
logs:
|
||||
@@ -1190,7 +1209,7 @@ components:
|
||||
CloudintegrationtypesService:
|
||||
properties:
|
||||
assets:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesServiceAssets'
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAssets'
|
||||
cloudIntegrationService:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesCloudIntegrationService'
|
||||
dataCollected:
|
||||
@@ -1203,6 +1222,8 @@ components:
|
||||
type: string
|
||||
supportedSignals:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesSupportedSignals'
|
||||
telemetryCollectionStrategy:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesTelemetryCollectionStrategy'
|
||||
title:
|
||||
type: string
|
||||
required:
|
||||
@@ -1213,17 +1234,9 @@ components:
|
||||
- assets
|
||||
- supportedSignals
|
||||
- dataCollected
|
||||
- telemetryCollectionStrategy
|
||||
- cloudIntegrationService
|
||||
type: object
|
||||
CloudintegrationtypesServiceAssets:
|
||||
properties:
|
||||
dashboards:
|
||||
items:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesServiceDashboard'
|
||||
type: array
|
||||
required:
|
||||
- dashboards
|
||||
type: object
|
||||
CloudintegrationtypesServiceConfig:
|
||||
properties:
|
||||
aws:
|
||||
@@ -1231,18 +1244,6 @@ components:
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureServiceConfig'
|
||||
type: object
|
||||
CloudintegrationtypesServiceDashboard:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
integrationDashboard:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesStorableIntegrationDashboard'
|
||||
title:
|
||||
type: string
|
||||
required:
|
||||
- title
|
||||
- description
|
||||
type: object
|
||||
CloudintegrationtypesServiceID:
|
||||
enum:
|
||||
- alb
|
||||
@@ -1260,6 +1261,7 @@ components:
|
||||
- sqs
|
||||
- storageaccountsblob
|
||||
- cdnprofile
|
||||
- appservice
|
||||
type: string
|
||||
CloudintegrationtypesServiceMetadata:
|
||||
properties:
|
||||
@@ -1277,30 +1279,6 @@ components:
|
||||
- icon
|
||||
- enabled
|
||||
type: object
|
||||
CloudintegrationtypesStorableIntegrationDashboard:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
dashboardId:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
provider:
|
||||
type: string
|
||||
slug:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- dashboardId
|
||||
- provider
|
||||
- slug
|
||||
- createdAt
|
||||
- updatedAt
|
||||
type: object
|
||||
CloudintegrationtypesSupportedSignals:
|
||||
properties:
|
||||
logs:
|
||||
@@ -1308,6 +1286,13 @@ components:
|
||||
metrics:
|
||||
type: boolean
|
||||
type: object
|
||||
CloudintegrationtypesTelemetryCollectionStrategy:
|
||||
properties:
|
||||
aws:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSTelemetryCollectionStrategy'
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureTelemetryCollectionStrategy'
|
||||
type: object
|
||||
CloudintegrationtypesUpdatableAccount:
|
||||
properties:
|
||||
config:
|
||||
@@ -2661,30 +2646,6 @@ components:
|
||||
legend:
|
||||
$ref: '#/components/schemas/DashboardtypesLegend'
|
||||
type: object
|
||||
DashboardtypesJSONPatchOperation:
|
||||
properties:
|
||||
from:
|
||||
description: Source JSON Pointer for move/copy ops; ignored for other ops.
|
||||
type: string
|
||||
op:
|
||||
$ref: '#/components/schemas/DashboardtypesPatchOp'
|
||||
path:
|
||||
description: JSON Pointer (RFC 6901) into the dashboard's postable shape
|
||||
— e.g. /spec/display/name, /spec/panels/<id>, /spec/panels/<id>/spec/queries/0,
|
||||
/tags/-.
|
||||
type: string
|
||||
value:
|
||||
description: 'Value to add/replace/test against. The expected type depends
|
||||
on the path. Common shapes (see referenced schemas for the exact field
|
||||
set): /spec/panels/<id> takes a DashboardtypesPanel; /spec/panels/<id>/spec/queries/N
|
||||
(or /-) takes a DashboardtypesQuery; /spec/variables/N takes a DashboardtypesVariable;
|
||||
/spec/layouts/N takes a DashboardtypesLayout; /tags/N (or /-) takes a
|
||||
TagtypesPostableTag; /spec/display/name and other leaf string fields take
|
||||
a string. Required for add/replace/test; ignored for remove/move/copy.'
|
||||
required:
|
||||
- op
|
||||
- path
|
||||
type: object
|
||||
DashboardtypesLayout:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesLayoutEnvelopeGithubComPersesPersesPkgModelApiV1DashboardGridLayoutSpec'
|
||||
@@ -2900,20 +2861,6 @@ components:
|
||||
$ref: '#/components/schemas/DashboardtypesQuery'
|
||||
type: array
|
||||
type: object
|
||||
DashboardtypesPatchOp:
|
||||
enum:
|
||||
- add
|
||||
- remove
|
||||
- replace
|
||||
- move
|
||||
- copy
|
||||
- test
|
||||
type: string
|
||||
DashboardtypesPatchableDashboardV2:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesJSONPatchOperation'
|
||||
nullable: true
|
||||
type: array
|
||||
DashboardtypesPieChartPanelSpec:
|
||||
properties:
|
||||
formatting:
|
||||
@@ -3201,27 +3148,6 @@ components:
|
||||
timePreference:
|
||||
$ref: '#/components/schemas/DashboardtypesTimePreference'
|
||||
type: object
|
||||
DashboardtypesUpdatableDashboardV2:
|
||||
properties:
|
||||
image:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
schemaVersion:
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesDashboardSpec'
|
||||
tags:
|
||||
items:
|
||||
$ref: '#/components/schemas/TagtypesPostableTag'
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- schemaVersion
|
||||
- name
|
||||
- tags
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesUpdatablePublicDashboard:
|
||||
properties:
|
||||
defaultTimeRange:
|
||||
@@ -8103,64 +8029,6 @@ paths:
|
||||
summary: Update account
|
||||
tags:
|
||||
- cloudintegration
|
||||
/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}/services:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint lists the services metadata for the specified account
|
||||
and cloud provider
|
||||
operationId: ListAccountServicesMetadata
|
||||
parameters:
|
||||
- in: path
|
||||
name: cloud_provider
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGettableServicesMetadata'
|
||||
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: List account services metadata
|
||||
tags:
|
||||
- cloudintegration
|
||||
/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}/services/{service_id}:
|
||||
put:
|
||||
deprecated: false
|
||||
@@ -12957,12 +12825,6 @@ paths:
|
||||
- data
|
||||
type: object
|
||||
description: Created
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
@@ -13015,12 +12877,6 @@ paths:
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
@@ -13033,12 +12889,6 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
@@ -13053,262 +12903,6 @@ paths:
|
||||
summary: Get dashboard (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
patch:
|
||||
deprecated: false
|
||||
description: 'This endpoint applies an RFC 6902 JSON Patch to a v2-shape dashboard.
|
||||
The patch is applied against the postable view of the dashboard (metadata,
|
||||
data, tags), so individual panels, queries, variables, layouts, or tags can
|
||||
be updated without re-sending the rest of the dashboard. Apply is lenient:
|
||||
`remove` on a missing path is a no-op (idempotent) and `add` creates any missing
|
||||
parent objects, rather than failing as strict RFC 6902 would. The resulting
|
||||
dashboard is still validated. Locked dashboards are rejected.'
|
||||
operationId: PatchDashboardV2
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DashboardtypesPatchableDashboardV2'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Patch dashboard (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
put:
|
||||
deprecated: false
|
||||
description: This endpoint updates a v2-shape dashboard's metadata, data, and
|
||||
tag set. Locked dashboards are rejected.
|
||||
operationId: UpdateDashboardV2
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DashboardtypesUpdatableDashboardV2'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Update dashboard (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/dashboards/{id}/lock:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: This endpoint unlocks a v2-shape dashboard. Only the dashboard's
|
||||
creator or an org admin may lock or unlock.
|
||||
operationId: UnlockDashboardV2
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Unlock dashboard (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
put:
|
||||
deprecated: false
|
||||
description: This endpoint locks a v2-shape dashboard. Only the dashboard's
|
||||
creator or an org admin may lock or unlock.
|
||||
operationId: LockDashboardV2
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Lock dashboard (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/factor_password/forgot:
|
||||
post:
|
||||
deprecated: false
|
||||
|
||||
@@ -355,32 +355,26 @@ func (module *module) GetService(ctx context.Context, orgID valuer.UUID, service
|
||||
|
||||
var integrationService *cloudintegrationtypes.CloudIntegrationService
|
||||
|
||||
if cloudIntegrationID.IsZero() {
|
||||
return cloudintegrationtypes.NewService(provider, serviceDefinition, nil, nil), nil
|
||||
if !cloudIntegrationID.IsZero() {
|
||||
storedService, err := module.store.GetServiceByServiceID(ctx, cloudIntegrationID, serviceID)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if storedService != nil {
|
||||
serviceConfig, err := cloudintegrationtypes.NewServiceConfigFromJSON(provider, storedService.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
integrationService = cloudintegrationtypes.NewCloudIntegrationServiceFromStorable(storedService, serviceConfig)
|
||||
}
|
||||
|
||||
if err := module.enrichDashboardIDs(ctx, orgID, provider, serviceID, serviceDefinition); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
storedService, err := module.store.GetServiceByServiceID(ctx, cloudIntegrationID, serviceID)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if storedService == nil {
|
||||
return cloudintegrationtypes.NewService(provider, serviceDefinition, nil, nil), nil
|
||||
}
|
||||
|
||||
serviceConfig, err := cloudintegrationtypes.NewServiceConfigFromJSON(provider, storedService.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
integrationService = cloudintegrationtypes.NewCloudIntegrationServiceFromStorable(storedService, serviceConfig)
|
||||
|
||||
slugPrefix := cloudintegrationtypes.CloudIntegrationDashboardSlugPrefix(provider, serviceID)
|
||||
integrationDashboards, err := module.store.ListIntegrationDashboardsBySlugPrefix(ctx, orgID, cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, slugPrefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cloudintegrationtypes.NewService(provider, serviceDefinition, integrationService, integrationDashboards), nil
|
||||
return cloudintegrationtypes.NewService(*serviceDefinition, integrationService), nil
|
||||
}
|
||||
|
||||
func (module *module) CreateService(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, service *cloudintegrationtypes.CloudIntegrationService, provider cloudintegrationtypes.CloudProviderType) error {
|
||||
@@ -589,3 +583,20 @@ func (module *module) deprovisionDashboards(ctx context.Context, orgID valuer.UU
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// enrichDashboardIDs replaces the raw dashboard name in each Dashboard.ID with the provisioned UUID.
|
||||
// TODO: remove this hack and send idiomatic response to client.
|
||||
func (module *module) enrichDashboardIDs(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, serviceID cloudintegrationtypes.ServiceID, serviceDefinition *cloudintegrationtypes.ServiceDefinition) error {
|
||||
for i, d := range serviceDefinition.Assets.Dashboards {
|
||||
slug := cloudintegrationtypes.CloudIntegrationDashboardSlug(provider, serviceID, d.ID)
|
||||
row, err := module.store.GetIntegrationDashboardBySlug(ctx, orgID, cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, slug)
|
||||
if err != nil {
|
||||
if errors.Ast(err, errors.TypeNotFound) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
serviceDefinition.Assets.Dashboards[i].ID = row.DashboardID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -221,18 +221,6 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
|
||||
return module.pkgDashboardModule.GetV2(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.pkgDashboardModule.UpdateV2(ctx, orgID, id, updatedBy, updatable)
|
||||
}
|
||||
|
||||
func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.pkgDashboardModule.PatchV2(ctx, orgID, id, updatedBy, patch)
|
||||
}
|
||||
|
||||
func (module *module) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
|
||||
return module.pkgDashboardModule.LockUnlockV2(ctx, orgID, id, updatedBy, isAdmin, lock)
|
||||
}
|
||||
|
||||
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
|
||||
return module.pkgDashboardModule.Get(ctx, orgID, id)
|
||||
}
|
||||
|
||||
28
frontend/__mocks__/useSafeNavigate.ts
Normal file
28
frontend/__mocks__/useSafeNavigate.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// Mock for useSafeNavigate hook to avoid React Router version conflicts in tests
|
||||
interface SafeNavigateOptions {
|
||||
replace?: boolean;
|
||||
state?: unknown;
|
||||
newTab?: boolean;
|
||||
}
|
||||
|
||||
interface SafeNavigateTo {
|
||||
pathname?: string;
|
||||
search?: string;
|
||||
hash?: string;
|
||||
}
|
||||
|
||||
type SafeNavigateToType = string | SafeNavigateTo;
|
||||
|
||||
interface UseSafeNavigateReturn {
|
||||
safeNavigate: jest.MockedFunction<
|
||||
(to: SafeNavigateToType, options?: SafeNavigateOptions) => void
|
||||
>;
|
||||
}
|
||||
|
||||
export const useSafeNavigate = (): UseSafeNavigateReturn => ({
|
||||
safeNavigate: jest.fn(
|
||||
(_to: SafeNavigateToType, _options?: SafeNavigateOptions) => {},
|
||||
) as jest.MockedFunction<
|
||||
(to: SafeNavigateToType, options?: SafeNavigateOptions) => void
|
||||
>,
|
||||
});
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { Config } from '@jest/types';
|
||||
|
||||
const USE_SAFE_NAVIGATE_MOCK_PATH =
|
||||
'<rootDir>/src/__tests__/safeNavigateMock.ts';
|
||||
const LOG_EVENT_MOCK_PATH = '<rootDir>/src/__tests__/logEventMock.ts';
|
||||
const USE_SAFE_NAVIGATE_MOCK_PATH = '<rootDir>/__mocks__/useSafeNavigate.ts';
|
||||
|
||||
const config: Config.InitialOptions = {
|
||||
silent: true,
|
||||
@@ -24,8 +22,6 @@ const config: Config.InitialOptions = {
|
||||
'^hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
|
||||
'^src/hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
|
||||
'^.*/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
|
||||
'^api/common/logEvent$': LOG_EVENT_MOCK_PATH,
|
||||
'^src/api/common/logEvent$': LOG_EVENT_MOCK_PATH,
|
||||
'^constants/env$': '<rootDir>/__mocks__/env.ts',
|
||||
'^src/constants/env$': '<rootDir>/__mocks__/env.ts',
|
||||
'^@signozhq/icons$': '<rootDir>/__mocks__/signozhqIconsMock.tsx',
|
||||
|
||||
@@ -351,18 +351,19 @@ function App(): JSX.Element {
|
||||
Sentry.init({
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
tunnel: process.env.TUNNEL_URL,
|
||||
environment: process.env.ENVIRONMENT,
|
||||
release: process.env.VERSION,
|
||||
environment: 'production',
|
||||
integrations: [
|
||||
// Kept for the `transaction` tag used in routing, even though
|
||||
// tracing is disabled. Ref: https://github.com/SigNoz/platform-pod/issues/2393#issuecomment-4603658055
|
||||
Sentry.browserTracingIntegration(),
|
||||
Sentry.replayIntegration({
|
||||
maskAllText: false,
|
||||
blockAllMedia: false,
|
||||
}),
|
||||
],
|
||||
tracesSampleRate: 0, // Ref: https://github.com/SigNoz/platform-pod/issues/2393#issuecomment-4603658055
|
||||
// Performance Monitoring
|
||||
tracesSampleRate: 1.0, // Capture 100% of the transactions
|
||||
// Set 'tracePropagationTargets' to control for which URLs distributed tracing should be enabled
|
||||
tracePropagationTargets: [],
|
||||
// Session Replay
|
||||
replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
|
||||
replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.
|
||||
beforeSend(event) {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
// Shared mock for `api/common/logEvent`.
|
||||
// Wired into jest.config.ts moduleNameMapper, so any import of
|
||||
// `api/common/logEvent` in test code resolves to this file.
|
||||
// Tests can import `logEventMock` to assert analytics calls — Jest's
|
||||
// `clearMocks: true` resets call history between tests.
|
||||
|
||||
export const logEventMock: jest.MockedFunction<
|
||||
(eventName: string, attributes?: Record<string, unknown>) => void
|
||||
> = jest.fn();
|
||||
|
||||
export default logEventMock;
|
||||
@@ -1,29 +0,0 @@
|
||||
// Shared mock for `hooks/useSafeNavigate`.
|
||||
// Wired into jest.config.ts moduleNameMapper, so any import of
|
||||
// `hooks/useSafeNavigate` in test code resolves to this file.
|
||||
// Tests can import `safeNavigateMock` to assert navigation calls — Jest's
|
||||
// `clearMocks: true` resets call history between tests.
|
||||
|
||||
interface SafeNavigateOptions {
|
||||
replace?: boolean;
|
||||
state?: unknown;
|
||||
newTab?: boolean;
|
||||
}
|
||||
|
||||
interface SafeNavigateTo {
|
||||
pathname?: string;
|
||||
search?: string;
|
||||
hash?: string;
|
||||
}
|
||||
|
||||
type SafeNavigateToType = string | SafeNavigateTo;
|
||||
|
||||
export const safeNavigateMock: jest.MockedFunction<
|
||||
(to: SafeNavigateToType, options?: SafeNavigateOptions) => void
|
||||
> = jest.fn();
|
||||
|
||||
export const useSafeNavigate = (): {
|
||||
safeNavigate: typeof safeNavigateMock;
|
||||
} => ({
|
||||
safeNavigate: safeNavigateMock,
|
||||
});
|
||||
@@ -36,8 +36,6 @@ import type {
|
||||
GetService200,
|
||||
GetServiceParams,
|
||||
GetServicePathParameters,
|
||||
ListAccountServicesMetadata200,
|
||||
ListAccountServicesMetadataPathParameters,
|
||||
ListAccounts200,
|
||||
ListAccountsPathParameters,
|
||||
ListServicesMetadata200,
|
||||
@@ -633,116 +631,6 @@ export const useUpdateAccount = <
|
||||
> => {
|
||||
return useMutation(getUpdateAccountMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint lists the services metadata for the specified account and cloud provider
|
||||
* @summary List account services metadata
|
||||
*/
|
||||
export const listAccountServicesMetadata = (
|
||||
{ cloudProvider, id }: ListAccountServicesMetadataPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListAccountServicesMetadata200>({
|
||||
url: `/api/v1/cloud_integrations/${cloudProvider}/accounts/${id}/services`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListAccountServicesMetadataQueryKey = ({
|
||||
cloudProvider,
|
||||
id,
|
||||
}: ListAccountServicesMetadataPathParameters) => {
|
||||
return [
|
||||
`/api/v1/cloud_integrations/${cloudProvider}/accounts/${id}/services`,
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getListAccountServicesMetadataQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listAccountServicesMetadata>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ cloudProvider, id }: ListAccountServicesMetadataPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listAccountServicesMetadata>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ??
|
||||
getListAccountServicesMetadataQueryKey({ cloudProvider, id });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listAccountServicesMetadata>>
|
||||
> = ({ signal }) => listAccountServicesMetadata({ cloudProvider, id }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!(cloudProvider && id),
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listAccountServicesMetadata>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListAccountServicesMetadataQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listAccountServicesMetadata>>
|
||||
>;
|
||||
export type ListAccountServicesMetadataQueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List account services metadata
|
||||
*/
|
||||
|
||||
export function useListAccountServicesMetadata<
|
||||
TData = Awaited<ReturnType<typeof listAccountServicesMetadata>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ cloudProvider, id }: ListAccountServicesMetadataPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listAccountServicesMetadata>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListAccountServicesMetadataQueryOptions(
|
||||
{ cloudProvider, id },
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List account services metadata
|
||||
*/
|
||||
export const invalidateListAccountServicesMetadata = async (
|
||||
queryClient: QueryClient,
|
||||
{ cloudProvider, id }: ListAccountServicesMetadataPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListAccountServicesMetadataQueryKey({ cloudProvider, id }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint updates a service for the specified cloud provider
|
||||
* @summary Update service
|
||||
|
||||
@@ -21,10 +21,8 @@ import type {
|
||||
CreateDashboardV2201,
|
||||
CreatePublicDashboard201,
|
||||
CreatePublicDashboardPathParameters,
|
||||
DashboardtypesPatchableDashboardV2DTO,
|
||||
DashboardtypesPostableDashboardV2DTO,
|
||||
DashboardtypesPostablePublicDashboardDTO,
|
||||
DashboardtypesUpdatableDashboardV2DTO,
|
||||
DashboardtypesUpdatablePublicDashboardDTO,
|
||||
DeletePublicDashboardPathParameters,
|
||||
GetDashboardV2200,
|
||||
@@ -35,13 +33,7 @@ import type {
|
||||
GetPublicDashboardPathParameters,
|
||||
GetPublicDashboardWidgetQueryRange200,
|
||||
GetPublicDashboardWidgetQueryRangePathParameters,
|
||||
LockDashboardV2PathParameters,
|
||||
PatchDashboardV2200,
|
||||
PatchDashboardV2PathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
UnlockDashboardV2PathParameters,
|
||||
UpdateDashboardV2200,
|
||||
UpdateDashboardV2PathParameters,
|
||||
UpdatePublicDashboardPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
@@ -824,360 +816,3 @@ export const invalidateGetDashboardV2 = async (
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint applies an RFC 6902 JSON Patch to a v2-shape dashboard. The patch is applied against the postable view of the dashboard (metadata, data, tags), so individual panels, queries, variables, layouts, or tags can be updated without re-sending the rest of the dashboard. Apply is lenient: `remove` on a missing path is a no-op (idempotent) and `add` creates any missing parent objects, rather than failing as strict RFC 6902 would. The resulting dashboard is still validated. Locked dashboards are rejected.
|
||||
* @summary Patch dashboard (v2)
|
||||
*/
|
||||
export const patchDashboardV2 = (
|
||||
{ id }: PatchDashboardV2PathParameters,
|
||||
dashboardtypesPatchableDashboardV2DTONull?: BodyType<DashboardtypesPatchableDashboardV2DTO | null> | null,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<PatchDashboardV2200>({
|
||||
url: `/api/v2/dashboards/${id}`,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: dashboardtypesPatchableDashboardV2DTONull,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getPatchDashboardV2MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesPatchableDashboardV2DTO | null>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesPatchableDashboardV2DTO | null>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['patchDashboardV2'];
|
||||
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 patchDashboardV2>>,
|
||||
{
|
||||
pathParams: PatchDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesPatchableDashboardV2DTO | null>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return patchDashboardV2(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type PatchDashboardV2MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof patchDashboardV2>>
|
||||
>;
|
||||
export type PatchDashboardV2MutationBody =
|
||||
| BodyType<DashboardtypesPatchableDashboardV2DTO | null>
|
||||
| undefined;
|
||||
export type PatchDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Patch dashboard (v2)
|
||||
*/
|
||||
export const usePatchDashboardV2 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesPatchableDashboardV2DTO | null>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof patchDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesPatchableDashboardV2DTO | null>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getPatchDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint updates a v2-shape dashboard's metadata, data, and tag set. Locked dashboards are rejected.
|
||||
* @summary Update dashboard (v2)
|
||||
*/
|
||||
export const updateDashboardV2 = (
|
||||
{ id }: UpdateDashboardV2PathParameters,
|
||||
dashboardtypesUpdatableDashboardV2DTO?: BodyType<DashboardtypesUpdatableDashboardV2DTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<UpdateDashboardV2200>({
|
||||
url: `/api/v2/dashboards/${id}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: dashboardtypesUpdatableDashboardV2DTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateDashboardV2MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesUpdatableDashboardV2DTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesUpdatableDashboardV2DTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateDashboardV2'];
|
||||
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 updateDashboardV2>>,
|
||||
{
|
||||
pathParams: UpdateDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesUpdatableDashboardV2DTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateDashboardV2(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateDashboardV2MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateDashboardV2>>
|
||||
>;
|
||||
export type UpdateDashboardV2MutationBody =
|
||||
| BodyType<DashboardtypesUpdatableDashboardV2DTO>
|
||||
| undefined;
|
||||
export type UpdateDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update dashboard (v2)
|
||||
*/
|
||||
export const useUpdateDashboardV2 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesUpdatableDashboardV2DTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesUpdatableDashboardV2DTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint unlocks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.
|
||||
* @summary Unlock dashboard (v2)
|
||||
*/
|
||||
export const unlockDashboardV2 = (
|
||||
{ id }: UnlockDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v2/dashboards/${id}/lock`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUnlockDashboardV2MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof unlockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: UnlockDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof unlockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: UnlockDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['unlockDashboardV2'];
|
||||
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 unlockDashboardV2>>,
|
||||
{ pathParams: UnlockDashboardV2PathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return unlockDashboardV2(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UnlockDashboardV2MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof unlockDashboardV2>>
|
||||
>;
|
||||
|
||||
export type UnlockDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Unlock dashboard (v2)
|
||||
*/
|
||||
export const useUnlockDashboardV2 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof unlockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: UnlockDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof unlockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: UnlockDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUnlockDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint locks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.
|
||||
* @summary Lock dashboard (v2)
|
||||
*/
|
||||
export const lockDashboardV2 = (
|
||||
{ id }: LockDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v2/dashboards/${id}/lock`,
|
||||
method: 'PUT',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getLockDashboardV2MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof lockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: LockDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof lockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: LockDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['lockDashboardV2'];
|
||||
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 lockDashboardV2>>,
|
||||
{ pathParams: LockDashboardV2PathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return lockDashboardV2(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type LockDashboardV2MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof lockDashboardV2>>
|
||||
>;
|
||||
|
||||
export type LockDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Lock dashboard (v2)
|
||||
*/
|
||||
export const useLockDashboardV2 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof lockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: LockDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof lockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: LockDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getLockDashboardV2MutationOptions(options));
|
||||
};
|
||||
|
||||
@@ -2457,6 +2457,33 @@ export interface CloudintegrationtypesAccountDTO {
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesStorableDashboardDataDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesDashboardDTO {
|
||||
definition?: DashboardtypesStorableDashboardDataDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesAssetsDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
dashboards?: CloudintegrationtypesDashboardDTO[] | null;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesAzureConnectionArtifactDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -2547,6 +2574,7 @@ export enum CloudintegrationtypesServiceIDDTO {
|
||||
sqs = 'sqs',
|
||||
storageaccountsblob = 'storageaccountsblob',
|
||||
cdnprofile = 'cdnprofile',
|
||||
appservice = 'appservice',
|
||||
}
|
||||
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
|
||||
/**
|
||||
@@ -2839,54 +2867,6 @@ export interface CloudintegrationtypesPostableAgentCheckInDTO {
|
||||
providerAccountId?: string;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesStorableIntegrationDashboardDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
dashboardId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
provider: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
slug: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesServiceDashboardDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description: string;
|
||||
integrationDashboard?: CloudintegrationtypesStorableIntegrationDashboardDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesServiceAssetsDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
dashboards: CloudintegrationtypesServiceDashboardDTO[];
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesSupportedSignalsDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
@@ -2898,8 +2878,13 @@ export interface CloudintegrationtypesSupportedSignalsDTO {
|
||||
metrics?: boolean;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesTelemetryCollectionStrategyDTO {
|
||||
aws?: CloudintegrationtypesAWSTelemetryCollectionStrategyDTO;
|
||||
azure?: CloudintegrationtypesAzureTelemetryCollectionStrategyDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesServiceDTO {
|
||||
assets: CloudintegrationtypesServiceAssetsDTO;
|
||||
assets: CloudintegrationtypesAssetsDTO;
|
||||
cloudIntegrationService: CloudintegrationtypesCloudIntegrationServiceDTO | null;
|
||||
dataCollected: CloudintegrationtypesDataCollectedDTO;
|
||||
/**
|
||||
@@ -2915,6 +2900,7 @@ export interface CloudintegrationtypesServiceDTO {
|
||||
*/
|
||||
overview: string;
|
||||
supportedSignals: CloudintegrationtypesSupportedSignalsDTO;
|
||||
telemetryCollectionStrategy: CloudintegrationtypesTelemetryCollectionStrategyDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -3720,10 +3706,6 @@ export interface DashboardtypesCustomVariableSpecDTO {
|
||||
customValue: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesStorableDashboardDataDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export enum DashboardtypesSourceDTO {
|
||||
user = 'user',
|
||||
system = 'system',
|
||||
@@ -4672,32 +4654,6 @@ export interface DashboardtypesGettablePublicDashboardDataDTO {
|
||||
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPatchOpDTO {
|
||||
add = 'add',
|
||||
remove = 'remove',
|
||||
replace = 'replace',
|
||||
move = 'move',
|
||||
copy = 'copy',
|
||||
test = 'test',
|
||||
}
|
||||
export interface DashboardtypesJSONPatchOperationDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @description Source JSON Pointer for move/copy ops; ignored for other ops.
|
||||
*/
|
||||
from?: string;
|
||||
op: DashboardtypesPatchOpDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description JSON Pointer (RFC 6901) into the dashboard's postable shape — e.g. /spec/display/name, /spec/panels/<id>, /spec/panels/<id>/spec/queries/0, /tags/-.
|
||||
*/
|
||||
path: string;
|
||||
/**
|
||||
* @description Value to add/replace/test against. The expected type depends on the path. Common shapes (see referenced schemas for the exact field set): /spec/panels/<id> takes a DashboardtypesPanel; /spec/panels/<id>/spec/queries/N (or /-) takes a DashboardtypesQuery; /spec/variables/N takes a DashboardtypesVariable; /spec/layouts/N takes a DashboardtypesLayout; /tags/N (or /-) takes a TagtypesPostableTag; /spec/display/name and other leaf string fields take a string. Required for add/replace/test; ignored for remove/move/copy.
|
||||
*/
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPanelPluginKindDTO {
|
||||
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
|
||||
'signoz/BarChartPanel' = 'signoz/BarChartPanel',
|
||||
@@ -4707,13 +4663,6 @@ export enum DashboardtypesPanelPluginKindDTO {
|
||||
'signoz/HistogramPanel' = 'signoz/HistogramPanel',
|
||||
'signoz/ListPanel' = 'signoz/ListPanel',
|
||||
}
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type DashboardtypesPatchableDashboardV2DTO =
|
||||
| DashboardtypesJSONPatchOperationDTO[]
|
||||
| null;
|
||||
|
||||
export interface DashboardtypesPostableDashboardV2DTO {
|
||||
/**
|
||||
* @type boolean
|
||||
@@ -4757,26 +4706,6 @@ export enum DashboardtypesQueryPluginKindDTO {
|
||||
'signoz/ClickHouseSQL' = 'signoz/ClickHouseSQL',
|
||||
'signoz/TraceOperator' = 'signoz/TraceOperator',
|
||||
}
|
||||
export interface DashboardtypesUpdatableDashboardV2DTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
image?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
schemaVersion: string;
|
||||
spec: DashboardtypesDashboardSpecDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
tags: TagtypesPostableTagDTO[] | null;
|
||||
}
|
||||
|
||||
export interface DashboardtypesUpdatablePublicDashboardDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -8695,18 +8624,6 @@ export type UpdateAccountPathParameters = {
|
||||
cloudProvider: string;
|
||||
id: string;
|
||||
};
|
||||
export type ListAccountServicesMetadataPathParameters = {
|
||||
cloudProvider: string;
|
||||
id: string;
|
||||
};
|
||||
export type ListAccountServicesMetadata200 = {
|
||||
data: CloudintegrationtypesGettableServicesMetadataDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UpdateServicePathParameters = {
|
||||
cloudProvider: string;
|
||||
id: string;
|
||||
@@ -9560,34 +9477,6 @@ export type GetDashboardV2200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type PatchDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type PatchDashboardV2200 = {
|
||||
data: DashboardtypesGettableDashboardV2DTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UpdateDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type UpdateDashboardV2200 = {
|
||||
data: DashboardtypesGettableDashboardV2DTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UnlockDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type LockDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetFeatures200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { getWaterfallV4 } from 'api/generated/services/tracedetail';
|
||||
import { ApiV3Instance as axios } from 'api';
|
||||
import { omit } from 'lodash-es';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import {
|
||||
GetTraceV4PayloadProps,
|
||||
GetTraceV4SuccessResponse,
|
||||
GetTraceV3PayloadProps,
|
||||
GetTraceV3SuccessResponse,
|
||||
SpanV3,
|
||||
} from 'types/api/trace/getTraceV3';
|
||||
|
||||
const getTraceV4 = async (
|
||||
props: GetTraceV4PayloadProps,
|
||||
): Promise<SuccessResponse<GetTraceV4SuccessResponse> | ErrorResponse> => {
|
||||
const getTraceV3 = async (
|
||||
props: GetTraceV3PayloadProps,
|
||||
): Promise<SuccessResponse<GetTraceV3SuccessResponse> | ErrorResponse> => {
|
||||
let uncollapsedSpans = [...props.uncollapsedSpans];
|
||||
if (!props.isSelectedSpanIDUnCollapsed) {
|
||||
uncollapsedSpans = uncollapsedSpans.filter(
|
||||
@@ -18,37 +19,31 @@ const getTraceV4 = async (
|
||||
props.selectedSpanId &&
|
||||
!uncollapsedSpans.includes(props.selectedSpanId)
|
||||
) {
|
||||
// Backend only uses the uncollapsedSpans list (unlike V2 which also interprets
|
||||
// V3 backend only uses uncollapsedSpans list (unlike V2 which also interprets
|
||||
// isSelectedSpanIDUnCollapsed server-side), so explicitly add the selected span
|
||||
uncollapsedSpans.push(props.selectedSpanId);
|
||||
}
|
||||
const response = await getWaterfallV4(
|
||||
{ traceID: props.traceId },
|
||||
{
|
||||
selectedSpanId: props.selectedSpanId,
|
||||
uncollapsedSpans,
|
||||
limit: 10000,
|
||||
},
|
||||
const postData: GetTraceV3PayloadProps = {
|
||||
...props,
|
||||
uncollapsedSpans,
|
||||
limit: 10000,
|
||||
};
|
||||
const response = await axios.post<GetTraceV3SuccessResponse>(
|
||||
`/traces/${props.traceId}/waterfall`,
|
||||
omit(postData, 'traceId'),
|
||||
);
|
||||
|
||||
// Generated client unwraps the axios response; .data is the waterfall payload.
|
||||
// Wire spans carry time_unix; SpanV3's timestamp + 'service.name' are derived below.
|
||||
type WireSpan = Omit<SpanV3, 'timestamp' | 'service.name'> & {
|
||||
time_unix: number;
|
||||
};
|
||||
const rawPayload = response.data as unknown as Omit<
|
||||
GetTraceV4SuccessResponse,
|
||||
'spans'
|
||||
> & { spans: WireSpan[] | null };
|
||||
// V3 API wraps response in { status, data }
|
||||
const rawPayload = (response.data as any).data || response.data;
|
||||
|
||||
// Derive 'service.name' from resource for convenience — only derived field
|
||||
const spans: SpanV3[] = (rawPayload.spans || []).map((span) => ({
|
||||
const spans: SpanV3[] = (rawPayload.spans || []).map((span: any) => ({
|
||||
...span,
|
||||
'service.name': span.resource?.['service.name'] || '',
|
||||
timestamp: span.time_unix,
|
||||
}));
|
||||
|
||||
// API returns startTimestampMillis/endTimestampMillis as relative durations (ms from epoch offset),
|
||||
// V3 API returns startTimestampMillis/endTimestampMillis as relative durations (ms from epoch offset),
|
||||
// not absolute unix millis like V2. The span timestamps are absolute unix millis.
|
||||
// Convert by using the first span's timestamp as the base if there's a mismatch.
|
||||
let { startTimestampMillis, endTimestampMillis } = rawPayload;
|
||||
@@ -75,4 +70,4 @@ const getTraceV4 = async (
|
||||
};
|
||||
};
|
||||
|
||||
export default getTraceV4;
|
||||
export default getTraceV3;
|
||||
@@ -3,12 +3,17 @@ import { useLocation } from 'react-router-dom';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { handleContactSupport } from 'container/Integrations/utils';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
|
||||
import FeedbackModal from '../FeedbackModal';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
@@ -30,6 +35,7 @@ jest.mock('container/Integrations/utils', () => ({
|
||||
handleContactSupport: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockLogEvent = logEvent as jest.MockedFunction<typeof logEvent>;
|
||||
const mockUseLocation = useLocation as jest.Mock;
|
||||
const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock;
|
||||
const mockHandleContactSupport = handleContactSupport as jest.Mock;
|
||||
@@ -44,7 +50,6 @@ const mockLocation = {
|
||||
describe('FeedbackModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
logEventMock.mockReturnValue(Promise.resolve() as never);
|
||||
mockUseLocation.mockReturnValue(mockLocation);
|
||||
mockUseGetTenantLicense.mockReturnValue({
|
||||
isCloudUser: false,
|
||||
@@ -111,7 +116,7 @@ describe('FeedbackModal', () => {
|
||||
await user.type(textarea, testFeedback);
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith('Feedback: Submitted', {
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
|
||||
data: testFeedback,
|
||||
type: 'feedback',
|
||||
page: mockLocation.pathname,
|
||||
@@ -144,7 +149,7 @@ describe('FeedbackModal', () => {
|
||||
await user.type(textarea, testFeedback);
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith('Feedback: Submitted', {
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
|
||||
data: testFeedback,
|
||||
type: 'reportBug',
|
||||
page: mockLocation.pathname,
|
||||
@@ -177,7 +182,7 @@ describe('FeedbackModal', () => {
|
||||
await user.type(textarea, testFeedback);
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith('Feedback: Submitted', {
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
|
||||
data: testFeedback,
|
||||
type: 'featureRequest',
|
||||
page: mockLocation.pathname,
|
||||
|
||||
@@ -2,11 +2,16 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
|
||||
import HeaderRightSection from '../HeaderRightSection';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
@@ -45,6 +50,7 @@ jest.mock('hooks/useIsAIAssistantEnabled', () => ({
|
||||
useIsAIAssistantEnabled: (): boolean => false,
|
||||
}));
|
||||
|
||||
const mockLogEvent = logEvent as jest.Mock;
|
||||
const mockUseLocation = useLocation as jest.Mock;
|
||||
const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock;
|
||||
|
||||
@@ -114,7 +120,7 @@ describe('HeaderRightSection', () => {
|
||||
|
||||
await user.click(feedbackButton!);
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith('Feedback: Clicked', {
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Clicked', {
|
||||
page: mockLocation.pathname,
|
||||
});
|
||||
expect(screen.getByTestId('feedback-modal')).toBeInTheDocument();
|
||||
@@ -127,7 +133,7 @@ describe('HeaderRightSection', () => {
|
||||
const shareButton = screen.getByRole('button', { name: /share/i });
|
||||
await user.click(shareButton);
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith('Share: Clicked', {
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Share: Clicked', {
|
||||
page: mockLocation.pathname,
|
||||
});
|
||||
expect(screen.getByTestId('share-modal')).toBeInTheDocument();
|
||||
@@ -144,7 +150,7 @@ describe('HeaderRightSection', () => {
|
||||
|
||||
await user.click(announcementsButton!);
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith('Announcements: Clicked', {
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Announcements: Clicked', {
|
||||
page: mockLocation.pathname,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,13 +5,18 @@ import { matchPath, useLocation } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ROUTES from 'constants/routes';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
|
||||
import ShareURLModal from '../ShareURLModal';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
@@ -48,6 +53,7 @@ Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const mockLogEvent = logEvent as jest.Mock;
|
||||
const mockUseLocation = useLocation as jest.Mock;
|
||||
const mockUseUrlQuery = useUrlQuery as jest.Mock;
|
||||
const mockUseSelector = useSelector as jest.Mock;
|
||||
@@ -119,7 +125,7 @@ describe('ShareURLModal', () => {
|
||||
await user.click(copyButton);
|
||||
|
||||
expect(mockHandleCopyToClipboard).toHaveBeenCalled();
|
||||
expect(logEventMock).toHaveBeenCalledWith('Share: Copy link clicked', {
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Share: Copy link clicked', {
|
||||
page: TEST_PATH,
|
||||
URL: expect.any(String),
|
||||
});
|
||||
|
||||
@@ -721,53 +721,6 @@ export const removeKeysFromExpression = (
|
||||
return result?.text ?? '';
|
||||
};
|
||||
|
||||
const escapeRegExp = (value: string): string =>
|
||||
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
export const createVariablePlaceholderRegExp = (
|
||||
variableName: string,
|
||||
): RegExp => {
|
||||
const escapedName = escapeRegExp(variableName);
|
||||
// (?![\w.]) prevents $env from matching inside $environment or $env.attr
|
||||
return new RegExp(
|
||||
`(\\$${escapedName}(?![\\w.])|\\{\\{\\s*\\.?${escapedName}\\s*\\}\\}|\\[\\[\\s*${escapedName}\\s*\\]\\])`,
|
||||
'g',
|
||||
);
|
||||
};
|
||||
|
||||
const matchesVariablePlaceholder = (
|
||||
text: string,
|
||||
variableName: string,
|
||||
): boolean => createVariablePlaceholderRegExp(variableName).test(text);
|
||||
|
||||
export const removeVariableFromExpression = (
|
||||
expression: string | undefined,
|
||||
variableName: string,
|
||||
): string => {
|
||||
if (!expression) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const queryPairs = extractQueryPairs(expression);
|
||||
|
||||
const keysToRemove = queryPairs
|
||||
.filter((pair) => {
|
||||
const singleValue = pair.value?.toString() ?? '';
|
||||
const listValues = (pair.valueList ?? []).join(' ');
|
||||
return (
|
||||
matchesVariablePlaceholder(singleValue, variableName) ||
|
||||
matchesVariablePlaceholder(listValues, variableName)
|
||||
);
|
||||
})
|
||||
.map((pair) => pair.key);
|
||||
|
||||
if (keysToRemove.length === 0) {
|
||||
return expression;
|
||||
}
|
||||
|
||||
return removeKeysFromExpression(expression, keysToRemove, `$${variableName}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert old having format to new having format
|
||||
* @param having - Array of old having objects with columnName, op, and value
|
||||
|
||||
@@ -139,6 +139,7 @@ jest.mock('react-query', (): unknown => {
|
||||
});
|
||||
|
||||
// mock other side-effecty modules
|
||||
jest.mock('api/common/logEvent', () => jest.fn());
|
||||
jest.mock('api/browser/localstorage/set', () => jest.fn());
|
||||
jest.mock('utils/error', () => ({ showErrorNotification: jest.fn() }));
|
||||
|
||||
|
||||
@@ -33,8 +33,7 @@ export const REACT_QUERY_KEY = {
|
||||
UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE',
|
||||
GET_ACTIVE_LICENSE_V3: 'GET_ACTIVE_LICENSE_V3',
|
||||
GET_TRACE_V2_WATERFALL: 'GET_TRACE_V2_WATERFALL',
|
||||
GET_TRACE_V4_WATERFALL: 'GET_TRACE_V4_WATERFALL',
|
||||
GET_TRACE_AGGREGATIONS: 'GET_TRACE_AGGREGATIONS',
|
||||
GET_TRACE_V3_WATERFALL: 'GET_TRACE_V3_WATERFALL',
|
||||
GET_TRACE_V2_FLAMEGRAPH: 'GET_TRACE_V2_FLAMEGRAPH',
|
||||
GET_POD_LIST: 'GET_POD_LIST',
|
||||
GET_NODE_LIST: 'GET_NODE_LIST',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { GlobalShortcuts } from 'constants/shortcuts/globalShortcuts';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import {
|
||||
@@ -24,6 +24,8 @@ jest.mock('providers/cmdKProvider', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/common/logEvent', () => jest.fn());
|
||||
|
||||
// Mock the AppContext
|
||||
const mockUpdateUserPreferenceInContext = jest.fn();
|
||||
|
||||
@@ -137,7 +139,7 @@ describe('Sidebar Toggle Shortcut', () => {
|
||||
it('should log the toggle event with correct parameters', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockHandleShortcut = jest.fn(() => {
|
||||
logEventMock('Global Shortcut: Sidebar Toggle', {
|
||||
logEvent('Global Shortcut: Sidebar Toggle', {
|
||||
previousState: false,
|
||||
newState: true,
|
||||
});
|
||||
@@ -153,13 +155,10 @@ describe('Sidebar Toggle Shortcut', () => {
|
||||
|
||||
await user.keyboard(SHIFT_B_KEYBOARD_SHORTCUT);
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Global Shortcut: Sidebar Toggle',
|
||||
{
|
||||
previousState: false,
|
||||
newState: true,
|
||||
},
|
||||
);
|
||||
expect(logEvent).toHaveBeenCalledWith('Global Shortcut: Sidebar Toggle', {
|
||||
previousState: false,
|
||||
newState: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should update user preference in context', async () => {
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { removeVariableReferencesFromDashboard } from './addTagFiltersToDashboard';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixture helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EMPTY_BUILDER = {
|
||||
queryData: [] as any,
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
};
|
||||
|
||||
const BASE_WIDGET = {
|
||||
opacity: '1',
|
||||
nullZeroValues: 'null',
|
||||
timePreferance: 'GLOBAL_TIME' as const,
|
||||
softMin: null,
|
||||
softMax: null,
|
||||
selectedLogFields: null,
|
||||
selectedTracesFields: null,
|
||||
};
|
||||
|
||||
const DEFAULT_QUERY_DATA = {
|
||||
queryName: 'q1',
|
||||
// In QB v5, expression holds the query label (A/B/C), not a filter expression
|
||||
expression: 'A',
|
||||
dataSource: DataSource.METRICS,
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
filters: { items: [] as any[], op: 'AND' as const },
|
||||
legend: '',
|
||||
disabled: false,
|
||||
having: [],
|
||||
limit: null,
|
||||
stepInterval: null,
|
||||
orderBy: [],
|
||||
selectColumns: [],
|
||||
source: '' as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a dashboard with a single builder widget.
|
||||
* Only supply the fields your test actually cares about.
|
||||
*/
|
||||
const buildBuilderDashboard = (
|
||||
filterExpression: string,
|
||||
queryDataOverrides: Record<string, any> = {},
|
||||
): Dashboard => ({
|
||||
id: 'dash1',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
createdBy: '',
|
||||
updatedBy: '',
|
||||
data: {
|
||||
title: 'Test Dashboard',
|
||||
widgets: [
|
||||
{
|
||||
...BASE_WIDGET,
|
||||
id: 'widget-1',
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
title: 'Widget 1',
|
||||
description: '',
|
||||
query: {
|
||||
id: 'query1',
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
...DEFAULT_QUERY_DATA,
|
||||
...queryDataOverrides,
|
||||
filter: { expression: filterExpression },
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
unit: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
variables: {},
|
||||
},
|
||||
});
|
||||
|
||||
const buildClickhouseDashboard = (query: string): Dashboard => ({
|
||||
id: 'dash-ch',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
createdBy: '',
|
||||
updatedBy: '',
|
||||
data: {
|
||||
title: 'CH',
|
||||
widgets: [
|
||||
{
|
||||
...BASE_WIDGET,
|
||||
id: 'w1',
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
title: '',
|
||||
description: '',
|
||||
query: {
|
||||
id: 'q1',
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
promql: [],
|
||||
clickhouse_sql: [{ name: 'A', query, legend: '', disabled: false }],
|
||||
builder: EMPTY_BUILDER,
|
||||
unit: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
variables: {},
|
||||
},
|
||||
});
|
||||
|
||||
const buildPromqlDashboard = (query: string): Dashboard => ({
|
||||
id: 'dash-prom',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
createdBy: '',
|
||||
updatedBy: '',
|
||||
data: {
|
||||
title: 'PromQL Dashboard',
|
||||
widgets: [
|
||||
{
|
||||
...BASE_WIDGET,
|
||||
id: 'widget-prom',
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
title: 'PromQL Widget',
|
||||
description: '',
|
||||
query: {
|
||||
id: 'query-prom',
|
||||
queryType: EQueryType.PROM,
|
||||
promql: [{ name: 'A', query, legend: '', disabled: false }],
|
||||
clickhouse_sql: [],
|
||||
builder: EMPTY_BUILDER,
|
||||
unit: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
variables: {},
|
||||
},
|
||||
});
|
||||
|
||||
/** Run removeVariableReferencesFromDashboard on a single-widget clickhouse dashboard and return the cleaned SQL. */
|
||||
const chQuery = (sql: string, varName: string): string => {
|
||||
const result = removeVariableReferencesFromDashboard(
|
||||
buildClickhouseDashboard(sql),
|
||||
varName,
|
||||
);
|
||||
return (result!.data.widgets![0] as any).query.clickhouse_sql[0].query;
|
||||
};
|
||||
|
||||
/** Extract the first builder queryData from a cleaned dashboard. */
|
||||
const firstBuilderQueryData = (dashboard: Dashboard | undefined): any =>
|
||||
(dashboard!.data.widgets![0] as any).query.builder.queryData[0];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('removeVariableReferencesFromDashboard', () => {
|
||||
describe('builder filter expression cleanup', () => {
|
||||
it('removes a variable clause from filter.expression', () => {
|
||||
const dashboard = buildBuilderDashboard(
|
||||
"service.name IN $service AND env = 'prod'",
|
||||
);
|
||||
|
||||
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
|
||||
|
||||
expect(firstBuilderQueryData(result).filter.expression).toBe("env = 'prod'");
|
||||
});
|
||||
|
||||
it('leaves no dangling AND/OR after removing a variable clause', () => {
|
||||
const dashboard = buildBuilderDashboard(
|
||||
"service.name IN $service AND env = 'prod'",
|
||||
);
|
||||
|
||||
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
|
||||
const { expression } = firstBuilderQueryData(result).filter;
|
||||
|
||||
expect(expression).toBe("env = 'prod'");
|
||||
expect(expression).not.toMatch(/^\s*(AND|OR)/i);
|
||||
expect(expression).not.toMatch(/(AND|OR)\s*$/i);
|
||||
});
|
||||
|
||||
it('does not remove $environment clause when deleting $env', () => {
|
||||
const dashboard = buildBuilderDashboard(
|
||||
'env = $env AND deployment.environment = $environment',
|
||||
);
|
||||
|
||||
const result = removeVariableReferencesFromDashboard(dashboard, 'env');
|
||||
|
||||
expect(firstBuilderQueryData(result).filter.expression).toBe(
|
||||
'deployment.environment = $environment',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves literal filter expressions untouched when removing a variable', () => {
|
||||
const dashboard = buildBuilderDashboard(
|
||||
"service.name = 'api-gateway' AND env = 'prod'",
|
||||
);
|
||||
|
||||
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
|
||||
|
||||
expect(firstBuilderQueryData(result).filter.expression).toBe(
|
||||
"service.name = 'api-gateway' AND env = 'prod'",
|
||||
);
|
||||
});
|
||||
|
||||
it('removes only the variable clause, preserving a literal clause on the same key', () => {
|
||||
const dashboard = buildBuilderDashboard(
|
||||
"service.name IN $service AND service.name = 'api-gateway'",
|
||||
);
|
||||
|
||||
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
|
||||
|
||||
expect(firstBuilderQueryData(result).filter.expression).toBe(
|
||||
"service.name = 'api-gateway'",
|
||||
);
|
||||
});
|
||||
|
||||
it('returns filter.expression unchanged when the variable has no clauses in it', () => {
|
||||
const dashboard = buildBuilderDashboard("env = 'prod'");
|
||||
|
||||
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
|
||||
|
||||
expect(firstBuilderQueryData(result).filter.expression).toBe("env = 'prod'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('PromQL query cleanup', () => {
|
||||
it('removes variable placeholder from a promql query', () => {
|
||||
const result = removeVariableReferencesFromDashboard(
|
||||
buildPromqlDashboard('sum(rate(http_requests_total{$service}[5m]))'),
|
||||
'service',
|
||||
);
|
||||
|
||||
const widget = result!.data.widgets![0] as any;
|
||||
expect(widget.query.promql[0].query).toBe(
|
||||
'sum(rate(http_requests_total{}[5m]))',
|
||||
);
|
||||
});
|
||||
|
||||
it('strips only the variable token inside a PromQL label matcher (token-only path)', () => {
|
||||
const result = removeVariableReferencesFromDashboard(
|
||||
buildPromqlDashboard('up{env="$env", job="api"}'),
|
||||
'env',
|
||||
);
|
||||
|
||||
const widget = result!.data.widgets![0] as any;
|
||||
expect(widget.query.promql[0].query).toBe('up{env="", job="api"}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ClickHouse SQL query cleanup', () => {
|
||||
it('removes a quoted variable clause and its WHERE keyword', () => {
|
||||
expect(
|
||||
chQuery(
|
||||
"SELECT count() FROM signoz_logs WHERE service_name = '$service'",
|
||||
'service',
|
||||
),
|
||||
).toBe('SELECT count() FROM signoz_logs');
|
||||
});
|
||||
|
||||
it('removes a middle clause: AND env={{.env}} AND', () => {
|
||||
expect(
|
||||
chQuery('SELECT count() FROM t WHERE a=1 AND env={{.env}} AND b=2', 'env'),
|
||||
).toBe('SELECT count() FROM t WHERE a=1 AND b=2');
|
||||
});
|
||||
|
||||
it('removes the first clause: env={{.env}} AND rest', () => {
|
||||
expect(
|
||||
chQuery('SELECT count() FROM t WHERE env={{.env}} AND b=2', 'env'),
|
||||
).toBe('SELECT count() FROM t WHERE b=2');
|
||||
});
|
||||
|
||||
it('removes the last clause: rest AND env=$env', () => {
|
||||
expect(chQuery('SELECT count() FROM t WHERE a=1 AND env=$env', 'env')).toBe(
|
||||
'SELECT count() FROM t WHERE a=1',
|
||||
);
|
||||
});
|
||||
|
||||
it('removes a clause with double-bracket syntax: service=[[svc]]', () => {
|
||||
expect(chQuery('SELECT count() FROM t WHERE service=[[svc]]', 'svc')).toBe(
|
||||
'SELECT count() FROM t',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to token-only strip for a bare variable in SELECT', () => {
|
||||
expect(chQuery('SELECT $metric FROM table', 'metric')).toBe(
|
||||
'SELECT FROM table',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('is idempotent — calling twice produces the same result', () => {
|
||||
const dashboard = buildBuilderDashboard(
|
||||
"service.name IN $service AND env = 'prod'",
|
||||
);
|
||||
|
||||
const once = removeVariableReferencesFromDashboard(dashboard, 'service');
|
||||
const twice = removeVariableReferencesFromDashboard(once, 'service');
|
||||
|
||||
expect(twice).toStrictEqual(once);
|
||||
});
|
||||
|
||||
it('handles a dashboard with no widgets without throwing', () => {
|
||||
const dashboard: Dashboard = {
|
||||
id: 'dash-empty',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
createdBy: '',
|
||||
updatedBy: '',
|
||||
data: { title: 'Empty Dashboard', widgets: undefined, variables: {} },
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
removeVariableReferencesFromDashboard(dashboard, 'service'),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,6 @@
|
||||
import {
|
||||
convertFiltersToExpressionWithExistingQuery,
|
||||
createVariablePlaceholderRegExp,
|
||||
removeKeysFromExpression,
|
||||
removeVariableFromExpression,
|
||||
} from 'components/QueryBuilderV2/utils';
|
||||
import { cloneDeep, isArray, isEmpty } from 'lodash-es';
|
||||
import { Dashboard, Widgets } from 'types/api/dashboard/getAll';
|
||||
@@ -159,139 +157,6 @@ const updateAfterRemoval = (
|
||||
};
|
||||
};
|
||||
|
||||
const removeVariablePlaceholders = (
|
||||
text: string | undefined,
|
||||
variableName: string,
|
||||
): string => {
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const tokenPattern = createVariablePlaceholderRegExp(variableName);
|
||||
|
||||
// Step 1: attempt clause-aware removal for SQL WHERE patterns.
|
||||
// Strips the entire `key op $var` unit plus its adjacent AND/OR so we
|
||||
// never leave a dangling `key = ` in unquoted ClickHouse SQL clauses.
|
||||
// Handles three shapes:
|
||||
// (a) preceding conjunction: AND key = $var
|
||||
// (b) following conjunction: key = $var AND
|
||||
// (c) standalone clause: key = $var (end of expression)
|
||||
const escapedToken = tokenPattern.source;
|
||||
const clausePattern = new RegExp(
|
||||
// (a) conjunction before the clause
|
||||
`\\s*\\b(?:AND|OR)\\b\\s+[\\w."'\\[\\]]+\\s*(?:=|!=|<>|LIKE|ILIKE|IN|NOT\\s+IN)\\s*'?${escapedToken}'?` +
|
||||
// (b)+(c) clause first, optional conjunction after
|
||||
`|[\\w."'\\[\\]]+\\s*(?:=|!=|<>|LIKE|ILIKE|IN|NOT\\s+IN)\\s*'?${escapedToken}'?(?:\\s*\\b(?:AND|OR)\\b)?`,
|
||||
'gi',
|
||||
);
|
||||
|
||||
const withClauseRemoval = text.replace(clausePattern, '');
|
||||
if (withClauseRemoval !== text) {
|
||||
return withClauseRemoval
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.replace(/\bWHERE\s*$/i, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Step 2: fallback — bare variable usage outside a key-op-value pattern
|
||||
// (e.g. SELECT $metric, LIMIT $n). Token-only removal is correct here.
|
||||
return text
|
||||
.replace(tokenPattern, '')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim();
|
||||
};
|
||||
|
||||
const removeVariableReferencesFromQueryData = (
|
||||
queryData: IBuilderQuery,
|
||||
variableName: string,
|
||||
): IBuilderQuery => {
|
||||
const updatedFilter = queryData.filter?.expression
|
||||
? {
|
||||
...queryData.filter,
|
||||
expression: removeVariableFromExpression(
|
||||
queryData.filter.expression,
|
||||
variableName,
|
||||
),
|
||||
}
|
||||
: queryData.filter;
|
||||
|
||||
return { ...queryData, filter: updatedFilter };
|
||||
};
|
||||
|
||||
const removeVariableReferencesFromWidget = (
|
||||
widget: Widgets,
|
||||
variableName: string,
|
||||
): Widgets => {
|
||||
let updatedWidget = { ...widget };
|
||||
|
||||
if (updatedWidget.query?.builder?.queryData) {
|
||||
updatedWidget = {
|
||||
...updatedWidget,
|
||||
query: {
|
||||
...updatedWidget.query,
|
||||
builder: {
|
||||
...updatedWidget.query.builder,
|
||||
queryData: updatedWidget.query.builder.queryData.map((queryData) =>
|
||||
removeVariableReferencesFromQueryData(queryData, variableName),
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (updatedWidget.query?.promql) {
|
||||
updatedWidget = {
|
||||
...updatedWidget,
|
||||
query: {
|
||||
...updatedWidget.query,
|
||||
promql: updatedWidget.query.promql.map((promqlQuery) => ({
|
||||
...promqlQuery,
|
||||
query: removeVariablePlaceholders(promqlQuery.query, variableName),
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (updatedWidget.query?.clickhouse_sql) {
|
||||
updatedWidget = {
|
||||
...updatedWidget,
|
||||
query: {
|
||||
...updatedWidget.query,
|
||||
clickhouse_sql: updatedWidget.query.clickhouse_sql.map((sqlQuery) => ({
|
||||
...sqlQuery,
|
||||
query: removeVariablePlaceholders(sqlQuery.query, variableName),
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return updatedWidget;
|
||||
};
|
||||
|
||||
export const removeVariableReferencesFromDashboard = (
|
||||
dashboard: Dashboard | undefined,
|
||||
variableName: string,
|
||||
): Dashboard | undefined => {
|
||||
if (!dashboard || !variableName) {
|
||||
return dashboard;
|
||||
}
|
||||
|
||||
const updatedDashboard = cloneDeep(dashboard);
|
||||
|
||||
if (updatedDashboard.data.widgets) {
|
||||
updatedDashboard.data.widgets = updatedDashboard.data.widgets.map(
|
||||
(widget) => {
|
||||
if ('query' in widget) {
|
||||
return removeVariableReferencesFromWidget(widget as Widgets, variableName);
|
||||
}
|
||||
return widget;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return updatedDashboard;
|
||||
};
|
||||
|
||||
/**
|
||||
* A function that takes a dashboard configuration and a list of tag filters
|
||||
* and returns an updated dashboard with the filters appended to widget queries.
|
||||
|
||||
@@ -18,11 +18,10 @@ import { convertVariablesToDbFormat } from 'container/DashboardContainer/Dashboa
|
||||
import { useAddDynamicVariableToPanels } from 'hooks/dashboard/useAddDynamicVariableToPanels';
|
||||
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
|
||||
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
import { removeVariableReferencesFromDashboard } from './addTagFiltersToDashboard';
|
||||
|
||||
import { TVariableMode } from './types';
|
||||
import VariableItem from './VariableItem/VariableItem';
|
||||
@@ -93,6 +92,8 @@ function VariablesSettings({
|
||||
const { dashboardData, setDashboardData } = useDashboardStore();
|
||||
const { dashboardVariables } = useDashboardVariables();
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const [variablesTableData, setVariablesTableData] = useState<any>([]);
|
||||
const [variblesOrderArr, setVariablesOrderArr] = useState<number[]>([]);
|
||||
const [existingVariableNamesMap, setExistingVariableNamesMap] = useState<
|
||||
@@ -200,7 +201,9 @@ function VariablesSettings({
|
||||
onSuccess: (updatedDashboard) => {
|
||||
if (updatedDashboard.data) {
|
||||
setDashboardData(updatedDashboard.data);
|
||||
toast.success(t('variable_updated_successfully'));
|
||||
notifications.success({
|
||||
message: t('variable_updated_successfully'),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -253,11 +256,6 @@ function VariablesSettings({
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = (): void => {
|
||||
if (!dashboardData || !variableToDelete.current) {
|
||||
setDeleteVariableModal(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const newVariablesArr = variablesTableData.filter(
|
||||
(variable: IDashboardVariable) =>
|
||||
variable.id !== variableToDelete?.current?.id,
|
||||
@@ -265,31 +263,7 @@ function VariablesSettings({
|
||||
|
||||
const updatedVariables = convertVariablesToDbFormat(newVariablesArr);
|
||||
|
||||
const cleanedDashboard =
|
||||
removeVariableReferencesFromDashboard(
|
||||
dashboardData,
|
||||
variableToDelete.current.name || '',
|
||||
) || dashboardData;
|
||||
|
||||
updateMutation.mutateAsync(
|
||||
{
|
||||
id: dashboardData.id,
|
||||
|
||||
data: {
|
||||
...cleanedDashboard.data,
|
||||
variables: updatedVariables,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: (updatedDashboard) => {
|
||||
if (updatedDashboard.data) {
|
||||
setDashboardData(updatedDashboard.data);
|
||||
toast.success(t('variable_updated_successfully'));
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
updateVariables(updatedVariables);
|
||||
variableToDelete.current = null;
|
||||
setDeleteVariableModal(false);
|
||||
};
|
||||
@@ -502,7 +476,6 @@ function VariablesSettings({
|
||||
open={deleteVariableModal}
|
||||
onOk={handleDeleteConfirm}
|
||||
onCancel={handleDeleteCancel}
|
||||
okButtonProps={{ loading: updateMutation.isLoading }}
|
||||
>
|
||||
<Typography.Text>
|
||||
Are you sure you want to delete variable{' '}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { Events } from 'constants/events';
|
||||
import { DEFAULT_PIN_TOOLTIP_KEY } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import TooltipFooter from '../TooltipFooter';
|
||||
|
||||
const mockLogEvent = jest.fn();
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]): unknown => mockLogEvent(...args),
|
||||
}));
|
||||
|
||||
describe('TooltipFooter', () => {
|
||||
const defaultProps = {
|
||||
id: 'panel-123',
|
||||
@@ -78,7 +84,7 @@ describe('TooltipFooter', () => {
|
||||
|
||||
await user.click(screen.getByTestId('uplot-tooltip-unpin'));
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(Events.TOOLTIP_UNPINNED, {
|
||||
expect(mockLogEvent).toHaveBeenCalledWith(Events.TOOLTIP_UNPINNED, {
|
||||
id: 'panel-123',
|
||||
});
|
||||
expect(dismiss).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { DEFAULT_ENTITY_VERSION, ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
@@ -66,6 +67,20 @@ function GridCardGraph({
|
||||
const [errorMessage, setErrorMessage] = useState<string>();
|
||||
const [isInternalServerError, setIsInternalServerError] =
|
||||
useState<boolean>(false);
|
||||
const queryRangeCalledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!queryRangeCalledRef.current) {
|
||||
Sentry.captureEvent({
|
||||
message: `Dashboard query range not called within expected timeframe for widget ${widget?.id}`,
|
||||
level: 'warning',
|
||||
});
|
||||
}
|
||||
}, 120000);
|
||||
return (): void => clearTimeout(timeoutId);
|
||||
}, [widget?.id]);
|
||||
|
||||
const {
|
||||
minTime,
|
||||
maxTime,
|
||||
@@ -256,12 +271,14 @@ function GridCardGraph({
|
||||
});
|
||||
}
|
||||
}
|
||||
queryRangeCalledRef.current = true;
|
||||
},
|
||||
onSettled: (data) => {
|
||||
dataAvailable?.(
|
||||
isDataAvailableByPanelType(data?.payload?.data, widget?.panelTypes),
|
||||
);
|
||||
getGraphData?.(data?.payload?.data);
|
||||
queryRangeCalledRef.current = true;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -8,16 +8,16 @@ import { Tabs } from '@signozhq/ui/tabs';
|
||||
import { Skeleton } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
getListAccountServicesMetadataQueryKey,
|
||||
getListServicesMetadataQueryKey,
|
||||
invalidateGetService,
|
||||
invalidateListAccountServicesMetadata,
|
||||
invalidateListServicesMetadata,
|
||||
useGetService,
|
||||
useUpdateService,
|
||||
} from 'api/generated/services/cloudintegration';
|
||||
import {
|
||||
CloudintegrationtypesServiceConfigDTO,
|
||||
CloudintegrationtypesServiceDTO,
|
||||
ListAccountServicesMetadata200,
|
||||
ListServicesMetadata200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import CloudServiceDataCollected from 'components/CloudIntegrations/CloudServiceDataCollected/CloudServiceDataCollected';
|
||||
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
|
||||
@@ -240,12 +240,16 @@ function ServiceDetails({
|
||||
// instead of waiting for the refetch to complete.
|
||||
reset(nextFormValues);
|
||||
|
||||
const servicesListQueryKey = getListAccountServicesMetadataQueryKey({
|
||||
cloudProvider: type,
|
||||
id: cloudAccountId,
|
||||
});
|
||||
const servicesListQueryKey = getListServicesMetadataQueryKey(
|
||||
{
|
||||
cloudProvider: type,
|
||||
},
|
||||
{
|
||||
cloud_integration_id: cloudAccountId,
|
||||
},
|
||||
);
|
||||
|
||||
queryClient.setQueryData<ListAccountServicesMetadata200 | undefined>(
|
||||
queryClient.setQueryData<ListServicesMetadata200 | undefined>(
|
||||
servicesListQueryKey,
|
||||
(prev) => {
|
||||
if (!prev?.data?.services?.length) {
|
||||
@@ -279,10 +283,15 @@ function ServiceDetails({
|
||||
},
|
||||
);
|
||||
|
||||
invalidateListAccountServicesMetadata(queryClient, {
|
||||
cloudProvider: type,
|
||||
id: cloudAccountId,
|
||||
});
|
||||
invalidateListServicesMetadata(
|
||||
queryClient,
|
||||
{
|
||||
cloudProvider: type,
|
||||
},
|
||||
{
|
||||
cloud_integration_id: cloudAccountId,
|
||||
},
|
||||
);
|
||||
|
||||
logEvent(`${type} Integration: Service settings saved`, {
|
||||
cloudAccountId,
|
||||
|
||||
@@ -55,6 +55,7 @@ const buildServiceDetailsResponse = (
|
||||
},
|
||||
},
|
||||
},
|
||||
telemetryCollectionStrategy: { aws: {} },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import type { KeyboardEvent, MouseEvent } from 'react';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { CloudintegrationtypesServiceDashboardDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
const DISABLED_TOOLTIP =
|
||||
'Enable metrics collection for this service to view this dashboard.';
|
||||
|
||||
function DashboardCard({
|
||||
dashboard,
|
||||
isInteractive,
|
||||
}: {
|
||||
dashboard: CloudintegrationtypesServiceDashboardDTO;
|
||||
isInteractive: boolean;
|
||||
}): JSX.Element {
|
||||
const dashboardId = dashboard.integrationDashboard?.dashboardId;
|
||||
const isClickable = Boolean(dashboardId) && isInteractive;
|
||||
const dashboardUrl = dashboardId ? `/dashboard/${dashboardId}` : '';
|
||||
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const interactiveProps = isClickable
|
||||
? {
|
||||
role: 'button',
|
||||
tabIndex: 0,
|
||||
onClick: (event: MouseEvent<HTMLDivElement>): void => {
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
openInNewTab(dashboardUrl);
|
||||
return;
|
||||
}
|
||||
safeNavigate(dashboardUrl);
|
||||
},
|
||||
onKeyDown: (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
safeNavigate(dashboardUrl);
|
||||
}
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
const card = (
|
||||
<div
|
||||
className={`aws-service-dashboard-item ${
|
||||
isClickable
|
||||
? 'aws-service-dashboard-item-clickable'
|
||||
: 'aws-service-dashboard-item-disabled'
|
||||
} `}
|
||||
{...interactiveProps}
|
||||
>
|
||||
<div className="aws-service-dashboard-item-content">
|
||||
<div className="aws-service-dashboard-item-title">{dashboard.title}</div>
|
||||
<div className="aws-service-dashboard-item-description">
|
||||
{dashboard.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!dashboardId) {
|
||||
return <TooltipSimple title={DISABLED_TOOLTIP}>{card}</TooltipSimple>;
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
export default DashboardCard;
|
||||
@@ -53,11 +53,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.aws-service-dashboard-item-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.aws-service-dashboard-item-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import {
|
||||
CloudintegrationtypesServiceDashboardDTO,
|
||||
CloudintegrationtypesDashboardDTO,
|
||||
CloudintegrationtypesServiceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { withBasePath } from 'utils/basePath';
|
||||
|
||||
import DashboardCard from './DashboardCard';
|
||||
import './ServiceDashboards.styles.scss';
|
||||
|
||||
function ServiceDashboards({
|
||||
@@ -14,6 +16,7 @@ function ServiceDashboards({
|
||||
isInteractive?: boolean;
|
||||
}): JSX.Element {
|
||||
const dashboards = service?.assets?.dashboards || [];
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
if (!dashboards.length) {
|
||||
return <></>;
|
||||
}
|
||||
@@ -22,20 +25,68 @@ function ServiceDashboards({
|
||||
<div className="aws-service-dashboards">
|
||||
<div className="aws-service-dashboards-title">Dashboards</div>
|
||||
<div className="aws-service-dashboards-items">
|
||||
{dashboards.map(
|
||||
(dashboard: CloudintegrationtypesServiceDashboardDTO, index: number) => {
|
||||
const key =
|
||||
dashboard.integrationDashboard?.dashboardId ||
|
||||
`${dashboard.title}-${index}`;
|
||||
return (
|
||||
<DashboardCard
|
||||
key={key}
|
||||
dashboard={dashboard}
|
||||
isInteractive={isInteractive}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
{dashboards.map((dashboard: CloudintegrationtypesDashboardDTO) => {
|
||||
if (!dashboard.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dashboardUrl = `/dashboard/${dashboard.id}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={dashboard.id}
|
||||
className={`aws-service-dashboard-item ${
|
||||
isInteractive ? 'aws-service-dashboard-item-clickable' : ''
|
||||
}`}
|
||||
role={isInteractive ? 'button' : undefined}
|
||||
tabIndex={isInteractive ? 0 : -1}
|
||||
onClick={(event): void => {
|
||||
if (!isInteractive) {
|
||||
return;
|
||||
}
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
window.open(
|
||||
withBasePath(dashboardUrl),
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
return;
|
||||
}
|
||||
safeNavigate(dashboardUrl);
|
||||
}}
|
||||
onAuxClick={(event): void => {
|
||||
if (!isInteractive) {
|
||||
return;
|
||||
}
|
||||
if (event.button === 1) {
|
||||
window.open(
|
||||
withBasePath(dashboardUrl),
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event): void => {
|
||||
if (!isInteractive) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
safeNavigate(dashboardUrl);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="aws-service-dashboard-item-content">
|
||||
<div className="aws-service-dashboard-item-title">
|
||||
{dashboard.title}
|
||||
</div>
|
||||
<div className="aws-service-dashboard-item-description">
|
||||
{dashboard.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom-v5-compat';
|
||||
import { Skeleton } from 'antd';
|
||||
import {
|
||||
useListAccounts,
|
||||
useListAccountServicesMetadata,
|
||||
useListServicesMetadata,
|
||||
} from 'api/generated/services/cloudintegration';
|
||||
import { useListServicesMetadata } from 'api/generated/services/cloudintegration';
|
||||
import type { CloudintegrationtypesServiceMetadataDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import { IntegrationType } from 'container/Integrations/types';
|
||||
@@ -24,33 +20,17 @@ function ServicesList({
|
||||
}: ServicesListProps): JSX.Element {
|
||||
const urlQuery = useUrlQuery();
|
||||
const navigate = useNavigate();
|
||||
const isAccountConnected = Boolean(cloudAccountId);
|
||||
const { data: listAccountsResponse, isLoading: isAccountsLoading } =
|
||||
useListAccounts({ cloudProvider: type });
|
||||
const hasConnectedAccounts =
|
||||
(listAccountsResponse?.data?.accounts?.length ?? 0) > 0;
|
||||
const hasValidCloudAccountId = Boolean(cloudAccountId);
|
||||
const serviceQueryParams = hasValidCloudAccountId
|
||||
? { cloud_integration_id: cloudAccountId }
|
||||
: undefined;
|
||||
|
||||
const { data: accountServicesMetadata, isLoading: isAccountServicesLoading } =
|
||||
useListAccountServicesMetadata(
|
||||
{ cloudProvider: type, id: cloudAccountId },
|
||||
{ query: { enabled: isAccountConnected } },
|
||||
);
|
||||
|
||||
const {
|
||||
data: providerServicesMetadata,
|
||||
isLoading: isProviderServicesLoading,
|
||||
} = useListServicesMetadata({ cloudProvider: type }, undefined, {
|
||||
query: { enabled: !isAccountsLoading && !hasConnectedAccounts },
|
||||
});
|
||||
|
||||
const servicesMetadata = hasConnectedAccounts
|
||||
? accountServicesMetadata
|
||||
: providerServicesMetadata;
|
||||
const isLoading =
|
||||
isAccountsLoading ||
|
||||
(hasConnectedAccounts
|
||||
? isAccountServicesLoading || !isAccountConnected
|
||||
: isProviderServicesLoading);
|
||||
const { data: servicesMetadata, isLoading } = useListServicesMetadata(
|
||||
{
|
||||
cloudProvider: type,
|
||||
},
|
||||
serviceQueryParams,
|
||||
);
|
||||
|
||||
const awsServices = useMemo(
|
||||
() => servicesMetadata?.data?.services ?? [],
|
||||
|
||||
@@ -89,7 +89,7 @@ export function AlertsEmptyState({
|
||||
onClick={onClickNewAlertHandler}
|
||||
disabled={!addNewAlert}
|
||||
loading={loading}
|
||||
testId="add-alert"
|
||||
data-testid="add-alert"
|
||||
>
|
||||
<span className={styles.buttonContent}>
|
||||
<Plus size="md" />
|
||||
@@ -97,12 +97,7 @@ export function AlertsEmptyState({
|
||||
</span>
|
||||
</Button>
|
||||
{onRefresh && (
|
||||
<Button
|
||||
onClick={onRefresh}
|
||||
prefix={<RefreshCw />}
|
||||
color="secondary"
|
||||
testId="list-alerts-empty-refresh-button"
|
||||
>
|
||||
<Button onClick={onRefresh} prefix={<RefreshCw />} color="secondary">
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { findAlertRow, renderListAlertRules } from './_helpers';
|
||||
|
||||
async function openActionsMenu(row: HTMLElement): Promise<void> {
|
||||
const trigger = row.querySelector(
|
||||
'[data-testid="alert-actions"]',
|
||||
) as HTMLElement | null;
|
||||
expect(trigger).not.toBeNull();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
await user.click(trigger as HTMLElement);
|
||||
// Radix renders the menu items in a portal once the trigger is activated.
|
||||
await screen.findByRole('menu');
|
||||
}
|
||||
|
||||
async function clickMenuItem(label: string): Promise<void> {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
const item = await screen.findByRole('menuitem', { name: label });
|
||||
await user.click(item);
|
||||
}
|
||||
|
||||
describe('ListAlertRules — actions menu', () => {
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
});
|
||||
|
||||
it('renders Enable/Disable/Edit/Edit in New Tab/Clone/Delete items after opening the menu', async () => {
|
||||
renderListAlertRules();
|
||||
const row = await findAlertRow('High CPU Alert');
|
||||
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
|
||||
|
||||
await openActionsMenu(row);
|
||||
|
||||
const items = screen.getAllByRole('menuitem');
|
||||
const labels = items.map((it) => it.textContent);
|
||||
expect(labels).toStrictEqual(
|
||||
expect.arrayContaining([
|
||||
'Edit',
|
||||
'Edit in New Tab',
|
||||
'Clone',
|
||||
'Delete',
|
||||
'Disable',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('disabled rule (rule-4) shows "Enable" instead of "Disable"', async () => {
|
||||
renderListAlertRules();
|
||||
const row = await findAlertRow('Disabled Alert');
|
||||
await openActionsMenu(row);
|
||||
|
||||
const items = screen.getAllByRole('menuitem');
|
||||
const labels = items.map((it) => it.textContent);
|
||||
expect(labels).toContain('Enable');
|
||||
expect(labels).not.toContain('Disable');
|
||||
});
|
||||
|
||||
it('toggle action: clicking Disable sends PATCH with disabled:true', async () => {
|
||||
let capturedBody: unknown = null;
|
||||
let capturedPath: string | null = null;
|
||||
server.use(
|
||||
rest.patch('http://localhost/api/v2/rules/:id', async (req, res, ctx) => {
|
||||
capturedBody = await req.json();
|
||||
capturedPath = req.params.id as string;
|
||||
return res(ctx.status(200), ctx.json({ status: 'success' }));
|
||||
}),
|
||||
);
|
||||
|
||||
renderListAlertRules();
|
||||
const row = await findAlertRow('High CPU Alert');
|
||||
await openActionsMenu(row);
|
||||
await clickMenuItem('Disable');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedBody).toStrictEqual(
|
||||
expect.objectContaining({ disabled: true }),
|
||||
);
|
||||
});
|
||||
expect(capturedPath).toBe('rule-1');
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Alert: Action',
|
||||
expect.objectContaining({ action: 'Enable/Disable', ruleId: 'rule-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('edit action: clicking Edit navigates via safeNavigate and logs event', async () => {
|
||||
renderListAlertRules();
|
||||
const row = await findAlertRow('High CPU Alert');
|
||||
await openActionsMenu(row);
|
||||
await clickMenuItem('Edit');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(safeNavigateMock).toHaveBeenCalled();
|
||||
});
|
||||
expect(safeNavigateMock.mock.calls[0][0]).toContain('ruleId=rule-1');
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Alert: Action',
|
||||
expect.objectContaining({ action: 'Edit', ruleId: 'rule-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('edit in new tab action: clicking opens with newTab:true', async () => {
|
||||
renderListAlertRules();
|
||||
const row = await findAlertRow('High CPU Alert');
|
||||
await openActionsMenu(row);
|
||||
await clickMenuItem('Edit in New Tab');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(safeNavigateMock).toHaveBeenCalled();
|
||||
});
|
||||
const [url, options] = safeNavigateMock.mock.calls[0];
|
||||
expect(url).toContain('ruleId=rule-1');
|
||||
expect(options).toStrictEqual(expect.objectContaining({ newTab: true }));
|
||||
});
|
||||
|
||||
it('clone action: sends POST with " - Copy" suffix and opens the cloned rule returned by the API', async () => {
|
||||
let capturedPostBody: unknown = null;
|
||||
server.use(
|
||||
rest.post('http://localhost/api/v2/rules', async (req, res, ctx) => {
|
||||
capturedPostBody = await req.json();
|
||||
return res(
|
||||
ctx.status(201),
|
||||
ctx.json({
|
||||
data: {
|
||||
...(capturedPostBody as Record<string, unknown>),
|
||||
id: 'cloned-from-server',
|
||||
},
|
||||
status: 'success',
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
renderListAlertRules();
|
||||
const row = await findAlertRow('High CPU Alert');
|
||||
await openActionsMenu(row);
|
||||
await clickMenuItem('Clone');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedPostBody).toStrictEqual(
|
||||
expect.objectContaining({ alert: 'High CPU Alert - Copy' }),
|
||||
);
|
||||
});
|
||||
|
||||
// The id from the server response round-trips into the navigate URL — this
|
||||
// protects against a regression where the code hardcodes the id.
|
||||
await waitFor(() => {
|
||||
expect(safeNavigateMock).toHaveBeenCalled();
|
||||
});
|
||||
expect(safeNavigateMock.mock.calls[0][0]).toContain(
|
||||
'ruleId=cloned-from-server',
|
||||
);
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Alert: Action',
|
||||
expect.objectContaining({ action: 'Clone', ruleId: 'rule-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('delete action: sends DELETE for the rule id', async () => {
|
||||
let deletedId: string | null = null;
|
||||
server.use(
|
||||
rest.delete('http://localhost/api/v2/rules/:id', (req, res, ctx) => {
|
||||
deletedId = req.params.id as string;
|
||||
return res(ctx.status(200), ctx.json({ status: 'success' }));
|
||||
}),
|
||||
);
|
||||
|
||||
renderListAlertRules();
|
||||
const row = await findAlertRow('High CPU Alert');
|
||||
await openActionsMenu(row);
|
||||
await clickMenuItem('Delete');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deletedId).toBe('rule-1');
|
||||
});
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Alert: Action',
|
||||
expect.objectContaining({ action: 'Delete', ruleId: 'rule-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('error path: PATCH is still attempted when server returns 500', async () => {
|
||||
let patchAttempted = false;
|
||||
server.use(
|
||||
rest.patch('http://localhost/api/v2/rules/:id', (_, res, ctx) => {
|
||||
patchAttempted = true;
|
||||
return res(ctx.status(500), ctx.json({ status: 'error' }));
|
||||
}),
|
||||
);
|
||||
|
||||
renderListAlertRules();
|
||||
const row = await findAlertRow('High CPU Alert');
|
||||
await openActionsMenu(row);
|
||||
await clickMenuItem('Disable');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(patchAttempted).toBe(true);
|
||||
});
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Alert: Action',
|
||||
expect.objectContaining({ action: 'Enable/Disable', ruleId: 'rule-1' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { renderListAlertRules } from './_helpers';
|
||||
|
||||
const COLUMN_STORAGE_KEY = '@signoz/table-columns/alert-rules-columns';
|
||||
|
||||
describe('ListAlertRules — columns selector', () => {
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('opens columns popover and lists toggleable columns', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
await user.click(screen.getByTestId('alert-columns-button'));
|
||||
|
||||
// Popover should reveal "Toggle Columns" heading + per-column labels.
|
||||
await screen.findByText('Toggle Columns');
|
||||
expect(screen.getByText('Created At')).toBeInTheDocument();
|
||||
expect(screen.getByText('Created By')).toBeInTheDocument();
|
||||
expect(screen.getByText('Updated At')).toBeInTheDocument();
|
||||
expect(screen.getByText('Updated By')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('default-hidden columns (Created At/By, Updated At/By) are not in the table header', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
const headers = document.querySelectorAll('th');
|
||||
const headerTexts = Array.from(headers).map((h) => h.textContent || '');
|
||||
expect(headerTexts.some((t) => t.includes('Created At'))).toBe(false);
|
||||
expect(headerTexts.some((t) => t.includes('Created By'))).toBe(false);
|
||||
expect(headerTexts.some((t) => t.includes('Updated At'))).toBe(false);
|
||||
expect(headerTexts.some((t) => t.includes('Updated By'))).toBe(false);
|
||||
});
|
||||
|
||||
it('toggling Created At on writes to localStorage and adds the header', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
const headersBefore = Array.from(document.querySelectorAll('th')).map(
|
||||
(h) => h.textContent ?? '',
|
||||
);
|
||||
expect(headersBefore.some((t) => t.includes('Created At'))).toBe(false);
|
||||
|
||||
await user.click(screen.getByTestId('alert-columns-button'));
|
||||
await screen.findByText('Toggle Columns');
|
||||
|
||||
const checkbox = document.getElementById('col-createdAt');
|
||||
expect(checkbox).not.toBeNull();
|
||||
await user.click(checkbox as HTMLElement);
|
||||
|
||||
await waitFor(() => {
|
||||
const stored = window.localStorage.getItem(COLUMN_STORAGE_KEY);
|
||||
expect(stored).not.toBeNull();
|
||||
const parsed = JSON.parse(stored as string);
|
||||
expect(parsed.hiddenColumnIds).not.toContain('createdAt');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const headersAfter = Array.from(document.querySelectorAll('th')).map(
|
||||
(h) => h.textContent ?? '',
|
||||
);
|
||||
expect(headersAfter.some((t) => t.includes('Created At'))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { alertRulesFixture } from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { screen } from 'tests/test-utils';
|
||||
|
||||
import { renderListAlertRules } from './_helpers';
|
||||
|
||||
describe('ListAlertRules — empty states', () => {
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
});
|
||||
|
||||
it('renders AlertsEmptyState when API returns no rules', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v2/rules', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: [], status: 'success' })),
|
||||
),
|
||||
);
|
||||
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('No Alert rules yet.');
|
||||
expect(
|
||||
screen.getByText('Create an Alert Rule to get started'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// New Alert Rule button is visible and triggers safeNavigate to ALERTS_NEW.
|
||||
await user.click(screen.getByTestId('add-alert'));
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
ROUTES.ALERTS_NEW,
|
||||
expect.objectContaining({ newTab: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders ErrorEmptyState when API returns 500; refresh triggers a refetch', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v2/rules', (_, res, ctx) => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
return res(ctx.status(500), ctx.json({ status: 'error' }));
|
||||
}
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({ data: alertRulesFixture, status: 'success' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByTestId('error-empty-state');
|
||||
|
||||
await user.click(screen.getByTestId('error-refresh-button'));
|
||||
|
||||
const rule = await screen.findByText('High CPU Alert');
|
||||
expect(rule).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders NoResultsEmptyState when search yields no match; Clear Search resets', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
const searchInput = screen.getByTestId('list-alerts-search-input');
|
||||
await user.clear(searchInput);
|
||||
await user.type(searchInput, 'totally-not-found');
|
||||
|
||||
await screen.findByTestId('no-results-empty-state');
|
||||
expect(screen.getByTestId('no-results-title')).toHaveTextContent(
|
||||
'No matching alert rules',
|
||||
);
|
||||
expect(screen.getByTestId('no-results-subtitle')).toHaveTextContent(
|
||||
'No alert rules match your search. Try adjusting your search criteria.',
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('no-results-clear-button'));
|
||||
|
||||
const rule = await screen.findByText('High CPU Alert');
|
||||
expect(rule).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { renderListAlertRules } from './_helpers';
|
||||
|
||||
describe('ListAlertRules — list rendering', () => {
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
});
|
||||
|
||||
it('renders alert rules from API', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('alert-row-rule-1-name'),
|
||||
).resolves.toHaveTextContent('High CPU Alert');
|
||||
expect(screen.getByTestId('alert-row-rule-2-name')).toHaveTextContent(
|
||||
'Memory Pending Alert',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-3-name')).toHaveTextContent(
|
||||
'Healthy Alert',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-4-name')).toHaveTextContent(
|
||||
'Disabled Alert',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders state badges via STATE_CONFIG mapping', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('alert-row-rule-1-state')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('alert-row-rule-1-state')).toHaveTextContent(
|
||||
'Firing',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-2-state')).toHaveTextContent(
|
||||
'Pending',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-3-state')).toHaveTextContent('OK');
|
||||
expect(screen.getByTestId('alert-row-rule-4-state')).toHaveTextContent(
|
||||
'Disabled',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-5-state')).toHaveTextContent('OK');
|
||||
});
|
||||
|
||||
it('renders state badges with semantic colors', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('alert-row-rule-1-state')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('alert-row-rule-1-state')).toHaveAttribute(
|
||||
'data-color',
|
||||
'cherry',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-2-state')).toHaveAttribute(
|
||||
'data-color',
|
||||
'amber',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-3-state')).toHaveAttribute(
|
||||
'data-color',
|
||||
'forest',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-4-state')).toHaveAttribute(
|
||||
'data-color',
|
||||
'vanilla',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders severity badges for rules with severity', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('alert-row-rule-1-severity')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('alert-row-rule-1-severity')).toHaveTextContent(
|
||||
'critical',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-2-severity')).toHaveTextContent(
|
||||
'warning',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-3-severity')).toHaveTextContent(
|
||||
'info',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-4-severity')).toHaveTextContent(
|
||||
'critical',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-5-severity')).toHaveTextContent(
|
||||
'-',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-1-severity')).toHaveAttribute(
|
||||
'data-color',
|
||||
'cherry',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-2-severity')).toHaveAttribute(
|
||||
'data-color',
|
||||
'amber',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders header controls (search, columns, new alert)', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('alert-row-rule-1-name')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('list-alerts-search-input')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText('Search by Alert Name, Severity and Labels'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('alert-columns-button')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('list-alerts-new-alert-button'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /new alert/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { renderListAlertRules } from './_helpers';
|
||||
|
||||
describe('ListAlertRules — new alert button', () => {
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
});
|
||||
|
||||
it('plain click navigates to ALERTS_NEW with newTab:false', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /new alert/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(safeNavigateMock).toHaveBeenCalled();
|
||||
});
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
ROUTES.ALERTS_NEW,
|
||||
expect.objectContaining({ newTab: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs Alert: New alert button clicked', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /new alert/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Alert: New alert button clicked',
|
||||
expect.objectContaining({ layout: 'new' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('ctrl+click on New Alert opens in a new tab (newTab:true)', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
await user.keyboard('{Control>}');
|
||||
await user.click(screen.getByRole('button', { name: /new alert/i }));
|
||||
await user.keyboard('{/Control}');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(safeNavigateMock).toHaveBeenCalled();
|
||||
});
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
ROUTES.ALERTS_NEW,
|
||||
expect.objectContaining({ newTab: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { alertRulesPaginationFixture } from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
import { getCurrentNuqsQueryString } from 'tests/nuqs-helpers';
|
||||
|
||||
import { renderListAlertRules } from './_helpers';
|
||||
|
||||
describe('ListAlertRules — pagination', () => {
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v2/rules', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ data: alertRulesPaginationFixture, status: 'success' }),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows first 10 rows on page 1 (default limit)', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('Pag Rule 0');
|
||||
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
expect(screen.getByText(`Pag Rule ${i}`)).toBeInTheDocument();
|
||||
}
|
||||
expect(screen.queryByText('Pag Rule 10')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Pag Rule 14')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows total count when showTotalCount is enabled', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('Pag Rule 0');
|
||||
|
||||
const totalCount = await screen.findByTestId('pagination-total-count');
|
||||
expect(totalCount.textContent).toContain('Showing');
|
||||
expect(totalCount.textContent).toContain('of 15');
|
||||
});
|
||||
|
||||
it('navigates to page 2 and shows remaining rows', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('Pag Rule 0');
|
||||
|
||||
const nextBtn = screen.getByLabelText('Go to next page');
|
||||
await user.click(nextBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Pag Rule 10')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pag Rule 14')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Pag Rule 0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getCurrentNuqsQueryString()).toContain('page=2');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import { renderListAlertRules } from './_helpers';
|
||||
|
||||
describe('ListAlertRules — permissions', () => {
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
});
|
||||
|
||||
it('VIEWER role hides "New Alert" button and "Actions" column', async () => {
|
||||
renderListAlertRules({ role: USER_ROLES.VIEWER });
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('list-alerts-new-alert-button'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /new alert/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const headers = Array.from(document.querySelectorAll('th')).map(
|
||||
(h) => h.textContent ?? '',
|
||||
);
|
||||
expect(headers.some((t) => t.includes('Actions'))).toBe(false);
|
||||
expect(screen.queryByTestId('alert-actions')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ADMIN role shows "New Alert" button and "Actions" column', async () => {
|
||||
renderListAlertRules({ role: USER_ROLES.ADMIN });
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
expect(
|
||||
screen.getByTestId('list-alerts-new-alert-button'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /new alert/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
const headers = Array.from(document.querySelectorAll('th')).map(
|
||||
(h) => h.textContent ?? '',
|
||||
);
|
||||
expect(headers.some((t) => t.includes('Actions'))).toBe(true);
|
||||
});
|
||||
expect(screen.getAllByTestId('alert-actions').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('EDITOR role behaves like ADMIN (New Alert + Actions visible)', async () => {
|
||||
renderListAlertRules({ role: USER_ROLES.EDITOR });
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
expect(
|
||||
screen.getByTestId('list-alerts-new-alert-button'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /new alert/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
const headers = Array.from(document.querySelectorAll('th')).map(
|
||||
(h) => h.textContent ?? '',
|
||||
);
|
||||
expect(headers.some((t) => t.includes('Actions'))).toBe(true);
|
||||
});
|
||||
expect(screen.getAllByTestId('alert-actions').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { renderListAlertRules } from './_helpers';
|
||||
|
||||
describe('ListAlertRules — row click navigation', () => {
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
});
|
||||
|
||||
it('clicking a row calls safeNavigate to alerts/overview with composite query + ruleId', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules();
|
||||
|
||||
const ruleCell = await screen.findByText('High CPU Alert');
|
||||
|
||||
const td = ruleCell.closest('td');
|
||||
expect(td).not.toBeNull();
|
||||
await user.click(td as HTMLElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(safeNavigateMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const [url] = safeNavigateMock.mock.calls[0];
|
||||
expect(url).toContain('/alerts/overview?');
|
||||
expect(url).toContain('ruleId=rule-1');
|
||||
expect(url).toContain('panelTypes=graph');
|
||||
expect(url).toContain('compositeQuery=');
|
||||
});
|
||||
|
||||
it('ctrl+click on a row navigates with newTab option', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules();
|
||||
|
||||
const ruleCell = await screen.findByText('High CPU Alert');
|
||||
|
||||
const td = ruleCell.closest('td');
|
||||
await user.keyboard('{Control>}');
|
||||
await user.click(td as HTMLElement);
|
||||
await user.keyboard('{/Control}');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(safeNavigateMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const [url, options] = safeNavigateMock.mock.calls[0];
|
||||
expect(url).toContain('ruleId=rule-1');
|
||||
expect(options).toStrictEqual(expect.objectContaining({ newTab: true }));
|
||||
});
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
import { getCurrentNuqsQueryString } from 'tests/nuqs-helpers';
|
||||
|
||||
import { renderListAlertRules } from './_helpers';
|
||||
|
||||
function getSearchInput(): HTMLInputElement {
|
||||
return screen.getByTestId('list-alerts-search-input') as HTMLInputElement;
|
||||
}
|
||||
|
||||
describe('ListAlertRules — search', () => {
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
});
|
||||
|
||||
it('filters rows by alert name with debounce', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
await user.clear(getSearchInput());
|
||||
await user.type(getSearchInput(), 'CPU');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('High CPU Alert')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Memory Pending Alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('filters rows by label values (severity)', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
await user.clear(getSearchInput());
|
||||
await user.type(getSearchInput(), 'warning');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Memory Pending Alert')).toBeInTheDocument();
|
||||
expect(screen.queryByText('High CPU Alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('restores all rows when search is cleared', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
await user.clear(getSearchInput());
|
||||
await user.type(getSearchInput(), 'CPU');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Memory Pending Alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.clear(getSearchInput());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('High CPU Alert')).toBeInTheDocument();
|
||||
expect(screen.getByText('Memory Pending Alert')).toBeInTheDocument();
|
||||
expect(screen.getByText('Healthy Alert')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows no-results state when no match', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
await user.clear(getSearchInput());
|
||||
await user.type(getSearchInput(), 'zzzzzz-no-match');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('no-results-empty-state')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('no-results-title')).toHaveTextContent(
|
||||
'No matching alert rules',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('resets page to 1 when search debounce fires', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules({ initialRoute: '/?page=2' });
|
||||
|
||||
// Page 2 of the 4-rule fixture has no rows; we only need the search input
|
||||
// to be mounted, which happens before data is fetched.
|
||||
const input = await screen.findByTestId('list-alerts-search-input');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'CPU');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getCurrentNuqsQueryString()).not.toContain('page=2');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,232 +0,0 @@
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { SortState } from 'components/TanStackTableView/types';
|
||||
|
||||
import type { AlertRule } from '../types';
|
||||
import {
|
||||
ALERT_ACTIONS,
|
||||
alertActionLogEvent,
|
||||
filterRulesByFilters,
|
||||
getAlertSortValue,
|
||||
sortRules,
|
||||
} from '../utils';
|
||||
|
||||
const baseRule = {
|
||||
id: 'r1',
|
||||
alert: 'Rule 1',
|
||||
alertType: 'METRIC_BASED_ALERT',
|
||||
state: 'inactive',
|
||||
labels: { severity: 'info' },
|
||||
condition: {},
|
||||
createdAt: '2023-10-15T10:00:00Z',
|
||||
updatedAt: '2023-10-19T10:00:00Z',
|
||||
} as unknown as AlertRule;
|
||||
|
||||
const makeRule = (overrides: Partial<AlertRule>): AlertRule => ({
|
||||
...baseRule,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('getAlertSortValue', () => {
|
||||
it('returns state for "state"', () => {
|
||||
expect(
|
||||
getAlertSortValue(
|
||||
makeRule({ state: RuletypesAlertStateDTO.firing }),
|
||||
'state',
|
||||
),
|
||||
).toBe('firing');
|
||||
});
|
||||
|
||||
it('returns alert name for "name"', () => {
|
||||
expect(getAlertSortValue(makeRule({ alert: 'My Rule' }), 'name')).toBe(
|
||||
'My Rule',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns severity label for "severity"', () => {
|
||||
expect(
|
||||
getAlertSortValue(
|
||||
makeRule({ labels: { severity: 'critical' } }),
|
||||
'severity',
|
||||
),
|
||||
).toBe('critical');
|
||||
});
|
||||
|
||||
it('returns createdAt as ms', () => {
|
||||
const rule = makeRule({ createdAt: '2023-10-15T10:00:00Z' });
|
||||
const result = getAlertSortValue(rule, 'createdAt');
|
||||
expect(result).toBe(new Date('2023-10-15T10:00:00Z').getTime());
|
||||
});
|
||||
|
||||
it('returns updatedAt as ms', () => {
|
||||
const rule = makeRule({ updatedAt: '2023-10-19T10:00:00Z' });
|
||||
const result = getAlertSortValue(rule, 'updatedAt');
|
||||
expect(result).toBe(new Date('2023-10-19T10:00:00Z').getTime());
|
||||
});
|
||||
|
||||
it('returns 0 when createdAt missing', () => {
|
||||
expect(
|
||||
getAlertSortValue(makeRule({ createdAt: undefined }), 'createdAt'),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('returns empty for unknown column', () => {
|
||||
expect(getAlertSortValue(baseRule, 'xxx')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty for missing fields', () => {
|
||||
expect(
|
||||
getAlertSortValue(
|
||||
makeRule({ state: undefined, labels: undefined }),
|
||||
'state',
|
||||
),
|
||||
).toBe('');
|
||||
expect(
|
||||
getAlertSortValue(
|
||||
makeRule({ state: undefined, labels: undefined }),
|
||||
'severity',
|
||||
),
|
||||
).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortRules', () => {
|
||||
const r1 = makeRule({ id: '1', alert: 'A' });
|
||||
const r2 = makeRule({ id: '2', alert: 'B' });
|
||||
const r3 = makeRule({ id: '3', alert: 'C' });
|
||||
|
||||
it('sorts ascending by name', () => {
|
||||
const order: SortState = { columnName: 'name', order: 'asc' };
|
||||
const result = sortRules([r3, r1, r2], order);
|
||||
expect(result.map((r) => r.alert)).toStrictEqual(['A', 'B', 'C']);
|
||||
});
|
||||
|
||||
it('sorts descending by name', () => {
|
||||
const order: SortState = { columnName: 'name', order: 'desc' };
|
||||
const result = sortRules([r1, r2, r3], order);
|
||||
expect(result.map((r) => r.alert)).toStrictEqual(['C', 'B', 'A']);
|
||||
});
|
||||
|
||||
it('returns unsorted when orderBy is null', () => {
|
||||
const result = sortRules([r3, r1, r2], null);
|
||||
expect(result.map((r) => r.alert)).toStrictEqual(['C', 'A', 'B']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterRulesByFilters', () => {
|
||||
const r1 = makeRule({
|
||||
id: '1',
|
||||
alert: 'R1',
|
||||
state: RuletypesAlertStateDTO.firing,
|
||||
labels: { severity: 'critical' },
|
||||
});
|
||||
const r2 = makeRule({
|
||||
id: '2',
|
||||
alert: 'R2',
|
||||
state: RuletypesAlertStateDTO.inactive,
|
||||
labels: { severity: 'warning' },
|
||||
});
|
||||
const r3 = makeRule({
|
||||
id: '3',
|
||||
alert: 'R3',
|
||||
state: RuletypesAlertStateDTO.firing,
|
||||
labels: { severity: 'warning' },
|
||||
});
|
||||
const rules = [r1, r2, r3];
|
||||
|
||||
it('returns input when filters empty', () => {
|
||||
expect(filterRulesByFilters(rules, [])).toStrictEqual(rules);
|
||||
});
|
||||
|
||||
it('filters by state', () => {
|
||||
const result = filterRulesByFilters(rules, ['state:firing']);
|
||||
expect(result.map((r) => r.id)).toStrictEqual(['1', '3']);
|
||||
});
|
||||
|
||||
it('filters by severity', () => {
|
||||
const result = filterRulesByFilters(rules, ['severity:warning']);
|
||||
expect(result.map((r) => r.id)).toStrictEqual(['2', '3']);
|
||||
});
|
||||
|
||||
it('combines state AND severity', () => {
|
||||
const result = filterRulesByFilters(rules, [
|
||||
'state:firing',
|
||||
'severity:warning',
|
||||
]);
|
||||
expect(result.map((r) => r.id)).toStrictEqual(['3']);
|
||||
});
|
||||
|
||||
it('OR within same key (state)', () => {
|
||||
const result = filterRulesByFilters(rules, [
|
||||
'state:firing',
|
||||
'state:inactive',
|
||||
]);
|
||||
expect(result.map((r) => r.id)).toStrictEqual(['1', '2', '3']);
|
||||
});
|
||||
|
||||
it('matches values case-insensitively', () => {
|
||||
const result = filterRulesByFilters(rules, ['state:FIRING']);
|
||||
expect(result.map((r) => r.id)).toStrictEqual(['1', '3']);
|
||||
});
|
||||
|
||||
it('ignores prefixes with wrong case (state: is required lowercase)', () => {
|
||||
const result = filterRulesByFilters(rules, ['STATE:FIRING']);
|
||||
expect(result).toStrictEqual(rules);
|
||||
});
|
||||
|
||||
it('returns empty when no rule matches', () => {
|
||||
expect(filterRulesByFilters(rules, ['state:nonexistent'])).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('ignores unknown prefix', () => {
|
||||
expect(filterRulesByFilters(rules, ['foo:bar'])).toStrictEqual(rules);
|
||||
});
|
||||
});
|
||||
|
||||
describe('alertActionLogEvent', () => {
|
||||
it('logs with mapped action label', () => {
|
||||
const rule = makeRule({
|
||||
id: 'rule-1',
|
||||
alert: 'My Rule',
|
||||
alertType: 'METRIC_BASED_ALERT' as AlertRule['alertType'],
|
||||
});
|
||||
alertActionLogEvent(ALERT_ACTIONS.EDIT, rule);
|
||||
expect(logEventMock).toHaveBeenCalledWith('Alert: Action', {
|
||||
ruleId: 'rule-1',
|
||||
dataSource: expect.any(String),
|
||||
name: 'My Rule',
|
||||
action: 'Edit',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to raw action when unmapped', () => {
|
||||
alertActionLogEvent('custom', baseRule);
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Alert: Action',
|
||||
expect.objectContaining({ action: 'custom' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('maps TOGGLE action', () => {
|
||||
alertActionLogEvent(ALERT_ACTIONS.TOGGLE, baseRule);
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Alert: Action',
|
||||
expect.objectContaining({ action: 'Enable/Disable' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('maps DELETE and CLONE', () => {
|
||||
alertActionLogEvent(ALERT_ACTIONS.DELETE, baseRule);
|
||||
alertActionLogEvent(ALERT_ACTIONS.CLONE, baseRule);
|
||||
expect(logEventMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'Alert: Action',
|
||||
expect.objectContaining({ action: 'Delete' }),
|
||||
);
|
||||
expect(logEventMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'Alert: Action',
|
||||
expect.objectContaining({ action: 'Clone' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import { render, RenderResult, screen } from '@testing-library/react';
|
||||
import ListAlertRules from 'container/ListAlertRules';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { AppContext } from 'providers/App/App';
|
||||
import TimezoneProvider from 'providers/Timezone';
|
||||
import { onNuqsUrlUpdate, resetNuqsState } from 'tests/nuqs-helpers';
|
||||
import { getAppContextMock } from 'tests/test-utils';
|
||||
|
||||
interface RenderOptions {
|
||||
role?: string;
|
||||
initialRoute?: string;
|
||||
}
|
||||
|
||||
export function renderListAlertRules(
|
||||
options: RenderOptions = {},
|
||||
): RenderResult {
|
||||
const { role = 'ADMIN', initialRoute = '/' } = options;
|
||||
|
||||
const initialSearch = initialRoute.includes('?')
|
||||
? initialRoute.slice(initialRoute.indexOf('?'))
|
||||
: '';
|
||||
resetNuqsState(initialSearch);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { refetchOnWindowFocus: false, retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initialRoute]}>
|
||||
<NuqsTestingAdapter
|
||||
searchParams={initialSearch}
|
||||
onUrlUpdate={onNuqsUrlUpdate}
|
||||
rateLimitFactor={0}
|
||||
hasMemory
|
||||
>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppContext.Provider value={getAppContextMock(role)}>
|
||||
<TimezoneProvider>
|
||||
<VirtuosoMockContext.Provider
|
||||
value={{ viewportHeight: 800, itemHeight: 46 }}
|
||||
>
|
||||
<ListAlertRules />
|
||||
</VirtuosoMockContext.Provider>
|
||||
</TimezoneProvider>
|
||||
</AppContext.Provider>
|
||||
</QueryClientProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
export async function findAlertRow(alertName: string): Promise<HTMLElement> {
|
||||
const cell = await screen.findByText(alertName, {}, { timeout: 5000 });
|
||||
const row = cell.closest('tr');
|
||||
if (!row) {
|
||||
throw new Error(`Row not found for alert "${alertName}"`);
|
||||
}
|
||||
return row as HTMLElement;
|
||||
}
|
||||
@@ -47,7 +47,6 @@ function ColumnSelector<TData>({
|
||||
size="sm"
|
||||
color="secondary"
|
||||
prefix={<Columns3 size={14} />}
|
||||
data-testid="alert-columns-button"
|
||||
>
|
||||
Columns
|
||||
</Button>
|
||||
|
||||
@@ -136,7 +136,6 @@ function ListAlertRules(): JSX.Element {
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={handleNewAlert}
|
||||
color="primary"
|
||||
testId="list-alerts-new-alert-button"
|
||||
>
|
||||
New Alert
|
||||
</Button>
|
||||
@@ -158,7 +157,6 @@ function ListAlertRules(): JSX.Element {
|
||||
value={searchText}
|
||||
onChange={handleSearchChange}
|
||||
suffix={<Search size={14} className={styles.searchIcon} />}
|
||||
testId="list-alerts-search-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -26,18 +26,14 @@ export function getAlertRuleColumns(
|
||||
enableSort: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
cell: ({ row, value }): JSX.Element => {
|
||||
cell: ({ value }): JSX.Element => {
|
||||
const state = String(value ?? '').toLowerCase();
|
||||
const config = STATE_CONFIG[state] ?? {
|
||||
color: 'secondary' as BadgeColor,
|
||||
label: 'Unknown',
|
||||
};
|
||||
return (
|
||||
<Badge
|
||||
color={config.color}
|
||||
variant="outline"
|
||||
testId={`alert-row-${row.id ?? ''}-state`}
|
||||
>
|
||||
<Badge color={config.color} variant="outline">
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
@@ -51,11 +47,8 @@ export function getAlertRuleColumns(
|
||||
enableSort: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
cell: ({ row, value }): JSX.Element => (
|
||||
<TanStackTable.Text
|
||||
title={value}
|
||||
data-testid={`alert-row-${row.id ?? ''}-name`}
|
||||
>
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text title={value}>
|
||||
{String(value ?? '-')}
|
||||
</TanStackTable.Text>
|
||||
),
|
||||
@@ -67,20 +60,15 @@ export function getAlertRuleColumns(
|
||||
width: { fixed: '120px' },
|
||||
enableSort: true,
|
||||
enableMove: false,
|
||||
cell: ({ row, value }): JSX.Element => {
|
||||
cell: ({ value }): JSX.Element => {
|
||||
const severity = String(value ?? '').toLowerCase();
|
||||
if (!severity) {
|
||||
return (
|
||||
<TanStackTable.Text data-testid={`alert-row-${row.id ?? ''}-severity`}>
|
||||
-
|
||||
</TanStackTable.Text>
|
||||
);
|
||||
return <TanStackTable.Text>-</TanStackTable.Text>;
|
||||
}
|
||||
return (
|
||||
<Badge
|
||||
color={SEVERITY_BADGE_COLORS[severity] ?? 'secondary'}
|
||||
variant="outline"
|
||||
testId={`alert-row-${row.id ?? ''}-severity`}
|
||||
>
|
||||
{severity}
|
||||
</Badge>
|
||||
|
||||
@@ -232,7 +232,7 @@ function DashboardsList(): JSX.Element {
|
||||
isLocked: !!e.locked || false,
|
||||
lastUpdatedBy: e.updatedBy,
|
||||
image: e.data.image || Base64Icons[0],
|
||||
variables: e.data.variables ?? {},
|
||||
variables: e.data.variables,
|
||||
widgets: e.data.widgets,
|
||||
layout: e.data.layout,
|
||||
panelMap: e.data.panelMap,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import getFromLocalstorage from 'api/browser/localstorage/get';
|
||||
import setToLocalstorage from 'api/browser/localstorage/set';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -42,6 +42,7 @@ import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
|
||||
import { isEmpty, isUndefined } from 'lodash-es';
|
||||
import LiveLogs from 'pages/LiveLogs';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
@@ -76,6 +77,7 @@ function LogsExplorerViewsContainer({
|
||||
handleChangeSelectedView: ChangeViewFunctionType;
|
||||
}): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [showFrequencyChart, setShowFrequencyChart] = useState(
|
||||
() => getFromLocalstorage(LOCALSTORAGE.SHOW_FREQUENCY_CHART) === 'true',
|
||||
@@ -88,9 +90,10 @@ function LogsExplorerViewsContainer({
|
||||
DEFAULT_PER_PAGE_VALUE,
|
||||
);
|
||||
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const { minTime, maxTime, selectedTime } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
|
||||
const currentMinTimeRef = useRef<number>(minTime);
|
||||
|
||||
@@ -326,6 +329,16 @@ function LogsExplorerViewsContainer({
|
||||
currentMinTimeRef.current !== minTime ||
|
||||
orderByChanged
|
||||
) {
|
||||
// Recalculate global time when query changes i.e. stage and run query clicked
|
||||
if (
|
||||
!!requestData?.id &&
|
||||
stagedQuery?.id &&
|
||||
requestData?.id !== stagedQuery?.id &&
|
||||
selectedTime !== 'custom'
|
||||
) {
|
||||
dispatch(UpdateTimeInterval(selectedTime));
|
||||
}
|
||||
|
||||
const newRequestData = getRequestData(stagedQuery, {
|
||||
filters: listQuery?.filters || initialFilters,
|
||||
filter: listQuery?.filter || { expression: '' },
|
||||
@@ -347,6 +360,8 @@ function LogsExplorerViewsContainer({
|
||||
minTime,
|
||||
activeLogId,
|
||||
selectedPanelType,
|
||||
dispatch,
|
||||
selectedTime,
|
||||
maxTime,
|
||||
orderBy,
|
||||
]);
|
||||
|
||||
@@ -108,21 +108,13 @@ jest.mock('hooks/useSafeNavigate', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => {
|
||||
const { useQueryBuilder } = jest.requireActual(
|
||||
'hooks/queryBuilder/useQueryBuilder',
|
||||
);
|
||||
const { useSyncTimeOnStagedQueryChange } = jest.requireActual(
|
||||
'hooks/queryBuilder/useSyncTimeOnStagedQueryChange',
|
||||
);
|
||||
|
||||
return function MockDateTimeSelection(): JSX.Element {
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
useSyncTimeOnStagedQueryChange(stagedQuery?.id);
|
||||
return <div>MockDateTimeSelection</div>;
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock(
|
||||
'container/TopNav/DateTimeSelectionV2/index.tsx',
|
||||
() =>
|
||||
function MockDateTimeSelection(): JSX.Element {
|
||||
return <div>MockDateTimeSelection</div>;
|
||||
},
|
||||
);
|
||||
jest.mock(
|
||||
'container/LogsExplorerChart',
|
||||
() =>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import MCPServerSettings from './MCPServerSettings';
|
||||
|
||||
const mockLogEvent = jest.fn();
|
||||
const mockCopyToClipboard = jest.fn();
|
||||
const mockHistoryPush = jest.fn();
|
||||
const mockUseGetGlobalConfig = jest.fn();
|
||||
@@ -11,6 +11,11 @@ const mockUseGetTenantLicense = jest.fn();
|
||||
const mockToastSuccess = jest.fn();
|
||||
const mockToastWarning = jest.fn();
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]): unknown => mockLogEvent(...args),
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/global', () => ({
|
||||
useGetGlobalConfig: (...args: unknown[]): unknown =>
|
||||
mockUseGetGlobalConfig(...args),
|
||||
@@ -143,7 +148,7 @@ describe('MCPServerSettings', () => {
|
||||
|
||||
render(<MCPServerSettings />, undefined, { role: 'ADMIN' });
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith('MCP Settings: Page viewed', {
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('MCP Settings: Page viewed', {
|
||||
role: 'ADMIN',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import MySettingsContainer from 'container/MySettings';
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
@@ -13,6 +12,7 @@ import APIError from 'types/api/error';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
|
||||
const toggleThemeFunction = jest.fn();
|
||||
const logEventFunction = jest.fn();
|
||||
const copyToClipboardFn = jest.fn();
|
||||
const editUserFn = jest.fn();
|
||||
const updateMyPasswordFn = jest.fn();
|
||||
@@ -62,6 +62,11 @@ jest.mock('hooks/useDarkMode', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn((eventName, data) => logEventFunction(eventName, data)),
|
||||
}));
|
||||
|
||||
const errorNotification = jest.fn();
|
||||
const successNotification = jest.fn();
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
@@ -130,7 +135,7 @@ describe('MySettings Flows', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toggleThemeFunction).toHaveBeenCalled();
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
expect(logEventFunction).toHaveBeenCalledWith(
|
||||
'Account Settings: Theme Changed',
|
||||
{
|
||||
theme: 'light',
|
||||
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
|
||||
import InviteTeamMembers from '../InviteTeamMembers';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockNotificationSuccess = jest.fn() as jest.MockedFunction<
|
||||
(args: { message: string }) => void
|
||||
>;
|
||||
|
||||
@@ -4,6 +4,11 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import OnboardingQuestionaire from '../index';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('lib/history', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
|
||||
@@ -4,13 +4,15 @@ import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import i18n from 'ReactI18';
|
||||
import store from 'store';
|
||||
|
||||
import CreatePipelineButton from '../Layouts/Pipeline/CreatePipelineButton';
|
||||
import { pipelineApiResponseMockData } from '../mocks/pipeline';
|
||||
|
||||
jest.mock('api/common/logEvent');
|
||||
|
||||
describe('PipelinePage container test', () => {
|
||||
it('should render CreatePipelineButton section', async () => {
|
||||
const { asFragment } = render(
|
||||
@@ -51,12 +53,9 @@ describe('PipelinePage container test', () => {
|
||||
expect(editButton).toBeInTheDocument();
|
||||
await userEvent.click(editButton);
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Logs: Pipelines: Entered Edit Mode',
|
||||
{
|
||||
source: 'signoz-ui',
|
||||
},
|
||||
);
|
||||
expect(logEvent).toHaveBeenCalledWith('Logs: Pipelines: Entered Edit Mode', {
|
||||
source: 'signoz-ui',
|
||||
});
|
||||
});
|
||||
|
||||
it('CreatePipelineButton - add new mode & tracking', async () => {
|
||||
@@ -79,7 +78,7 @@ describe('PipelinePage container test', () => {
|
||||
expect(editButton).toBeInTheDocument();
|
||||
await userEvent.click(editButton);
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
expect(logEvent).toHaveBeenCalledWith(
|
||||
'Logs: Pipelines: Clicked Add New Pipeline',
|
||||
{
|
||||
source: 'signoz-ui',
|
||||
|
||||
@@ -233,10 +233,8 @@
|
||||
background: var(--l1-background);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
padding: 0px;
|
||||
gap: 0;
|
||||
margin: 4px;
|
||||
|
||||
.qb-tag-text {
|
||||
.ant-typography {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px !important;
|
||||
@@ -246,7 +244,7 @@
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
> button {
|
||||
.close-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -261,26 +259,26 @@
|
||||
&.resource {
|
||||
border: 1px solid color-mix(in srgb, var(--bg-aqua-400) 13%, transparent);
|
||||
|
||||
.qb-tag-text {
|
||||
.ant-typography {
|
||||
color: var(--bg-aqua-400);
|
||||
background: color-mix(in srgb, var(--bg-aqua-400) 6%, transparent);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
> button {
|
||||
.close-icon {
|
||||
background: color-mix(in srgb, var(--bg-aqua-400) 6%, transparent);
|
||||
}
|
||||
}
|
||||
&.tag {
|
||||
border: 1px solid color-mix(in srgb, var(--bg-sienna-400) 20%, transparent);
|
||||
|
||||
.qb-tag-text {
|
||||
.ant-typography {
|
||||
color: var(--bg-sienna-400);
|
||||
background: color-mix(in srgb, var(--bg-sienna-400) 10%, transparent);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
> button {
|
||||
.close-icon {
|
||||
background: color-mix(in srgb, var(--bg-sienna-400) 10%, transparent);
|
||||
}
|
||||
}
|
||||
@@ -288,13 +286,13 @@
|
||||
&.scope {
|
||||
border: 1px solid color-mix(in srgb, var(--bg-robin-400) 20%, transparent);
|
||||
|
||||
.qb-tag-text {
|
||||
.ant-typography {
|
||||
color: var(--bg-robin-400);
|
||||
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
> button {
|
||||
.close-icon {
|
||||
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -966,7 +966,6 @@ function QueryBuilderSearchV2(
|
||||
>
|
||||
<Tooltip title={chipValue}>
|
||||
<TypographyText
|
||||
className="qb-tag-text"
|
||||
$isInNin={isInNin}
|
||||
$isEnabled={!!searchValue}
|
||||
onClick={(): void => {
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
useIsGlobalTimeQueryRefreshing,
|
||||
} from 'store/globalTime';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useSyncTimeOnStagedQueryChange } from 'hooks/queryBuilder/useSyncTimeOnStagedQueryChange';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { isValidShortHandDateTimeFormat } from 'lib/getMinMax';
|
||||
import getTimeString from 'lib/getTimeString';
|
||||
@@ -188,8 +187,6 @@ function DateTimeSelection({
|
||||
|
||||
const { stagedQuery, currentQuery, initQueryBuilderData } = useQueryBuilder();
|
||||
|
||||
useSyncTimeOnStagedQueryChange(stagedQuery?.id);
|
||||
|
||||
const getInputLabel = (
|
||||
startTime?: Dayjs,
|
||||
endTime?: Dayjs,
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { triggeredAlertsFixture } from 'mocks-server/__mockdata__/triggered_alerts';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { renderTriggeredAlerts } from './_helpers';
|
||||
|
||||
describe('TriggeredAlerts — empty / error states', () => {
|
||||
it('shows the "No alerts firing" empty state when the API returns []', async () => {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: [], status: 'success' })),
|
||||
),
|
||||
);
|
||||
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await screen.findByText('No alerts firing');
|
||||
expect(
|
||||
screen.getByTestId('triggered-alerts-empty-create-button'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('triggered-alerts-empty-refresh-button'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to ROUTES.ALERTS_NEW when "Create Alert Rule" is clicked', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: [], status: 'success' })),
|
||||
),
|
||||
);
|
||||
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await screen.findByText('No alerts firing');
|
||||
|
||||
await user.click(screen.getByTestId('triggered-alerts-empty-create-button'));
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
ROUTES.ALERTS_NEW,
|
||||
expect.objectContaining({ newTab: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows ErrorEmptyState when the API returns 500', async () => {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) =>
|
||||
res(ctx.status(500)),
|
||||
),
|
||||
);
|
||||
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await screen.findByTestId('error-empty-state');
|
||||
expect(screen.getByTestId('error-refresh-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('refetches on refresh button click after an initial error', async () => {
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
return res(ctx.status(500));
|
||||
}
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({ data: triggeredAlertsFixture, status: 'success' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await screen.findByTestId('error-refresh-button');
|
||||
|
||||
await user.click(screen.getByTestId('error-refresh-button'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows NoResultsEmptyState when filters yield zero matches', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const input = screen.getByTestId('triggered-alerts-search-input');
|
||||
await user.type(input, 'this-matches-nothing-xyz');
|
||||
|
||||
await screen.findByTestId('no-results-empty-state');
|
||||
expect(screen.getByTestId('no-results-title')).toHaveTextContent(
|
||||
'No matching alerts',
|
||||
);
|
||||
expect(screen.getByTestId('no-results-subtitle')).toHaveTextContent(
|
||||
'No alerts match your current filters. Try adjusting your search criteria.',
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('no-results-clear-button'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
(screen.getByTestId('triggered-alerts-search-input') as HTMLInputElement)
|
||||
.value,
|
||||
).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -1,87 +0,0 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { renderTriggeredAlerts } from './_helpers';
|
||||
|
||||
describe('TriggeredAlerts — severity filter', () => {
|
||||
it('filters to only critical-severity rows when "Critical" is selected', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('triggered-alerts-filter-combobox'));
|
||||
|
||||
const criticalOption = await screen.findByText(
|
||||
'Critical (severity:critical)',
|
||||
);
|
||||
await user.click(criticalOption);
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Memory Warning')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Disk Slow')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Network Hiccup')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows union when multiple severities are selected', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('triggered-alerts-filter-combobox'));
|
||||
const critical = await screen.findByText('Critical (severity:critical)');
|
||||
await user.click(critical);
|
||||
|
||||
const warning = await screen.findByText('Warning (severity:warning)');
|
||||
await user.click(warning);
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument();
|
||||
expect(screen.getByText('Memory Warning')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Disk Slow')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Network Hiccup')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('clearing the filter shows all rows again', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('triggered-alerts-filter-combobox'));
|
||||
const critical = await screen.findByText('Critical (severity:critical)');
|
||||
await user.click(critical);
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Memory Warning')).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
// Reopen the filter combobox and deselect Critical (clicking again toggles).
|
||||
await user.click(screen.getByTestId('triggered-alerts-filter-combobox'));
|
||||
const criticalAgain = await screen.findByText('Critical (severity:critical)');
|
||||
await user.click(criticalAgain);
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument();
|
||||
expect(screen.getByText('Memory Warning')).toBeInTheDocument();
|
||||
expect(screen.getByText('Disk Slow')).toBeInTheDocument();
|
||||
expect(screen.getByText('Network Hiccup')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { renderTriggeredAlerts } from './_helpers';
|
||||
|
||||
describe('TriggeredAlerts — group by', () => {
|
||||
it('renders a flat table when no group-by is selected', async () => {
|
||||
renderTriggeredAlerts();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
// No "Group" column header in flat mode.
|
||||
expect(screen.queryByText('Group')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('groups by service when "service" is selected', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('triggered-alerts-groupby-combobox'));
|
||||
|
||||
const serviceOption = await screen.findByText('service');
|
||||
await user.click(serviceOption);
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Group')).toBeInTheDocument());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('service:frontend')).toBeInTheDocument();
|
||||
expect(screen.getByText('service:backend')).toBeInTheDocument();
|
||||
expect(screen.getByText('service:misc')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('expands and collapses a group row to reveal nested alerts', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('triggered-alerts-groupby-combobox'));
|
||||
const serviceOption = await screen.findByText('service');
|
||||
await user.click(serviceOption);
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('service:frontend')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
// Nested rows aren't shown yet.
|
||||
expect(screen.queryByText('High CPU Usage')).not.toBeInTheDocument();
|
||||
|
||||
// The "frontend" group sits first in the table; its expand toggle is the
|
||||
// first `group-expand-toggle` in DOM order. Targeting by testid is safe
|
||||
// against design changes that add other buttons to the row.
|
||||
const expandToggles = screen.getAllByTestId('group-expand-toggle');
|
||||
expect(expandToggles.length).toBeGreaterThan(0);
|
||||
const frontendGroupBadge = screen.getByText('service:frontend');
|
||||
const frontendRow = frontendGroupBadge.closest('tr');
|
||||
expect(frontendRow).not.toBeNull();
|
||||
const frontendToggle = (frontendRow as HTMLElement).querySelector(
|
||||
'[data-testid="group-expand-toggle"]',
|
||||
) as HTMLElement | null;
|
||||
expect(frontendToggle).not.toBeNull();
|
||||
|
||||
await user.click(frontendToggle as HTMLElement);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByText('Disk Slow')).toBeInTheDocument();
|
||||
|
||||
await user.click(frontendToggle as HTMLElement);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('High CPU Usage')).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { renderTriggeredAlerts } from './_helpers';
|
||||
|
||||
describe('TriggeredAlerts — list rendering', () => {
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
});
|
||||
|
||||
it('renders alerts from the API', async () => {
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('alert-row-fp-critical-1-name'),
|
||||
).resolves.toHaveTextContent('High CPU Usage');
|
||||
expect(screen.getByTestId('alert-row-fp-warning-1-name')).toHaveTextContent(
|
||||
'Memory Warning',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-fp-info-1-name')).toHaveTextContent(
|
||||
'Disk Slow',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders severity badges for alerts that have severity', async () => {
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId('alert-row-fp-critical-1-name'),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('alert-row-fp-critical-1-severity'),
|
||||
).toHaveTextContent('critical');
|
||||
expect(
|
||||
screen.getByTestId('alert-row-fp-warning-1-severity'),
|
||||
).toHaveTextContent('warning');
|
||||
expect(screen.getByTestId('alert-row-fp-info-1-severity')).toHaveTextContent(
|
||||
'info',
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('alert-row-fp-noseverity-severity'),
|
||||
).toHaveTextContent('-');
|
||||
});
|
||||
|
||||
it('renders status tags reflecting the alert state', async () => {
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId('alert-row-fp-critical-1-name'),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('alert-row-fp-critical-1-status'),
|
||||
).toHaveTextContent('Firing');
|
||||
expect(screen.getByTestId('alert-row-fp-info-1-status')).toHaveTextContent(
|
||||
'Unprocessed',
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('alert-row-fp-suppressed-1-status'),
|
||||
).toHaveTextContent('Suppressed');
|
||||
});
|
||||
|
||||
it('renders status badges with semantic colors', async () => {
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId('alert-row-fp-critical-1-status'),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('alert-row-fp-critical-1-status')).toHaveAttribute(
|
||||
'data-color',
|
||||
'cherry',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-fp-info-1-status')).toHaveAttribute(
|
||||
'data-color',
|
||||
'forest',
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('alert-row-fp-critical-1-severity'),
|
||||
).toHaveAttribute('data-color', 'cherry');
|
||||
expect(screen.getByTestId('alert-row-fp-warning-1-severity')).toHaveAttribute(
|
||||
'data-color',
|
||||
'amber',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the search input and filter comboboxes', async () => {
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId('alert-row-fp-critical-1-name'),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('triggered-alerts-search-input'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText('Search alerts by name'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('triggered-alerts-filter-combobox'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('triggered-alerts-groupby-combobox'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { triggeredAlertsPaginationFixture } from 'mocks-server/__mockdata__/triggered_alerts';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
import { getCurrentNuqsQueryString } from 'tests/nuqs-helpers';
|
||||
|
||||
import { renderTriggeredAlerts } from './_helpers';
|
||||
|
||||
function usePaginationHandler(): void {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: triggeredAlertsPaginationFixture,
|
||||
status: 'success',
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe('TriggeredAlerts — pagination', () => {
|
||||
// Default sort is duration ascending = newest startsAt first. Fixture indices
|
||||
// 0..14 use startsAt 2023-10-01..15, so index 14 (newest) appears first and
|
||||
// index 0 (oldest) appears last. Page 1 (limit 10) = items 14..5. Page 2 = 4..0.
|
||||
|
||||
it('shows the first 10 rows on page 1 by default', async () => {
|
||||
usePaginationHandler();
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await screen.findByText('Pag Alert 14');
|
||||
|
||||
expect(screen.getByText('Pag Alert 5')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Pag Alert 4')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Pag Alert 0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a "page 2" pagination button reflecting total=15 with pageSize=10', async () => {
|
||||
usePaginationHandler();
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await screen.findByText('Pag Alert 14');
|
||||
|
||||
const nav = screen.getByRole('navigation');
|
||||
const page2 = Array.from(nav.querySelectorAll('button')).find(
|
||||
(b) => b.textContent?.trim() === '2',
|
||||
);
|
||||
expect(page2).toBeDefined();
|
||||
});
|
||||
|
||||
it('navigates to page 2 and shows the next batch of alerts', async () => {
|
||||
usePaginationHandler();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await screen.findByText('Pag Alert 14');
|
||||
|
||||
const nav = screen.getByRole('navigation');
|
||||
const page2Button = Array.from(nav.querySelectorAll('button')).find(
|
||||
(b) => b.textContent?.trim() === '2',
|
||||
);
|
||||
if (!page2Button) {
|
||||
throw new Error('Page 2 button not found');
|
||||
}
|
||||
await user.click(page2Button);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Pag Alert 0')).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByText('Pag Alert 4')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Pag Alert 14')).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(getCurrentNuqsQueryString()).toContain('page=2'));
|
||||
});
|
||||
});
|
||||
@@ -1,143 +0,0 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { getTriggeredAlertRowTestId, renderTriggeredAlerts } from './_helpers';
|
||||
|
||||
describe('TriggeredAlerts — row click', () => {
|
||||
it('navigates to the alert overview with the rule id from labels on row click', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId(getTriggeredAlertRowTestId('fp-critical-1', 'name')),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByTestId(getTriggeredAlertRowTestId('fp-critical-1', 'name')),
|
||||
);
|
||||
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
'/alerts/overview?ruleId=rule-1',
|
||||
);
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Alert: Triggered alert clicked',
|
||||
expect.objectContaining({
|
||||
ruleId: 'rule-1',
|
||||
alertName: 'High CPU Usage',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('opens in a new tab when ctrl+clicked', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId(getTriggeredAlertRowTestId('fp-warning-1', 'name')),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.keyboard('{Control>}');
|
||||
await user.click(
|
||||
screen.getByTestId(getTriggeredAlertRowTestId('fp-warning-1', 'name')),
|
||||
);
|
||||
await user.keyboard('{/Control}');
|
||||
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
'/alerts/overview?ruleId=rule-2',
|
||||
{ newTab: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('navigates correctly when ruleId is parsed from generatorURL', async () => {
|
||||
// Override fixture so the alert has no labels.ruleId but a valid
|
||||
// generatorURL → getRuleId() falls back to parsing the URL.
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: [
|
||||
{
|
||||
fingerprint: 'fp-no-rule-label',
|
||||
startsAt: '2023-10-19T10:00:00Z',
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
updatedAt: '2023-10-20T00:00:00Z',
|
||||
generatorURL: 'http://localhost/alerts/edit?ruleId=rule-from-url',
|
||||
labels: { alertname: 'URL Rule Alert' },
|
||||
annotations: {},
|
||||
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
|
||||
receivers: [],
|
||||
},
|
||||
],
|
||||
status: 'success',
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId(getTriggeredAlertRowTestId('fp-no-rule-label', 'name')),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByTestId(getTriggeredAlertRowTestId('fp-no-rule-label', 'name')),
|
||||
);
|
||||
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
'/alerts/overview?ruleId=rule-from-url',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not navigate when the row has no ruleId anywhere', async () => {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: [
|
||||
{
|
||||
fingerprint: 'fp-no-rule',
|
||||
startsAt: '2023-10-19T10:00:00Z',
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
updatedAt: '2023-10-20T00:00:00Z',
|
||||
labels: { alertname: 'No Rule Alert' },
|
||||
annotations: {},
|
||||
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
|
||||
receivers: [],
|
||||
},
|
||||
],
|
||||
status: 'success',
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId(getTriggeredAlertRowTestId('fp-no-rule', 'name')),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByTestId(getTriggeredAlertRowTestId('fp-no-rule', 'name')),
|
||||
);
|
||||
|
||||
expect(safeNavigateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,118 +0,0 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { renderTriggeredAlerts } from './_helpers';
|
||||
|
||||
describe('TriggeredAlerts — search', () => {
|
||||
it('filters rows by alertname when typing in the search input', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByText('Memory Warning')).toBeInTheDocument();
|
||||
|
||||
const input = screen.getByTestId('triggered-alerts-search-input');
|
||||
await user.type(input, 'CPU');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Memory Warning')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows all rows again when search is cleared', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const input = screen.getByTestId('triggered-alerts-search-input');
|
||||
await user.type(input, 'CPU');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Memory Warning')).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.clear(input);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument();
|
||||
expect(screen.getByText('Memory Warning')).toBeInTheDocument();
|
||||
expect(screen.getByText('Disk Slow')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('matches rows by label value (case-insensitive)', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const input = screen.getByTestId('triggered-alerts-search-input');
|
||||
// "backend" matches `service: backend` labels on Memory Warning and Network Hiccup.
|
||||
await user.type(input, 'backend');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Memory Warning')).toBeInTheDocument();
|
||||
expect(screen.getByText('Network Hiccup')).toBeInTheDocument();
|
||||
expect(screen.queryByText('High CPU Usage')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Disk Slow')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('matches rows by label key', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const input = screen.getByTestId('triggered-alerts-search-input');
|
||||
await user.type(input, 'staging');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Disk Slow')).toBeInTheDocument();
|
||||
expect(screen.queryByText('High CPU Usage')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Memory Warning')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the no-results empty state when the search matches nothing', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const input = screen.getByTestId('triggered-alerts-search-input');
|
||||
await user.type(input, 'zzzzznever-matches-anything');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('no-results-empty-state')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('resets pagination to page 1 when the search changes', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts({ initialRoute: '/?page=2&limit=2' });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Disk Slow')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const input = screen.getByTestId('triggered-alerts-search-input');
|
||||
await user.type(input, 'CPU');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,181 +0,0 @@
|
||||
import type { SortState } from 'components/TanStackTableView/types';
|
||||
|
||||
import type { Alert } from '../types';
|
||||
import {
|
||||
getAlertSortValue,
|
||||
getRuleId,
|
||||
normalizeAlerts,
|
||||
sortAlerts,
|
||||
} from '../utils';
|
||||
|
||||
const alertWithFingerprint: Alert = {
|
||||
fingerprint: 'fp-existing',
|
||||
labels: { alertname: 'Test', severity: 'critical' },
|
||||
annotations: {},
|
||||
startsAt: '2023-10-19T10:00:00Z',
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
|
||||
receivers: [],
|
||||
};
|
||||
|
||||
const alertWithoutFingerprint: Alert = {
|
||||
...alertWithFingerprint,
|
||||
fingerprint: undefined,
|
||||
};
|
||||
|
||||
describe('normalizeAlerts', () => {
|
||||
it('returns empty array when given undefined', () => {
|
||||
expect(normalizeAlerts(undefined)).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('preserves existing fingerprints', () => {
|
||||
const result = normalizeAlerts([alertWithFingerprint]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].fingerprint).toBe('fp-existing');
|
||||
});
|
||||
|
||||
it('mints fingerprint when missing', () => {
|
||||
const result = normalizeAlerts([alertWithoutFingerprint]);
|
||||
expect(result[0].fingerprint).toBeDefined();
|
||||
expect(typeof result[0].fingerprint).toBe('string');
|
||||
expect(result[0].fingerprint?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not mutate the input array', () => {
|
||||
const input = [alertWithFingerprint];
|
||||
const copy = JSON.parse(JSON.stringify(input));
|
||||
normalizeAlerts(input);
|
||||
expect(input).toStrictEqual(copy);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlertSortValue', () => {
|
||||
const alert: Alert = {
|
||||
fingerprint: 'fp',
|
||||
labels: { alertname: 'CPU', severity: 'critical' },
|
||||
annotations: {},
|
||||
startsAt: '2023-10-19T10:00:00Z',
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
|
||||
receivers: [],
|
||||
};
|
||||
|
||||
it('returns status.state for "status"', () => {
|
||||
expect(getAlertSortValue(alert, 'status')).toBe('active');
|
||||
});
|
||||
|
||||
it('returns alertname for "alertName"', () => {
|
||||
expect(getAlertSortValue(alert, 'alertName')).toBe('CPU');
|
||||
});
|
||||
|
||||
it('returns severity for "severity"', () => {
|
||||
expect(getAlertSortValue(alert, 'severity')).toBe('critical');
|
||||
});
|
||||
|
||||
it('returns elapsed ms for "firingSince"', () => {
|
||||
const result = getAlertSortValue(alert, 'firingSince');
|
||||
expect(typeof result).toBe('number');
|
||||
expect(result).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns elapsed ms for "duration"', () => {
|
||||
const result = getAlertSortValue(alert, 'duration');
|
||||
expect(typeof result).toBe('number');
|
||||
});
|
||||
|
||||
it('returns empty string for unknown columns', () => {
|
||||
expect(getAlertSortValue(alert, 'unknown')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for missing fields', () => {
|
||||
const empty = { ...alert, status: undefined, labels: undefined };
|
||||
expect(getAlertSortValue(empty, 'status')).toBe('');
|
||||
expect(getAlertSortValue(empty, 'alertName')).toBe('');
|
||||
expect(getAlertSortValue(empty, 'severity')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty for firingSince with no startsAt', () => {
|
||||
const empty = { ...alert, startsAt: undefined };
|
||||
expect(getAlertSortValue(empty, 'firingSince')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortAlerts', () => {
|
||||
const a: Alert = {
|
||||
fingerprint: 'a',
|
||||
labels: { alertname: 'A' },
|
||||
annotations: {},
|
||||
startsAt: '2023-10-19T10:00:00Z',
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
|
||||
receivers: [],
|
||||
};
|
||||
const b: Alert = { ...a, fingerprint: 'b', labels: { alertname: 'B' } };
|
||||
const c: Alert = { ...a, fingerprint: 'c', labels: { alertname: 'C' } };
|
||||
|
||||
it('sorts ascending when given orderBy', () => {
|
||||
const order: SortState = { columnName: 'alertName', order: 'asc' };
|
||||
const result = sortAlerts([c, a, b], order);
|
||||
expect(result.map((x) => x.labels?.alertname)).toStrictEqual(['A', 'B', 'C']);
|
||||
});
|
||||
|
||||
it('sorts descending', () => {
|
||||
const order: SortState = { columnName: 'alertName', order: 'desc' };
|
||||
const result = sortAlerts([a, b, c], order);
|
||||
expect(result.map((x) => x.labels?.alertname)).toStrictEqual(['C', 'B', 'A']);
|
||||
});
|
||||
|
||||
it('falls back to default duration asc when orderBy is null', () => {
|
||||
const result = sortAlerts([a, b, c], null);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRuleId', () => {
|
||||
const base: Alert = {
|
||||
labels: {},
|
||||
annotations: {},
|
||||
startsAt: '2023-10-19T10:00:00Z',
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
|
||||
receivers: [],
|
||||
fingerprint: 'fp',
|
||||
};
|
||||
|
||||
it('returns labels.ruleId when present', () => {
|
||||
expect(getRuleId({ ...base, labels: { ruleId: 'rule-1' } })).toBe('rule-1');
|
||||
});
|
||||
|
||||
it('falls back to generatorURL when ruleId label missing', () => {
|
||||
expect(
|
||||
getRuleId({
|
||||
...base,
|
||||
generatorURL: 'http://localhost/foo?ruleId=rule-42',
|
||||
}),
|
||||
).toBe('rule-42');
|
||||
});
|
||||
|
||||
it('prefers labels.ruleId over generatorURL', () => {
|
||||
expect(
|
||||
getRuleId({
|
||||
...base,
|
||||
labels: { ruleId: 'from-label' },
|
||||
generatorURL: 'http://localhost/foo?ruleId=from-url',
|
||||
}),
|
||||
).toBe('from-label');
|
||||
});
|
||||
|
||||
it('returns null when generatorURL has no ruleId param', () => {
|
||||
expect(
|
||||
getRuleId({ ...base, generatorURL: 'http://localhost/foo' }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when generatorURL is invalid', () => {
|
||||
expect(getRuleId({ ...base, generatorURL: 'not-a-url' })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when no source available', () => {
|
||||
expect(getRuleId(base)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import { render, RenderResult, screen } from '@testing-library/react';
|
||||
import TriggeredAlerts from 'container/TriggeredAlerts';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { AppContext } from 'providers/App/App';
|
||||
import TimezoneProvider from 'providers/Timezone';
|
||||
import { onNuqsUrlUpdate, resetNuqsState } from 'tests/nuqs-helpers';
|
||||
import { getAppContextMock } from 'tests/test-utils';
|
||||
|
||||
interface RenderOptions {
|
||||
initialRoute?: string;
|
||||
}
|
||||
|
||||
export function renderTriggeredAlerts(
|
||||
options: RenderOptions = {},
|
||||
): RenderResult {
|
||||
const { initialRoute = '/' } = options;
|
||||
|
||||
const initialSearch = initialRoute.includes('?')
|
||||
? initialRoute.slice(initialRoute.indexOf('?'))
|
||||
: '';
|
||||
resetNuqsState(initialSearch);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { refetchOnWindowFocus: false, retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initialRoute]}>
|
||||
<NuqsTestingAdapter
|
||||
searchParams={initialSearch}
|
||||
onUrlUpdate={onNuqsUrlUpdate}
|
||||
rateLimitFactor={0}
|
||||
hasMemory
|
||||
>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppContext.Provider value={getAppContextMock('ADMIN')}>
|
||||
<TimezoneProvider>
|
||||
<VirtuosoMockContext.Provider
|
||||
value={{ viewportHeight: 800, itemHeight: 46 }}
|
||||
>
|
||||
<TriggeredAlerts />
|
||||
</VirtuosoMockContext.Provider>
|
||||
</TimezoneProvider>
|
||||
</AppContext.Provider>
|
||||
</QueryClientProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
export async function findAlertRow(alertName: string): Promise<HTMLElement> {
|
||||
const cell = await screen.findByText(alertName, {}, { timeout: 5000 });
|
||||
const row = cell.closest('tr');
|
||||
if (!row) {
|
||||
throw new Error(`Row not found for alert "${alertName}"`);
|
||||
}
|
||||
return row as HTMLElement;
|
||||
}
|
||||
|
||||
export function getTriggeredAlertRowTestId(
|
||||
fingerprint: string,
|
||||
column: 'name' | 'severity' | 'status',
|
||||
): string {
|
||||
return `alert-row-${fingerprint}-${column}`;
|
||||
}
|
||||
@@ -2,32 +2,31 @@ import { Badge } from '@signozhq/ui/badge';
|
||||
|
||||
interface AlertStatusTagProps {
|
||||
state: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function AlertStatusTag({ state, testId }: AlertStatusTagProps): JSX.Element {
|
||||
function AlertStatusTag({ state }: AlertStatusTagProps): JSX.Element {
|
||||
switch (state) {
|
||||
case 'unprocessed':
|
||||
return (
|
||||
<Badge color="success" variant="outline" testId={testId}>
|
||||
<Badge color="success" variant="outline">
|
||||
Unprocessed
|
||||
</Badge>
|
||||
);
|
||||
case 'active':
|
||||
return (
|
||||
<Badge color="error" variant="outline" testId={testId}>
|
||||
<Badge color="error" variant="outline">
|
||||
Firing
|
||||
</Badge>
|
||||
);
|
||||
case 'suppressed':
|
||||
return (
|
||||
<Badge color="error" variant="outline" testId={testId}>
|
||||
<Badge color="error" variant="outline">
|
||||
Suppressed
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Badge color="secondary" variant="outline" testId={testId}>
|
||||
<Badge color="secondary" variant="outline">
|
||||
Unknown
|
||||
</Badge>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@ import { CircleCheck, Plus, RefreshCw } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
|
||||
import styles from './EmptyStates.module.scss';
|
||||
|
||||
@@ -14,12 +13,9 @@ interface EmptyStateProps {
|
||||
export function EmptyState({ onRefresh }: EmptyStateProps): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const handleCreateAlert = useCallback(
|
||||
(e: React.MouseEvent): void => {
|
||||
safeNavigate(ROUTES.ALERTS_NEW, { newTab: isModifierKeyPressed(e) });
|
||||
},
|
||||
[safeNavigate],
|
||||
);
|
||||
const handleCreateAlert = useCallback((): void => {
|
||||
safeNavigate(ROUTES.ALERTS_NEW);
|
||||
}, [safeNavigate]);
|
||||
|
||||
return (
|
||||
<div className={styles.emptyState}>
|
||||
@@ -34,7 +30,6 @@ export function EmptyState({ onRefresh }: EmptyStateProps): JSX.Element {
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={handleCreateAlert}
|
||||
testId="triggered-alerts-empty-create-button"
|
||||
>
|
||||
Create Alert Rule
|
||||
</Button>
|
||||
@@ -44,7 +39,6 @@ export function EmptyState({ onRefresh }: EmptyStateProps): JSX.Element {
|
||||
color="secondary"
|
||||
prefix={<RefreshCw size={14} />}
|
||||
onClick={onRefresh}
|
||||
testId="triggered-alerts-empty-refresh-button"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
@@ -44,7 +44,6 @@ export function GroupTagsCell({
|
||||
prefix={
|
||||
localIsExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />
|
||||
}
|
||||
testId="group-expand-toggle"
|
||||
/>
|
||||
<div className={styles.tagsContainer}>
|
||||
{tags.map((tag) => (
|
||||
|
||||
@@ -175,7 +175,6 @@ function TriggeredAlerts(): JSX.Element {
|
||||
value={searchText}
|
||||
onChange={handleSearchChange}
|
||||
suffix={<Search size={14} className={styles.searchIcon} />}
|
||||
testId="triggered-alerts-search-input"
|
||||
/>
|
||||
<ComboboxSimple
|
||||
className={styles.filterSelect}
|
||||
@@ -187,7 +186,6 @@ function TriggeredAlerts(): JSX.Element {
|
||||
allowCreate
|
||||
items={severyFilters}
|
||||
maxDisplayedPills={2}
|
||||
testId="triggered-alerts-filter-combobox"
|
||||
/>
|
||||
<ComboboxSimple
|
||||
className={styles.filterSelect}
|
||||
@@ -198,7 +196,6 @@ function TriggeredAlerts(): JSX.Element {
|
||||
items={labelOptions}
|
||||
multiple
|
||||
maxDisplayedPills={2}
|
||||
testId="triggered-alerts-groupby-combobox"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -21,11 +21,8 @@ export function getAlertColumns(
|
||||
width: { fixed: '100px' },
|
||||
enableSort: false,
|
||||
enableMove: false,
|
||||
cell: ({ row, value }): JSX.Element => (
|
||||
<AlertStatusTag
|
||||
state={String(value ?? '')}
|
||||
testId={`alert-row-${row.fingerprint ?? ''}-status`}
|
||||
/>
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<AlertStatusTag state={String(value ?? '')} />
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -35,11 +32,8 @@ export function getAlertColumns(
|
||||
width: { default: '100%' },
|
||||
enableSort: true,
|
||||
enableMove: false,
|
||||
cell: ({ row, value }): JSX.Element => (
|
||||
<TanStackTable.Text
|
||||
title={value}
|
||||
data-testid={`alert-row-${row.fingerprint ?? ''}-name`}
|
||||
>
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text title={value}>
|
||||
{String(value ?? '-')}
|
||||
</TanStackTable.Text>
|
||||
),
|
||||
@@ -51,17 +45,15 @@ export function getAlertColumns(
|
||||
width: { fixed: '120px' },
|
||||
enableSort: true,
|
||||
enableMove: false,
|
||||
cell: ({ row, value }): JSX.Element => {
|
||||
cell: ({ value }): JSX.Element => {
|
||||
const severity = String(value ?? '').toLowerCase();
|
||||
const testId = `alert-row-${row.fingerprint ?? ''}-severity`;
|
||||
if (!severity) {
|
||||
return <TanStackTable.Text data-testid={testId}>-</TanStackTable.Text>;
|
||||
return <TanStackTable.Text>-</TanStackTable.Text>;
|
||||
}
|
||||
return (
|
||||
<Badge
|
||||
color={SEVERITY_BADGE_COLORS[severity] ?? 'secondary'}
|
||||
variant="outline"
|
||||
testId={testId}
|
||||
>
|
||||
{severity}
|
||||
</Badge>
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
const orders = Object.values(result.data.variables!).map((v) => v.order);
|
||||
const orders = Object.values(result.data.variables).map((v) => v.order);
|
||||
expect(orders).toContain(0);
|
||||
expect(orders).toContain(1);
|
||||
});
|
||||
@@ -84,7 +84,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.order).toBe(5);
|
||||
expect(result.data.variables.v1.order).toBe(5);
|
||||
});
|
||||
|
||||
it('assigns unique orders across multiple variables that all lack an order', () => {
|
||||
@@ -97,7 +97,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
const orders = Object.values(result.data.variables!).map((v) => v.order);
|
||||
const orders = Object.values(result.data.variables).map((v) => v.order);
|
||||
// All three newly assigned orders must be distinct
|
||||
expect(new Set(orders).size).toBe(3);
|
||||
});
|
||||
@@ -112,7 +112,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.id).toMatch(
|
||||
expect(result.data.variables.v1.id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
});
|
||||
@@ -125,7 +125,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.id).toBe('keep-me');
|
||||
expect(result.data.variables.v1.id).toBe('keep-me');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,7 +145,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.defaultValue).toBe('hello');
|
||||
expect(result.data.variables.v1.defaultValue).toBe('hello');
|
||||
});
|
||||
|
||||
it('does not overwrite an existing defaultValue', () => {
|
||||
@@ -163,7 +163,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.defaultValue).toBe('keep');
|
||||
expect(result.data.variables.v1.defaultValue).toBe('keep');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,7 +178,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.selectedValue).toBe('staging');
|
||||
expect(result.data.variables.v1.selectedValue).toBe('staging');
|
||||
});
|
||||
|
||||
it('applies localStorage allSelected over DB value', () => {
|
||||
@@ -196,7 +196,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.allSelected).toBe(true);
|
||||
expect(result.data.variables.v1.allSelected).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -217,7 +217,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.allSelected).toBe(true);
|
||||
expect(result.data.variables.v1.allSelected).toBe(true);
|
||||
});
|
||||
|
||||
it('sets selectedValue from URL and clears allSelected when showALLOption is true', () => {
|
||||
@@ -237,8 +237,8 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.selectedValue).toBe('dev');
|
||||
expect(result.data.variables!.v1.allSelected).toBe(false);
|
||||
expect(result.data.variables.v1.selectedValue).toBe('dev');
|
||||
expect(result.data.variables.v1.allSelected).toBe(false);
|
||||
});
|
||||
|
||||
it('does not set allSelected=false when showALLOption is false', () => {
|
||||
@@ -258,8 +258,8 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.selectedValue).toBe('dev');
|
||||
expect(result.data.variables!.v1.allSelected).toBe(true);
|
||||
expect(result.data.variables.v1.selectedValue).toBe('dev');
|
||||
expect(result.data.variables.v1.allSelected).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes array URL value to single value for single-select variable', () => {
|
||||
@@ -277,7 +277,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.selectedValue).toBe('prod');
|
||||
expect(result.data.variables.v1.selectedValue).toBe('prod');
|
||||
});
|
||||
|
||||
it('wraps single URL value in array for multi-select variable', () => {
|
||||
@@ -292,7 +292,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.selectedValue).toStrictEqual(['prod']);
|
||||
expect(result.data.variables.v1.selectedValue).toStrictEqual(['prod']);
|
||||
});
|
||||
|
||||
it('looks up URL variable by variable id when name is absent', () => {
|
||||
@@ -306,7 +306,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.selectedValue).toBe('fallback');
|
||||
expect(result.data.variables.v1.selectedValue).toBe('fallback');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -327,11 +327,11 @@ describe('useTransformDashboardVariables', () => {
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({ id: 'id1', name: 'env', selectedValue: 'prod' }),
|
||||
});
|
||||
const originalValue = dashboard.data.variables!.v1.selectedValue;
|
||||
const originalValue = dashboard.data.variables.v1.selectedValue;
|
||||
|
||||
transformDashboardVariables(dashboard);
|
||||
|
||||
expect(dashboard.data.variables!.v1.selectedValue).toBe(originalValue);
|
||||
expect(dashboard.data.variables.v1.selectedValue).toBe(originalValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,12 +22,11 @@ export function useTransformDashboardVariables(dashboardId: string): Pick<
|
||||
localStorageVariables: any,
|
||||
): Dashboard => {
|
||||
const updatedData = data;
|
||||
const variables = data?.data?.variables;
|
||||
if (data && localStorageVariables && variables) {
|
||||
const updatedVariables = variables;
|
||||
if (data && localStorageVariables) {
|
||||
const updatedVariables = data.data.variables;
|
||||
const variablesFromUrl = getUrlVariables();
|
||||
Object.keys(variables).forEach((variable) => {
|
||||
const variableData = variables[variable];
|
||||
Object.keys(data.data.variables).forEach((variable) => {
|
||||
const variableData = data.data.variables[variable];
|
||||
|
||||
// values from url
|
||||
const urlVariable = variableData?.name
|
||||
@@ -35,7 +34,7 @@ export function useTransformDashboardVariables(dashboardId: string): Pick<
|
||||
: variablesFromUrl[variableData.id];
|
||||
|
||||
let updatedVariable = {
|
||||
...variables[variable],
|
||||
...data.data.variables[variable],
|
||||
...localStorageVariables[variableData.name as any],
|
||||
};
|
||||
|
||||
|
||||
@@ -17,6 +17,11 @@ jest.mock('react-redux', () => ({
|
||||
useSelector: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/dashboard/substitute_vars', () => ({
|
||||
getSubstituteVars: jest.fn(),
|
||||
}));
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
|
||||
import { useSyncTimeOnStagedQueryChange } from '../useSyncTimeOnStagedQueryChange';
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
useDispatch: (): jest.Mock => mockDispatch,
|
||||
useSelector: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('store/actions', () => ({
|
||||
UpdateTimeInterval: jest.fn((time: string) => ({
|
||||
type: 'UPDATE_TIME_INTERVAL_THUNK',
|
||||
payload: time,
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockedUseSelector = useSelector as jest.Mock;
|
||||
const mockedUpdateTimeInterval = UpdateTimeInterval as unknown as jest.Mock;
|
||||
|
||||
const setSelectedTime = (value: string): void => {
|
||||
mockedUseSelector.mockImplementation(
|
||||
(selector: (state: { globalTime: { selectedTime: string } }) => unknown) =>
|
||||
selector({ globalTime: { selectedTime: value } }),
|
||||
);
|
||||
};
|
||||
|
||||
describe('useSyncTimeOnStagedQueryChange', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
setSelectedTime('1h');
|
||||
});
|
||||
|
||||
it('does not dispatch on initial mount when stagedQueryId is undefined', () => {
|
||||
renderHook(() => useSyncTimeOnStagedQueryChange(undefined));
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not dispatch on initial mount when stagedQueryId is already defined', () => {
|
||||
renderHook(() => useSyncTimeOnStagedQueryChange('initial-id'));
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not dispatch when stagedQueryId transitions from undefined to defined (first staged query arriving)', () => {
|
||||
const { rerender } = renderHook(
|
||||
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
|
||||
{ initialProps: { id: undefined as string | undefined } },
|
||||
);
|
||||
|
||||
rerender({ id: 'first-id' });
|
||||
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dispatches UpdateTimeInterval with current selectedTime when stagedQueryId changes', () => {
|
||||
const { rerender } = renderHook(
|
||||
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
|
||||
{ initialProps: { id: 'first-id' as string | undefined } },
|
||||
);
|
||||
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ id: 'second-id' });
|
||||
|
||||
expect(mockedUpdateTimeInterval).toHaveBeenCalledTimes(1);
|
||||
expect(mockedUpdateTimeInterval).toHaveBeenCalledWith('1h');
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
expect(mockDispatch).toHaveBeenCalledWith({
|
||||
type: 'UPDATE_TIME_INTERVAL_THUNK',
|
||||
payload: '1h',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not dispatch when selectedTime is "custom" even if stagedQueryId changes', () => {
|
||||
setSelectedTime('custom');
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
|
||||
{ initialProps: { id: 'first-id' as string | undefined } },
|
||||
);
|
||||
|
||||
rerender({ id: 'second-id' });
|
||||
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(mockedUpdateTimeInterval).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not dispatch when only selectedTime changes (stagedQueryId stable)', () => {
|
||||
const { rerender } = renderHook(
|
||||
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
|
||||
{ initialProps: { id: 'stable-id' as string | undefined } },
|
||||
);
|
||||
|
||||
setSelectedTime('5m');
|
||||
rerender({ id: 'stable-id' });
|
||||
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dispatches once per distinct stagedQueryId change', () => {
|
||||
const { rerender } = renderHook(
|
||||
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
|
||||
{ initialProps: { id: 'a' as string | undefined } },
|
||||
);
|
||||
|
||||
rerender({ id: 'b' });
|
||||
rerender({ id: 'c' });
|
||||
rerender({ id: 'c' }); // no change — should not dispatch again
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not dispatch when stagedQueryId transitions from defined to undefined', () => {
|
||||
const { rerender } = renderHook(
|
||||
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
|
||||
{ initialProps: { id: 'first-id' as string | undefined } },
|
||||
);
|
||||
|
||||
rerender({ id: undefined });
|
||||
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the latest selectedTime at the moment of stagedQueryId change', () => {
|
||||
const { rerender } = renderHook(
|
||||
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
|
||||
{ initialProps: { id: 'a' as string | undefined } },
|
||||
);
|
||||
|
||||
setSelectedTime('15m');
|
||||
rerender({ id: 'b' });
|
||||
|
||||
expect(mockedUpdateTimeInterval).toHaveBeenCalledWith('15m');
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
// Push fresh min/max back into Redux whenever the staged query changes for a
|
||||
// relative time interval. The data hooks that read minTime/maxTime from Redux
|
||||
// otherwise keep refetching with the originally frozen window and the time
|
||||
// picker displays a stale absolute range.
|
||||
// ref - SigNoz/signoz#8277
|
||||
export function useSyncTimeOnStagedQueryChange(
|
||||
stagedQueryId: string | undefined,
|
||||
): void {
|
||||
const dispatch = useDispatch();
|
||||
const selectedTime = useSelector<AppState, GlobalReducer['selectedTime']>(
|
||||
(state) => state.globalTime.selectedTime,
|
||||
);
|
||||
const prevStagedQueryIdRef = useRef<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
const prevId = prevStagedQueryIdRef.current;
|
||||
const currentId = stagedQueryId;
|
||||
prevStagedQueryIdRef.current = currentId;
|
||||
|
||||
if (
|
||||
prevId !== undefined &&
|
||||
currentId !== undefined &&
|
||||
prevId !== currentId &&
|
||||
selectedTime !== 'custom'
|
||||
) {
|
||||
dispatch(UpdateTimeInterval(selectedTime));
|
||||
}
|
||||
}, [stagedQueryId, selectedTime, dispatch]);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { getTraceAggregations } from 'api/generated/services/tracedetail';
|
||||
import { ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
|
||||
import useGetTraceAggregations from '../useGetTraceAggregations';
|
||||
|
||||
jest.mock('api/generated/services/tracedetail', () => ({
|
||||
__esModule: true,
|
||||
getTraceAggregations: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ status: 'success', data: { aggregations: [] } }),
|
||||
}));
|
||||
|
||||
const mockApi = getTraceAggregations as jest.Mock;
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
};
|
||||
|
||||
const aggregations = [
|
||||
{ field: { name: 'service.name' }, aggregation: 'execution_time_percentage' },
|
||||
] as never;
|
||||
|
||||
describe('useGetTraceAggregations', () => {
|
||||
beforeEach(() => mockApi.mockClear());
|
||||
|
||||
it('fetches when enabled with a traceId and aggregations', async () => {
|
||||
renderHook(
|
||||
() =>
|
||||
useGetTraceAggregations({ traceId: 't1', aggregations, enabled: true }),
|
||||
{ wrapper },
|
||||
);
|
||||
await waitFor(() => expect(mockApi).toHaveBeenCalledTimes(1));
|
||||
expect(mockApi).toHaveBeenCalledWith({ traceID: 't1' }, { aggregations });
|
||||
});
|
||||
|
||||
it('does not fetch when disabled', () => {
|
||||
renderHook(
|
||||
() =>
|
||||
useGetTraceAggregations({ traceId: 't1', aggregations, enabled: false }),
|
||||
{ wrapper },
|
||||
);
|
||||
expect(mockApi).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fetch without a traceId', () => {
|
||||
renderHook(
|
||||
() => useGetTraceAggregations({ traceId: '', aggregations, enabled: true }),
|
||||
{ wrapper },
|
||||
);
|
||||
expect(mockApi).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fetch with no aggregations requested', () => {
|
||||
renderHook(
|
||||
() =>
|
||||
useGetTraceAggregations({ traceId: 't1', aggregations: [], enabled: true }),
|
||||
{ wrapper },
|
||||
);
|
||||
expect(mockApi).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import { useQuery, UseQueryResult } from 'react-query';
|
||||
import {
|
||||
GetTraceAggregations200,
|
||||
SpantypesSpanAggregationDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getTraceAggregations } from 'api/generated/services/tracedetail';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
|
||||
interface UseGetTraceAggregationsProps {
|
||||
traceId: string;
|
||||
aggregations: SpantypesSpanAggregationDTO[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
type UseGetTraceAggregations = UseQueryResult<GetTraceAggregations200>;
|
||||
|
||||
/**
|
||||
* Fetches trace aggregations on demand — gate via `enabled` so the request
|
||||
* fires only when the Analytics panel is open. The query key includes the
|
||||
* requested fields, so changing the color-by field refetches.
|
||||
*/
|
||||
const useGetTraceAggregations = ({
|
||||
traceId,
|
||||
aggregations,
|
||||
enabled,
|
||||
}: UseGetTraceAggregationsProps): UseGetTraceAggregations =>
|
||||
useQuery({
|
||||
queryFn: () => getTraceAggregations({ traceID: traceId }, { aggregations }),
|
||||
queryKey: [REACT_QUERY_KEY.GET_TRACE_AGGREGATIONS, traceId, aggregations],
|
||||
enabled: enabled && !!traceId && aggregations.length > 0,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
export default useGetTraceAggregations;
|
||||
@@ -1,29 +1,30 @@
|
||||
import { useQuery, UseQueryResult } from 'react-query';
|
||||
import getTraceV4 from 'api/trace/getTraceV4';
|
||||
import getTraceV3 from 'api/trace/getTraceV3';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import {
|
||||
GetTraceV4PayloadProps,
|
||||
GetTraceV4SuccessResponse,
|
||||
GetTraceV3PayloadProps,
|
||||
GetTraceV3SuccessResponse,
|
||||
} from 'types/api/trace/getTraceV3';
|
||||
|
||||
const useGetTraceV4 = (props: GetTraceV4PayloadProps): UseTraceV4 =>
|
||||
const useGetTraceV3 = (props: GetTraceV3PayloadProps): UseTraceV3 =>
|
||||
useQuery({
|
||||
queryFn: () => getTraceV4(props),
|
||||
queryFn: () => getTraceV3(props),
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_TRACE_V4_WATERFALL,
|
||||
REACT_QUERY_KEY.GET_TRACE_V3_WATERFALL,
|
||||
props.traceId,
|
||||
props.selectedSpanId,
|
||||
props.isSelectedSpanIDUnCollapsed,
|
||||
props.aggregations,
|
||||
],
|
||||
enabled: !!props.traceId,
|
||||
keepPreviousData: true,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
type UseTraceV4 = UseQueryResult<
|
||||
SuccessResponse<GetTraceV4SuccessResponse> | ErrorResponse,
|
||||
type UseTraceV3 = UseQueryResult<
|
||||
SuccessResponse<GetTraceV3SuccessResponse> | ErrorResponse,
|
||||
unknown
|
||||
>;
|
||||
|
||||
export default useGetTraceV4;
|
||||
export default useGetTraceV3;
|
||||
@@ -1,85 +0,0 @@
|
||||
import {
|
||||
RuletypesAlertStateDTO,
|
||||
RuletypesAlertTypeDTO,
|
||||
RuletypesPanelTypeDTO,
|
||||
RuletypesQueryTypeDTO,
|
||||
RuletypesRuleTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
RuletypesRuleConditionDTO,
|
||||
RuletypesRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
const baseCondition: RuletypesRuleConditionDTO = {
|
||||
compositeQuery: {
|
||||
queryType: RuletypesQueryTypeDTO.builder,
|
||||
panelType: RuletypesPanelTypeDTO.graph,
|
||||
queries: null,
|
||||
},
|
||||
} as unknown as RuletypesRuleConditionDTO;
|
||||
|
||||
const make = (
|
||||
id: string,
|
||||
overrides: Partial<RuletypesRuleDTO>,
|
||||
): RuletypesRuleDTO => ({
|
||||
id,
|
||||
alert: `Alert ${id}`,
|
||||
alertType: RuletypesAlertTypeDTO.METRIC_BASED_ALERT,
|
||||
condition: baseCondition,
|
||||
createdAt: '2023-10-15T10:00:00Z',
|
||||
updatedAt: '2023-10-19T10:00:00Z',
|
||||
createdBy: 'alice@signoz.io',
|
||||
updatedBy: 'alice@signoz.io',
|
||||
disabled: false,
|
||||
state: RuletypesAlertStateDTO.inactive,
|
||||
labels: { severity: 'info' },
|
||||
annotations: {},
|
||||
source: '',
|
||||
evalWindow: '5m0s',
|
||||
frequency: '1m0s',
|
||||
ruleType: RuletypesRuleTypeDTO.threshold_rule,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const alertRulesFixture: RuletypesRuleDTO[] = [
|
||||
make('rule-1', {
|
||||
alert: 'High CPU Alert',
|
||||
state: RuletypesAlertStateDTO.firing,
|
||||
labels: { severity: 'critical', team: 'infra' },
|
||||
}),
|
||||
make('rule-2', {
|
||||
alert: 'Memory Pending Alert',
|
||||
state: RuletypesAlertStateDTO.pending,
|
||||
labels: { severity: 'warning', team: 'backend' },
|
||||
}),
|
||||
make('rule-3', {
|
||||
alert: 'Healthy Alert',
|
||||
state: RuletypesAlertStateDTO.inactive,
|
||||
labels: { severity: 'info', team: 'infra' },
|
||||
}),
|
||||
make('rule-4', {
|
||||
alert: 'Disabled Alert',
|
||||
state: RuletypesAlertStateDTO.disabled,
|
||||
disabled: true,
|
||||
labels: { severity: 'critical', team: 'frontend' },
|
||||
}),
|
||||
make('rule-5', {
|
||||
alert: 'No Labels Alert',
|
||||
state: RuletypesAlertStateDTO.inactive,
|
||||
labels: {},
|
||||
}),
|
||||
];
|
||||
|
||||
export const alertRulesPaginationFixture: RuletypesRuleDTO[] = Array.from(
|
||||
{ length: 15 },
|
||||
(_, i) =>
|
||||
make(`rule-pag-${i}`, {
|
||||
alert: `Pag Rule ${i}`,
|
||||
state:
|
||||
i % 2 === 0
|
||||
? RuletypesAlertStateDTO.firing
|
||||
: RuletypesAlertStateDTO.inactive,
|
||||
labels: { severity: i % 2 === 0 ? 'critical' : 'warning' },
|
||||
createdAt: `2023-10-${String(i + 1).padStart(2, '0')}T10:00:00Z`,
|
||||
}),
|
||||
);
|
||||
@@ -1,102 +0,0 @@
|
||||
import type { AlertmanagertypesDeprecatedGettableAlertDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const triggeredAlertsFixture: AlertmanagertypesDeprecatedGettableAlertDTO[] =
|
||||
[
|
||||
{
|
||||
fingerprint: 'fp-critical-1',
|
||||
startsAt: '2023-10-19T10:00:00Z',
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
generatorURL: 'http://localhost/alerts/edit?ruleId=rule-1&panelTypes=graph',
|
||||
labels: {
|
||||
alertname: 'High CPU Usage',
|
||||
severity: 'critical',
|
||||
ruleId: 'rule-1',
|
||||
service: 'frontend',
|
||||
env: 'prod',
|
||||
},
|
||||
annotations: {
|
||||
summary: 'CPU above 90%',
|
||||
description: 'Frontend CPU usage critical',
|
||||
},
|
||||
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
|
||||
receivers: ['slack'],
|
||||
},
|
||||
{
|
||||
fingerprint: 'fp-warning-1',
|
||||
startsAt: '2023-10-19T09:00:00Z',
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
generatorURL: 'http://localhost/alerts/edit?ruleId=rule-2',
|
||||
labels: {
|
||||
alertname: 'Memory Warning',
|
||||
severity: 'warning',
|
||||
ruleId: 'rule-2',
|
||||
service: 'backend',
|
||||
env: 'prod',
|
||||
},
|
||||
annotations: { summary: 'Memory high' },
|
||||
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
|
||||
receivers: ['slack'],
|
||||
},
|
||||
{
|
||||
fingerprint: 'fp-info-1',
|
||||
startsAt: '2023-10-19T08:00:00Z',
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
generatorURL: 'http://localhost/alerts/edit?ruleId=rule-3',
|
||||
labels: {
|
||||
alertname: 'Disk Slow',
|
||||
severity: 'info',
|
||||
ruleId: 'rule-3',
|
||||
service: 'frontend',
|
||||
env: 'staging',
|
||||
},
|
||||
annotations: { summary: 'Disk slow' },
|
||||
status: { state: 'unprocessed', silencedBy: [], inhibitedBy: [] },
|
||||
receivers: ['email'],
|
||||
},
|
||||
{
|
||||
fingerprint: 'fp-suppressed-1',
|
||||
startsAt: '2023-10-19T07:00:00Z',
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
generatorURL: 'http://localhost/alerts/edit?ruleId=rule-4',
|
||||
labels: {
|
||||
alertname: 'Network Hiccup',
|
||||
severity: 'error',
|
||||
ruleId: 'rule-4',
|
||||
service: 'backend',
|
||||
env: 'dev',
|
||||
},
|
||||
annotations: { summary: 'Network errors' },
|
||||
status: { state: 'suppressed', silencedBy: ['s-1'], inhibitedBy: [] },
|
||||
receivers: ['pagerduty'],
|
||||
},
|
||||
{
|
||||
fingerprint: 'fp-noseverity',
|
||||
startsAt: '2023-10-19T06:00:00Z',
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
labels: {
|
||||
alertname: 'Unknown Alert',
|
||||
service: 'misc',
|
||||
},
|
||||
annotations: {},
|
||||
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
|
||||
receivers: [],
|
||||
},
|
||||
];
|
||||
|
||||
// Bigger fixture for pagination tests (15 entries → 2 pages at limit=10).
|
||||
export const triggeredAlertsPaginationFixture: AlertmanagertypesDeprecatedGettableAlertDTO[] =
|
||||
Array.from({ length: 15 }, (_, i) => ({
|
||||
fingerprint: `fp-pag-${i}`,
|
||||
startsAt: `2023-10-${String(i + 1).padStart(2, '0')}T10:00:00Z`,
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
generatorURL: `http://localhost/alerts/edit?ruleId=rule-pag-${i}`,
|
||||
labels: {
|
||||
alertname: `Pag Alert ${i}`,
|
||||
severity: i % 2 === 0 ? 'critical' : 'warning',
|
||||
ruleId: `rule-pag-${i}`,
|
||||
service: 'frontend',
|
||||
},
|
||||
annotations: {},
|
||||
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
|
||||
receivers: [],
|
||||
}));
|
||||
@@ -3,8 +3,6 @@ import { rest } from 'msw';
|
||||
import commonEnTranslation from '../../public/locales/en/common.json';
|
||||
import enTranslation from '../../public/locales/en/translation.json';
|
||||
import { allAlertChannels } from './__mockdata__/alerts';
|
||||
import { alertRulesFixture } from './__mockdata__/alert_rules';
|
||||
import { triggeredAlertsFixture } from './__mockdata__/triggered_alerts';
|
||||
import { billingSuccessResponse } from './__mockdata__/billing';
|
||||
import {
|
||||
dashboardSuccessResponse,
|
||||
@@ -238,38 +236,6 @@ export const handlers = [
|
||||
rest.get('http://localhost/api/v1/channels', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: allAlertChannels, status: 'success' })),
|
||||
),
|
||||
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ data: triggeredAlertsFixture, status: 'success' }),
|
||||
),
|
||||
),
|
||||
rest.get('http://localhost/api/v2/rules', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ data: alertRulesFixture, status: 'success' }),
|
||||
),
|
||||
),
|
||||
rest.post('http://localhost/api/v2/rules', async (req, res, ctx) => {
|
||||
const body = (await req.json()) as { alert?: string };
|
||||
return res(
|
||||
ctx.status(201),
|
||||
ctx.json({
|
||||
data: {
|
||||
...alertRulesFixture[0],
|
||||
id: 'new-rule-id',
|
||||
alert: body?.alert ?? 'New Rule',
|
||||
},
|
||||
status: 'success',
|
||||
}),
|
||||
);
|
||||
}),
|
||||
rest.patch('http://localhost/api/v2/rules/:id', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success' })),
|
||||
),
|
||||
rest.delete('http://localhost/api/v2/rules/:id', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success' })),
|
||||
),
|
||||
rest.delete('http://localhost/api/v1/channels/:id', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
|
||||
@@ -86,7 +86,7 @@ function DashboardWidgetInternal({
|
||||
setDashboardData(updatedDashboardData);
|
||||
setDashboardVariablesStore({
|
||||
dashboardId,
|
||||
variables: updatedDashboardData.data.variables ?? {},
|
||||
variables: updatedDashboardData.data.variables,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -10,6 +10,11 @@ jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
|
||||
children,
|
||||
}));
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('lib/history', () => ({
|
||||
push: jest.fn(),
|
||||
listen: jest.fn(() => jest.fn()),
|
||||
|
||||
@@ -14,6 +14,11 @@ jest.mock('AppRoutes/utils', () => ({
|
||||
|
||||
const mockAfterLogin = jest.mocked(afterLogin);
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('lib/history', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
|
||||
@@ -33,15 +33,6 @@
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
min-height: 120px;
|
||||
padding: 24px 12px;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr;
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsRoot,
|
||||
TabsTrigger,
|
||||
} from '@signozhq/ui/tabs';
|
||||
import cx from 'classnames';
|
||||
import { DetailsHeader } from 'components/DetailsPanel';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import useGetTraceAggregations from 'hooks/trace/useGetTraceAggregations';
|
||||
import { generateColorPair } from 'pages/TraceDetailsV3/utils/generateColorPair';
|
||||
import { FloatingPanel } from 'periscope/components/FloatingPanel';
|
||||
import {
|
||||
SpantypesSpanAggregationDTO,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { useTraceStore } from '../../stores/traceStore';
|
||||
import {
|
||||
AGGREGATIONS,
|
||||
getAggregationMap as findAggregationMap,
|
||||
} from '../../utils/aggregations';
|
||||
import AnalyticsTabContent, { AnalyticsRow } from './AnalyticsTabContent';
|
||||
|
||||
import styles from './AnalyticsPanel.module.scss';
|
||||
|
||||
@@ -42,31 +35,10 @@ function AnalyticsPanel({
|
||||
onClose,
|
||||
onTabChange,
|
||||
}: AnalyticsPanelProps): JSX.Element | null {
|
||||
const { id: traceId } = useParams<TraceDetailV3URLProps>();
|
||||
const colorByField = useTraceStore((s) => s.colorByField);
|
||||
const colorByFieldName = colorByField.name;
|
||||
const aggregations = useTraceStore((s) => s.aggregations);
|
||||
const colorByFieldName = useTraceStore((s) => s.colorByField.name);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
// Fetch exec-time % + span count for the current color-by field only, and
|
||||
// only while the panel is open. Changing the field refetches via the key.
|
||||
const aggregationsRequest = useMemo<SpantypesSpanAggregationDTO[]>(() => {
|
||||
// v5 TelemetryFieldKey and the generated DTO are runtime-identical; only
|
||||
// the literal-union vs enum nominal types differ
|
||||
const field = colorByField as unknown as TelemetrytypesTelemetryFieldKeyDTO;
|
||||
return [
|
||||
{ field, aggregation: AGGREGATIONS.EXEC_TIME_PCT },
|
||||
{ field, aggregation: AGGREGATIONS.SPAN_COUNT },
|
||||
];
|
||||
}, [colorByField]);
|
||||
|
||||
const { data, isLoading, isError } = useGetTraceAggregations({
|
||||
traceId: traceId || '',
|
||||
aggregations: aggregationsRequest,
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
const aggregations = data?.data.aggregations;
|
||||
|
||||
const execTimePct = useMemo(
|
||||
() =>
|
||||
findAggregationMap(
|
||||
@@ -83,39 +55,38 @@ function AnalyticsPanel({
|
||||
[aggregations, colorByFieldName],
|
||||
);
|
||||
|
||||
const execTimeRows = useMemo<AnalyticsRow[]>(() => {
|
||||
const execTimeRows = useMemo(() => {
|
||||
if (!execTimePct) {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(execTimePct)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([group, percentage]) => {
|
||||
const pair = generateColorPair(group);
|
||||
return {
|
||||
group,
|
||||
percentage,
|
||||
color: isDarkMode ? pair.color : pair.colorDark,
|
||||
widthPct: Math.min(percentage, 100),
|
||||
label: `${percentage.toFixed(2)}%`,
|
||||
};
|
||||
});
|
||||
})
|
||||
.sort((a, b) => b.percentage - a.percentage);
|
||||
}, [execTimePct, isDarkMode]);
|
||||
|
||||
const spanCountRows = useMemo<AnalyticsRow[]>(() => {
|
||||
const spanCountRows = useMemo(() => {
|
||||
if (!spanCounts) {
|
||||
return [];
|
||||
}
|
||||
const max = Math.max(...Object.values(spanCounts), 1);
|
||||
return Object.entries(spanCounts)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([group, count]) => {
|
||||
const pair = generateColorPair(group);
|
||||
return {
|
||||
group,
|
||||
count,
|
||||
max,
|
||||
color: isDarkMode ? pair.color : pair.colorDark,
|
||||
widthPct: (count / max) * 100,
|
||||
label: String(count),
|
||||
};
|
||||
});
|
||||
})
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}, [spanCounts, isDarkMode]);
|
||||
|
||||
if (!isOpen) {
|
||||
@@ -161,23 +132,65 @@ function AnalyticsPanel({
|
||||
|
||||
<div className={styles.tabsScroll}>
|
||||
<TabsContent value="exec-time">
|
||||
<AnalyticsTabContent
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
fieldName={colorByFieldName}
|
||||
rows={execTimeRows}
|
||||
valueVariant="wide"
|
||||
/>
|
||||
<div className={styles.list}>
|
||||
{execTimeRows.map((row) => (
|
||||
<>
|
||||
<div
|
||||
key={`${row.group}-dot`}
|
||||
className={styles.dot}
|
||||
style={{ backgroundColor: row.color }}
|
||||
/>
|
||||
<span key={`${row.group}-name`} className={styles.serviceName}>
|
||||
{row.group}
|
||||
</span>
|
||||
<div key={`${row.group}-bar`} className={styles.barCell}>
|
||||
<div className={styles.bar}>
|
||||
<div
|
||||
className={styles.barFill}
|
||||
style={{
|
||||
width: `${Math.min(row.percentage, 100)}%`,
|
||||
backgroundColor: row.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className={cx(styles.value, styles.valueWide)}>
|
||||
{row.percentage.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="spans">
|
||||
<AnalyticsTabContent
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
fieldName={colorByFieldName}
|
||||
rows={spanCountRows}
|
||||
valueVariant="narrow"
|
||||
/>
|
||||
<div className={styles.list}>
|
||||
{spanCountRows.map((row) => (
|
||||
<>
|
||||
<div
|
||||
key={`${row.group}-dot`}
|
||||
className={styles.dot}
|
||||
style={{ backgroundColor: row.color }}
|
||||
/>
|
||||
<span key={`${row.group}-name`} className={styles.serviceName}>
|
||||
{row.group}
|
||||
</span>
|
||||
<div key={`${row.group}-bar`} className={styles.barCell}>
|
||||
<div className={styles.bar}>
|
||||
<div
|
||||
className={styles.barFill}
|
||||
style={{
|
||||
width: `${(row.count / row.max) * 100}%`,
|
||||
backgroundColor: row.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className={cx(styles.value, styles.valueNarrow)}>
|
||||
{row.count}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</TabsRoot>
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { Fragment } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import Spinner from 'components/Spinner';
|
||||
|
||||
import styles from './AnalyticsPanel.module.scss';
|
||||
|
||||
export interface AnalyticsRow {
|
||||
group: string;
|
||||
color: string;
|
||||
widthPct: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface AnalyticsTabContentProps {
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
fieldName: string;
|
||||
rows: AnalyticsRow[];
|
||||
valueVariant: 'wide' | 'narrow';
|
||||
}
|
||||
|
||||
// Loading / error / empty render in place of the rows so the tabs stay visible.
|
||||
function AnalyticsTabContent({
|
||||
isLoading,
|
||||
isError,
|
||||
fieldName,
|
||||
rows,
|
||||
valueVariant,
|
||||
}: AnalyticsTabContentProps): JSX.Element {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.state}>
|
||||
<Spinner height="auto" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isError) {
|
||||
return (
|
||||
<div className={styles.state}>
|
||||
<Typography.Text>Couldn't load analytics</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className={styles.state}>
|
||||
<Typography.Text>No data for {fieldName}</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.list}>
|
||||
{rows.map((row) => (
|
||||
<Fragment key={row.group}>
|
||||
<div className={styles.dot} style={{ backgroundColor: row.color }} />
|
||||
<span className={styles.serviceName}>{row.group}</span>
|
||||
<div className={styles.barCell}>
|
||||
<div className={styles.bar}>
|
||||
<div
|
||||
className={styles.barFill}
|
||||
style={{
|
||||
width: `${row.widthPct}%`,
|
||||
backgroundColor: row.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={cx(
|
||||
styles.value,
|
||||
valueVariant === 'wide' ? styles.valueWide : styles.valueNarrow,
|
||||
)}
|
||||
>
|
||||
{row.label}
|
||||
</span>
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AnalyticsTabContent;
|
||||
@@ -1,144 +0,0 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import useGetTraceAggregations from 'hooks/trace/useGetTraceAggregations';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { DEFAULT_COLOR_BY_FIELD } from '../../../constants';
|
||||
import { useTraceStore } from '../../../stores/traceStore';
|
||||
import AnalyticsPanel from '../AnalyticsPanel';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: (): { id: string } => ({ id: 'trace-123' }),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/trace/useGetTraceAggregations', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
// Isolate the panel's own logic from the floating-panel chrome.
|
||||
jest.mock('periscope/components/FloatingPanel', () => ({
|
||||
__esModule: true,
|
||||
FloatingPanel: ({ children }: { children: React.ReactNode }): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
jest.mock('components/DetailsPanel', () => ({
|
||||
__esModule: true,
|
||||
DetailsHeader: (): JSX.Element => <div data-testid="details-header" />,
|
||||
}));
|
||||
jest.mock('components/Spinner', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="spinner" />,
|
||||
}));
|
||||
|
||||
const mockHook = useGetTraceAggregations as jest.Mock;
|
||||
|
||||
const noop = (): void => undefined;
|
||||
|
||||
const renderPanel = (isOpen = true): ReturnType<typeof render> =>
|
||||
render(<AnalyticsPanel isOpen={isOpen} onClose={noop} onTabChange={noop} />);
|
||||
|
||||
const aggregationsResponse = {
|
||||
status: 'success',
|
||||
data: {
|
||||
aggregations: [
|
||||
{
|
||||
field: { name: 'service.name' },
|
||||
aggregation: 'execution_time_percentage',
|
||||
value: { api: 80, db: 20 },
|
||||
},
|
||||
{
|
||||
field: { name: 'service.name' },
|
||||
aggregation: 'span_count',
|
||||
value: { api: 5, db: 2 },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
describe('AnalyticsPanel', () => {
|
||||
beforeEach(() => {
|
||||
mockHook.mockReset();
|
||||
useTraceStore.setState({ colorByField: DEFAULT_COLOR_BY_FIELD });
|
||||
});
|
||||
|
||||
it('renders nothing when closed and does not enable the fetch', () => {
|
||||
mockHook.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
const { container } = renderPanel(false);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(mockHook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ enabled: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('requests both aggregations for the current color-by field when open', () => {
|
||||
mockHook.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
});
|
||||
renderPanel();
|
||||
expect(mockHook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
traceId: 'trace-123',
|
||||
enabled: true,
|
||||
aggregations: [
|
||||
{
|
||||
field: DEFAULT_COLOR_BY_FIELD,
|
||||
aggregation: 'execution_time_percentage',
|
||||
},
|
||||
{ field: DEFAULT_COLOR_BY_FIELD, aggregation: 'span_count' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the loading state with the tabs still visible', () => {
|
||||
mockHook.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
});
|
||||
renderPanel();
|
||||
expect(screen.getByTestId('spinner')).toBeInTheDocument();
|
||||
// tabs stay visible while loading
|
||||
expect(screen.getByText('% exec time')).toBeInTheDocument();
|
||||
expect(screen.getByText('Spans')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an error state when the request fails', () => {
|
||||
mockHook.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
});
|
||||
renderPanel();
|
||||
expect(screen.getByText(/couldn't load analytics/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders rows for the current field on success', () => {
|
||||
mockHook.mockReturnValue({
|
||||
data: aggregationsResponse,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
renderPanel();
|
||||
expect(screen.getByText('api')).toBeInTheDocument();
|
||||
expect(screen.getByText('80.00%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an empty state when the field has no data', () => {
|
||||
mockHook.mockReturnValue({
|
||||
data: { status: 'success', data: { aggregations: [] } },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
renderPanel();
|
||||
expect(screen.getByText(/no data for service.name/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,3 @@
|
||||
// Applied to both the menu content and the submenu content (each renders in
|
||||
// its own portal with a default z-index of 50) so both stack above
|
||||
// FloatingPanel (z-index 999).
|
||||
.traceOptionsDropdown {
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@signozhq/ui/dropdown-menu';
|
||||
import { DropdownMenuSimple as Dropdown } from '@signozhq/ui/dropdown-menu';
|
||||
import { Settings2 } from '@signozhq/icons';
|
||||
|
||||
import { useTraceStore } from '../stores/traceStore';
|
||||
@@ -23,9 +14,6 @@ interface TraceOptionsMenuProps {
|
||||
onOpenPreviewFields: () => void;
|
||||
}
|
||||
|
||||
// Composed from dropdown-menu primitives (instead of DropdownMenuSimple)
|
||||
// because the simple preset offers no way to style the submenu content,
|
||||
// which renders in its own portal and needs a z-index above FloatingPanel.
|
||||
function TraceOptionsMenu({
|
||||
showTraceDetails,
|
||||
onToggleTraceDetails,
|
||||
@@ -37,52 +25,78 @@ function TraceOptionsMenu({
|
||||
(s) => s.availableColorByOptions,
|
||||
);
|
||||
|
||||
const handleColorByChange = (name: string): void => {
|
||||
const next = availableColorByOptions.find((o) => o.field.name === name);
|
||||
if (next) {
|
||||
setColorByField(next.field);
|
||||
const menuItems: MenuItem[] = useMemo(() => {
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
key: 'toggle-trace-details',
|
||||
label: showTraceDetails ? 'Hide trace details' : 'Show trace details',
|
||||
onClick: onToggleTraceDetails,
|
||||
},
|
||||
{
|
||||
key: 'preview-fields',
|
||||
label: 'Preview fields',
|
||||
onClick: onOpenPreviewFields,
|
||||
},
|
||||
];
|
||||
|
||||
// Only show the "Colour by" submenu if there's an actual choice to make.
|
||||
if (availableColorByOptions.length > 1) {
|
||||
items.push({
|
||||
key: 'colour-by',
|
||||
label: 'Colour by',
|
||||
children: [
|
||||
{
|
||||
type: 'group',
|
||||
label: 'COLOUR BY',
|
||||
children: [
|
||||
{
|
||||
type: 'radio-group',
|
||||
value: colorByField.name,
|
||||
onChange: (name: string): void => {
|
||||
const next = availableColorByOptions.find(
|
||||
(o) => o.field.name === name,
|
||||
);
|
||||
if (next) {
|
||||
setColorByField(next.field);
|
||||
}
|
||||
},
|
||||
children: availableColorByOptions.map((opt) => ({
|
||||
type: 'radio',
|
||||
key: opt.field.name,
|
||||
label: opt.label,
|
||||
value: opt.field.name,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return items;
|
||||
}, [
|
||||
showTraceDetails,
|
||||
onToggleTraceDetails,
|
||||
onOpenPreviewFields,
|
||||
colorByField.name,
|
||||
setColorByField,
|
||||
availableColorByOptions,
|
||||
]);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
aria-label="Trace options"
|
||||
prefix={<Settings2 size={14} />}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className={styles.traceOptionsDropdown}>
|
||||
<DropdownMenuItem clickable onSelect={onToggleTraceDetails}>
|
||||
{showTraceDetails ? 'Hide trace details' : 'Show trace details'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem clickable onSelect={onOpenPreviewFields}>
|
||||
Preview fields
|
||||
</DropdownMenuItem>
|
||||
{/* Only show the "Colour by" submenu if there's an actual choice to make. */}
|
||||
{availableColorByOptions.length > 1 && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>Colour by</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className={styles.traceOptionsDropdown}>
|
||||
<DropdownMenuLabel>COLOUR BY</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
value={colorByField.name}
|
||||
onValueChange={handleColorByChange}
|
||||
>
|
||||
{availableColorByOptions.map((opt) => (
|
||||
<DropdownMenuRadioItem key={opt.field.name} value={opt.field.name}>
|
||||
{opt.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Dropdown
|
||||
menu={{ items: menuItems }}
|
||||
align="start"
|
||||
className={styles.traceOptionsDropdown}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
aria-label="Trace options"
|
||||
prefix={<Settings2 size={14} />}
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
import {
|
||||
computeVisualLayout,
|
||||
WIDE_GROUP_THRESHOLD,
|
||||
} from '../computeVisualLayout';
|
||||
import { computeVisualLayout } from '../computeVisualLayout';
|
||||
|
||||
function makeSpan(
|
||||
overrides: Partial<FlamegraphSpan> & {
|
||||
@@ -475,177 +472,4 @@ describe('computeVisualLayout', () => {
|
||||
expect(aRow).toBeGreaterThan(1); // must NOT be at row 1
|
||||
expect(aRow).toBe(3); // next free row after B at row 2 (A overlaps B)
|
||||
});
|
||||
|
||||
// --- Wide-group fast path (> WIDE_GROUP_THRESHOLD siblings) ---
|
||||
// Past the threshold the layout switches to exact overlap-only packing to
|
||||
// avoid the O(N^2) connector-avoidance spiral. These lock in correctness and
|
||||
// the no-overlap invariant at scale.
|
||||
|
||||
function noRowHasOverlap(
|
||||
layout: ReturnType<typeof computeVisualLayout>,
|
||||
): void {
|
||||
for (const row of layout.visualRows) {
|
||||
const sorted = [...row].sort((a, b) => a.timestamp - b.timestamp);
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const prevEnd = sorted[i - 1].timestamp + sorted[i - 1].durationNano / 1e6;
|
||||
expect(sorted[i].timestamp).toBeGreaterThanOrEqual(prevEnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('should pack thousands of sequential leaf siblings into 1 row (wide path)', () => {
|
||||
const root = makeSpan({ spanId: 'root', timestamp: 0, durationNano: 1e12 });
|
||||
const kids: FlamegraphSpan[] = [];
|
||||
// 2000 strictly sequential (non-overlapping) children
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
kids.push(
|
||||
makeSpan({
|
||||
spanId: `k${i}`,
|
||||
parentSpanId: 'root',
|
||||
timestamp: i * 10,
|
||||
durationNano: 5e6, // 5ms, ends before next starts
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const layout = computeVisualLayout([[root], kids]);
|
||||
|
||||
expect(layout.spanToVisualRow['root']).toBe(0);
|
||||
expect(layout.totalVisualRows).toBe(2); // all siblings share row 1
|
||||
for (const k of kids) {
|
||||
expect(layout.spanToVisualRow[k.spanId]).toBe(1);
|
||||
}
|
||||
noRowHasOverlap(layout);
|
||||
});
|
||||
|
||||
it('should pack thousands of fully-overlapping leaf siblings without violations (wide path)', () => {
|
||||
const root = makeSpan({ spanId: 'root', timestamp: 0, durationNano: 1e12 });
|
||||
const kids: FlamegraphSpan[] = [];
|
||||
// 1000 children all spanning the same window → each needs its own row
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
kids.push(
|
||||
makeSpan({
|
||||
spanId: `k${i}`,
|
||||
parentSpanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 100e6,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const layout = computeVisualLayout([[root], kids]);
|
||||
|
||||
expect(layout.totalVisualRows).toBe(1001); // root + 1000 stacked rows
|
||||
expect(Object.keys(layout.spanToVisualRow)).toHaveLength(1001);
|
||||
noRowHasOverlap(layout);
|
||||
});
|
||||
|
||||
it('should keep non-leaf subtrees adjacent within a wide mixed group (wide path)', () => {
|
||||
const root = makeSpan({ spanId: 'root', timestamp: 0, durationNano: 1e12 });
|
||||
const kids: FlamegraphSpan[] = [];
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
kids.push(
|
||||
makeSpan({
|
||||
spanId: `k${i}`,
|
||||
parentSpanId: 'root',
|
||||
timestamp: i * 10,
|
||||
durationNano: 5e6,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// One of the wide siblings has a child of its own
|
||||
const grandchild = makeSpan({
|
||||
spanId: 'gc',
|
||||
parentSpanId: 'k500',
|
||||
timestamp: 5000,
|
||||
durationNano: 2e6,
|
||||
});
|
||||
|
||||
const layout = computeVisualLayout([[root], kids, [grandchild]]);
|
||||
|
||||
const parentRow = layout.spanToVisualRow['k500'];
|
||||
const gcRow = layout.spanToVisualRow['gc'];
|
||||
expect(gcRow - parentRow).toBe(1); // subtree adjacency preserved
|
||||
expect(Object.keys(layout.spanToVisualRow)).toHaveLength(1002);
|
||||
noRowHasOverlap(layout);
|
||||
});
|
||||
|
||||
// --- Regression guards for the wide-trace layout spiral ---
|
||||
// The pre-fix algorithm's connector-avoidance checks formed a positive-
|
||||
// feedback loop on wide SCATTERED groups (short spans spread across the
|
||||
// parent window with modest concurrency): each pushed-down child stamped
|
||||
// connector points on intermediate rows, pushing later children even
|
||||
// higher. Sequential and fully-overlapping groups (tests above) do NOT
|
||||
// trigger it — these two shapes do, and encode its failure signatures.
|
||||
|
||||
function makeScatteredStar(
|
||||
childCount: number,
|
||||
spacingMs: number,
|
||||
durationMs: number,
|
||||
): FlamegraphSpan[][] {
|
||||
const root = makeSpan({
|
||||
spanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: (childCount * spacingMs + durationMs) * 1e6,
|
||||
});
|
||||
const kids: FlamegraphSpan[] = [];
|
||||
for (let i = 0; i < childCount; i++) {
|
||||
kids.push(
|
||||
makeSpan({
|
||||
spanId: `k${i}`,
|
||||
parentSpanId: 'root',
|
||||
timestamp: i * spacingMs,
|
||||
durationNano: durationMs * 1e6,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return [[root], kids];
|
||||
}
|
||||
|
||||
it('should not spiral row count on a scattered group just above the threshold', () => {
|
||||
// Children of 50ms each, starting every 10ms → max temporal concurrency
|
||||
// is 6 (each span overlaps the next 5). The old algorithm spiraled this
|
||||
// shape to ~1 row per child; overlap-only packing needs ~7 including the
|
||||
// root. Sized just above WIDE_GROUP_THRESHOLD so a regression fails fast
|
||||
// (sub-second) rather than burning CI time.
|
||||
const count = WIDE_GROUP_THRESHOLD + 88;
|
||||
const layout = computeVisualLayout(makeScatteredStar(count, 10, 50));
|
||||
|
||||
expect(Object.keys(layout.spanToVisualRow)).toHaveLength(count + 1);
|
||||
// Generous slack over the optimal ~7 — but orders of magnitude below the
|
||||
// one-row-per-child the spiral produced.
|
||||
expect(layout.totalVisualRows).toBeLessThan(30);
|
||||
noRowHasOverlap(layout);
|
||||
});
|
||||
|
||||
it('should be faster through the fast path despite one extra span', () => {
|
||||
// Identical scattered shape on both sides of the gate: THRESHOLD children
|
||||
// run the original connector-avoidance path, THRESHOLD + 1 run the
|
||||
// overlap-only fast path. With one MORE span to place, the fast path can
|
||||
// only win because the algorithm is cheaper — a self-calibrating
|
||||
// comparison (same machine, same process) with no absolute time budget.
|
||||
// On this shape the avoidance path spirals (~tens of ms) while the fast
|
||||
// path stays ~1ms, so the margin is well past timer noise. Also guards
|
||||
// the gate itself: if everything routed to one path, one extra span can
|
||||
// never be faster.
|
||||
const avoidancePathInput = makeScatteredStar(WIDE_GROUP_THRESHOLD, 10, 50);
|
||||
const fastPathInput = makeScatteredStar(WIDE_GROUP_THRESHOLD + 1, 10, 50);
|
||||
|
||||
// Warm-up runs so JIT compilation doesn't skew either side.
|
||||
computeVisualLayout(avoidancePathInput);
|
||||
computeVisualLayout(fastPathInput);
|
||||
|
||||
const timeOf = (input: FlamegraphSpan[][]): number => {
|
||||
const start = performance.now();
|
||||
computeVisualLayout(input);
|
||||
return performance.now() - start;
|
||||
};
|
||||
|
||||
// Best-of-3 per side to shave off GC pauses and scheduler noise.
|
||||
const RUNS = [0, 1, 2];
|
||||
const avoidanceMs = Math.min(...RUNS.map(() => timeOf(avoidancePathInput)));
|
||||
const fastMs = Math.min(...RUNS.map(() => timeOf(fastPathInput)));
|
||||
|
||||
expect(fastMs).toBeLessThan(avoidanceMs);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,82 +18,6 @@ export interface VisualLayout {
|
||||
totalVisualRows: number;
|
||||
}
|
||||
|
||||
// Above this many siblings under one parent, the connector-avoidance refinement
|
||||
// (Checks 2 & 3) is both visually meaningless — the row is already a dense wall —
|
||||
// and quadratic: every child deposits a connector point on each intermediate row,
|
||||
// which pushes later children even higher, which deposits more points. That
|
||||
// feedback loop inflates a layout needing ~50 rows to thousands and never
|
||||
// finishes on wide traces. Past the threshold we pack by overlap only.
|
||||
// Exported so the regression tests stay anchored to the real gate value.
|
||||
export const WIDE_GROUP_THRESHOLD = 512;
|
||||
|
||||
/**
|
||||
* Segment tree over rows that answers "lowest row index >= `from` whose smallest
|
||||
* span start-time is >= `end`" in O(log rows). Used to place a large group of
|
||||
* leaf siblings by overlap only: because siblings are processed in descending
|
||||
* start order, every already-placed span on a row starts at or after the current
|
||||
* one, so [start, end] overlaps a row iff some span there starts before `end` —
|
||||
* i.e. the row is free iff its minimum start >= end. Each node stores the max of
|
||||
* its subtree's per-row minimum starts so a free row can be found by descent.
|
||||
*/
|
||||
class LowestFreeRow {
|
||||
private readonly size: number;
|
||||
|
||||
private readonly tree: Float64Array;
|
||||
|
||||
constructor(rows: number) {
|
||||
let size = 1;
|
||||
while (size < rows) {
|
||||
size *= 2;
|
||||
}
|
||||
this.size = size;
|
||||
this.tree = new Float64Array(size * 2).fill(Infinity);
|
||||
}
|
||||
|
||||
place(row: number, start: number): void {
|
||||
let i = row + this.size;
|
||||
// A row's key is the minimum start among its spans. Children are processed
|
||||
// in descending start order so a leaf's start is the new minimum, but a
|
||||
// non-leaf subtree's descendant can land on a row out of order — take min.
|
||||
if (start >= this.tree[i]) {
|
||||
return;
|
||||
}
|
||||
this.tree[i] = start;
|
||||
for (i >>= 1; i >= 1; i >>= 1) {
|
||||
const next = Math.max(this.tree[2 * i], this.tree[2 * i + 1]);
|
||||
if (this.tree[i] === next) {
|
||||
break;
|
||||
}
|
||||
this.tree[i] = next;
|
||||
}
|
||||
}
|
||||
|
||||
lowestFrom(from: number, end: number): number {
|
||||
return this.descend(1, 0, this.size - 1, from, end);
|
||||
}
|
||||
|
||||
private descend(
|
||||
node: number,
|
||||
lo: number,
|
||||
hi: number,
|
||||
from: number,
|
||||
end: number,
|
||||
): number {
|
||||
if (hi < from || this.tree[node] < end) {
|
||||
return -1;
|
||||
}
|
||||
if (lo === hi) {
|
||||
return lo;
|
||||
}
|
||||
const mid = (lo + hi) >> 1;
|
||||
const left = this.descend(2 * node, lo, mid, from, end);
|
||||
if (left !== -1) {
|
||||
return left;
|
||||
}
|
||||
return this.descend(2 * node + 1, mid + 1, hi, from, end);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes an overlap-safe visual layout for flamegraph spans using DFS ordering.
|
||||
*
|
||||
@@ -290,53 +214,7 @@ export function computeVisualLayout(spans: FlamegraphSpan[][]): VisualLayout {
|
||||
arr.push(point);
|
||||
}
|
||||
|
||||
// Fast path for a parent with a very large group of children: pack by overlap
|
||||
// only (descending greedy), skipping the quadratic connector-avoidance that
|
||||
// spirals at this scale. Leaf children — the bulk of a wide trace — are placed
|
||||
// in O(log rows) via the segment tree; the rare non-leaf subtree falls back to
|
||||
// findPlacement against the shared interval map. Both structures are kept in
|
||||
// sync so each placement sees all prior occupancy. Same ShapeEntry[] contract.
|
||||
function computeWideShape(
|
||||
rootSpan: FlamegraphSpan,
|
||||
children: FlamegraphSpan[],
|
||||
): ShapeEntry[] {
|
||||
const shape: ShapeEntry[] = [{ span: rootSpan, relativeRow: 0 }];
|
||||
const localIntervals = new Map<number, Array<[number, number]>>();
|
||||
// Children occupy relative rows 1..children.length in the worst case.
|
||||
const finder = new LowestFreeRow(children.length + 2);
|
||||
|
||||
const occupy = (row: number, span: FlamegraphSpan): void => {
|
||||
const s = span.timestamp;
|
||||
const e = span.timestamp + span.durationNano / 1e6;
|
||||
shape.push({ span, relativeRow: row });
|
||||
addIntervalTo(localIntervals, row, s, e);
|
||||
finder.place(row, s);
|
||||
};
|
||||
|
||||
for (const child of children) {
|
||||
if (childrenMap.has(child.spanId)) {
|
||||
// Non-leaf: place its whole subtree shape as a unit via findPlacement.
|
||||
const childShape = computeSubtreeShape(child);
|
||||
const offset = findPlacement(childShape, 1, localIntervals);
|
||||
for (const entry of childShape) {
|
||||
occupy(entry.relativeRow + offset, entry.span);
|
||||
}
|
||||
} else {
|
||||
const end = child.timestamp + child.durationNano / 1e6;
|
||||
occupy(finder.lowestFrom(1, end), child);
|
||||
}
|
||||
}
|
||||
|
||||
return shape;
|
||||
}
|
||||
|
||||
function computeSubtreeShape(rootSpan: FlamegraphSpan): ShapeEntry[] {
|
||||
const children = childrenMap.get(rootSpan.spanId);
|
||||
|
||||
if (children && children.length > WIDE_GROUP_THRESHOLD) {
|
||||
return computeWideShape(rootSpan, children);
|
||||
}
|
||||
|
||||
const localIntervals = new Map<number, Array<[number, number]>>();
|
||||
const localConnectorPoints = new Map<number, number[]>();
|
||||
const shape: ShapeEntry[] = [];
|
||||
@@ -347,6 +225,7 @@ export function computeVisualLayout(spans: FlamegraphSpan[][]): VisualLayout {
|
||||
shape.push({ span: rootSpan, relativeRow: 0 });
|
||||
addIntervalTo(localIntervals, 0, rootStart, rootEnd);
|
||||
|
||||
const children = childrenMap.get(rootSpan.spanId);
|
||||
if (children) {
|
||||
for (const child of children) {
|
||||
const childShape = computeSubtreeShape(child);
|
||||
|
||||
@@ -94,7 +94,7 @@ export function useVisualLayoutWorker(spans: FlamegraphSpan[][]): {
|
||||
cleanup();
|
||||
};
|
||||
|
||||
// Timeout: if worker doesn't respond in 15s, terminate and error
|
||||
// Timeout: if worker doesn't respond in 30s, terminate and error
|
||||
const WORKER_TIMEOUT_MS = 15000;
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (requestIdRef.current === currentId && isComputingRef.current) {
|
||||
|
||||
@@ -119,7 +119,6 @@
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
align-items: center;
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
@@ -127,13 +126,16 @@
|
||||
padding: 6px 8px;
|
||||
background: var(--l3-background);
|
||||
|
||||
height: 18px;
|
||||
margin-inline-end: 6px;
|
||||
:global(.ant-input-prefix) {
|
||||
height: 18px;
|
||||
margin-inline-end: 6px;
|
||||
|
||||
svg {
|
||||
opacity: 0.4;
|
||||
svg {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
&,
|
||||
input {
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Skeleton } from 'antd';
|
||||
import { AxiosError } from 'axios';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { GetTraceV4SuccessResponse, SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { GetTraceV3SuccessResponse, SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { TraceWaterfallStates } from './constants';
|
||||
import Error from './TraceWaterfallStates/Error/Error';
|
||||
@@ -22,7 +22,7 @@ interface ITraceWaterfallProps {
|
||||
localUncollapsedNodes: Set<string>;
|
||||
setLocalUncollapsedNodes: Dispatch<SetStateAction<Set<string>>>;
|
||||
traceData:
|
||||
| SuccessResponse<GetTraceV4SuccessResponse, unknown>
|
||||
| SuccessResponse<GetTraceV3SuccessResponse, unknown>
|
||||
| ErrorResponse
|
||||
| undefined;
|
||||
isFetchingTraceData: boolean;
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { getAvailableColorByFieldNames } from '../utils';
|
||||
|
||||
const span = (partial: Partial<SpanV3>): SpanV3 =>
|
||||
({ level: 1, resource: {}, attributes: {}, ...partial }) as SpanV3;
|
||||
|
||||
describe('getAvailableColorByFieldNames', () => {
|
||||
it('returns [] for an empty span set', () => {
|
||||
expect(getAvailableColorByFieldNames([])).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('offers a field if any span carries it, in option order', () => {
|
||||
const spans = [
|
||||
span({ resource: { 'service.name': 'api' } }),
|
||||
// k8s.node.name lives on a non-root span — still offered
|
||||
span({ resource: { 'k8s.node.name': 'node-1' } }),
|
||||
];
|
||||
expect(getAvailableColorByFieldNames(spans)).toStrictEqual([
|
||||
'service.name',
|
||||
'k8s.node.name',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads from attributes when the key is not on resource', () => {
|
||||
const spans = [span({ attributes: { 'host.name': 'box-1' } })];
|
||||
expect(getAvailableColorByFieldNames(spans)).toStrictEqual(['host.name']);
|
||||
});
|
||||
|
||||
it('does not offer fields no span carries', () => {
|
||||
const spans = [span({ resource: { 'service.name': 'api' } })];
|
||||
expect(getAvailableColorByFieldNames(spans)).toStrictEqual(['service.name']);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user