Compare commits

...

30 Commits

Author SHA1 Message Date
aks07
bddd80ad9f feat: minor refactor 2026-01-08 16:01:39 +05:30
aks07
82bbc35acd feat: show badge for mulitple varient attributes 2026-01-08 08:06:50 +05:30
aks07
2aaa2a8158 fix: add unique to attribute options to avoid conflicts in matching attribute names 2026-01-06 16:35:02 +05:30
Niladri Adhikary
14ab4c9b79 fix: fillZero usage in query builder and correct step values to ms (#9880) 2026-01-04 17:00:16 +05:30
Vishal Sharma
9aa0073fef chore(frontend): make docs URL configurable via environment variable (#9908)
* chore(frontend): make docs URL configurable via environment variable

* chore(onboarding): remove internalRedirect property from RDS logs entry
2026-01-03 15:03:52 +00:00
Yunus M
e124a6a269 feat: default max lines to 1 in logs (#9874)
* feat: default max lines to 1

* feat: update test cases
2026-01-03 19:36:02 +05:30
Karan Balani
666bfa7a0f feat: rename org_domains table to auth_domain (#9910) 2026-01-03 16:40:06 +05:30
Vikrant Gupta
c547ba28e5 chore(preference): add integration tests for org preferences (#9913)
* chore(preference): add integration tests for org preferences

* chore(preference): add integration tests for org preferences
2026-01-02 17:12:47 +05:30
Vikrant Gupta
10f8616d47 fix(preference): rename columns for preference tables (#9912)
* fix(preference): rename columns for preference tables

* fix(preference): use go based migration instead of sql based
2026-01-02 16:44:20 +05:30
Piyush Singariya
3718f6da59 tests: fix logspipelines integration test suite (#9905) 2025-12-31 17:20:21 +05:30
Ashwin Bhatkal
4882d7d524 fix: alerts creation from dashboard and panel to happen in new tab (#9902)
* fix: alerts creation from dashboard and panel to happen in new tab

* fix: add tests

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2025-12-31 15:28:44 +05:30
Vinicius Lourenço
a0b722887d perf(logs): reduce amount of re-renders (#9839) 2025-12-31 08:57:36 +00:00
Vikrant Gupta
0b890154b4 feat(dashboard): add public dashboard APIs to open-api spec (#9899)
* feat(dashboard): add public dashboard APIs to open-api spec

* feat(dashboard): split the ee and pkg modules

* feat(dashboard): commit open api spec

* feat(dashboard): fix signoz module test

* feat(dashboard): add license checks

* feat(dashboard): merge main

* feat(dashboard): add anonymous scheme
2025-12-30 20:58:12 +05:30
Pandey
d0ef7b181e chore: add integration tests owner (#9907) 2025-12-30 12:23:03 +00:00
Aditya Singh
19c6aead54 Fix Metrics to Logs/Traces correlation | Limit text box disappears on non numeric input (#9893)
* fix: update limit input to be number type

* fix: add metrics to logs/traces exp transformation

* fix: minor comments

* fix: update span.king mapping for metrics correlation

* fix: remove console

* fix: minor change
2025-12-30 11:52:30 +00:00
Piyush Singariya
032ac75932 fix: pipelines saving failure on first try (#9898)
* fix: pipelines saving failure on first try

* feat: add integration tests

* test: making change in integration tests

---------

Co-authored-by: Nityananda Gohain <nityanandagohain@gmail.com>
2025-12-30 17:11:05 +05:30
Ishan
c6f5a19256 feat: handled case for checkbox query reload (#9871) 2025-12-30 16:21:19 +05:30
Karan Balani
a4ce770941 feat: introduce flagger package for feature flags (#9827) 2025-12-30 15:59:54 +05:30
Srikanth Chekuri
201d5c24a5 test(integration): add dtype=object to preserve types and insert resource keys (#9897) 2025-12-30 00:28:34 +05:30
Aditya Singh
ab8b42fbbe fix: update query suggestion hook to accept options (#9894) 2025-12-29 11:23:34 +00:00
Abhi kumar
f99821bc40 perf: optimize uplot chart data processing (#9881) 2025-12-29 14:40:51 +05:30
Niladri Adhikary
7c051601f2 fix: normalize context-prefixed field keys (#9089)
* feat: normalize context-prefixed field keys

Signed-off-by: “niladrix719” <niladrix719@gmail.com>

* test: added tests validation for context-prefixed field

Signed-off-by: “niladrix719” <niladrix719@gmail.com>

* refactor: moved logic to parse.go

Signed-off-by: “niladrix719” <niladrix719@gmail.com>

* fix: attribute key edge case

Signed-off-by: “niladrix719” <niladrix719@gmail.com>

* fix: corrupt field context

Signed-off-by: “niladrix719” <niladrix719@gmail.com>

* fix: corrupt field context

Signed-off-by: “niladrix719” <niladrix719@gmail.com>

* refactor: parse and signal

Signed-off-by: “niladrix719” <niladrix719@gmail.com>

* refactor: mismatch for unknown signal

Signed-off-by: “niladrix719” <niladrix719@gmail.com>

---------

Signed-off-by: “niladrix719” <niladrix719@gmail.com>
Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2025-12-28 23:17:44 +05:30
Niladri Adhikary
b9f9c00da5 feat: implement case-insensitive query name handling in formula evaluation (#9302)
* feat: implement case-insensitive query name handling in formula evaluation

Signed-off-by: “niladrix719” <niladrix719@gmail.com>

* feat: optimized lookups

Signed-off-by: “niladrix719” <niladrix719@gmail.com>

* feat: updated naming

Signed-off-by: “niladrix719” <niladrix719@gmail.com>

* fix: normalize keys in canDefaultZero for case insensitivity

Signed-off-by: “niladrix719” <niladrix719@gmail.com>

* fix: lookup

Signed-off-by: “niladrix719” <niladrix719@gmail.com>

* fix: canDefaultZero lookup

Signed-off-by: “niladrix719” <niladrix719@gmail.com>

---------

Signed-off-by: “niladrix719” <niladrix719@gmail.com>
Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2025-12-28 22:29:37 +05:30
Asp-irin
49ff86e65a fix: correctly display OS type value for host detail
Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2025-12-28 16:47:45 +05:30
Amlan Kumar Nandy
2dc6febb38 chore: warn users about incorrect usage with y axis unit (#9588) 2025-12-28 10:33:43 +05:30
lif
4ae268d867 fix: improve light mode text color for selected values in query builder (#9876)
In light mode, selected values in query builder Select components appeared
disabled due to inheriting light-colored text from dark mode styles.

This fix adds explicit text color (--text-ink-400) for .ant-select-selection-item
elements in light mode across QueryBuilder, QueryBuilderV2, and
MetricsAggregateSection styles.

Fixes #9801

Signed-off-by: majiayu000 <1835304752@qq.com>
Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2025-12-26 17:38:25 +00:00
Amlan Kumar Nandy
9d78d67461 chore: y axis management in metrics explorer (#9587) 2025-12-26 17:14:15 +00:00
Abhi kumar
055d0ba90d fix: added fix for limit still getting sent in payload even after removing (#9877)
* fix: added fix for limit still getting sent in payload even after removing

* chore: removed console log
2025-12-26 17:35:08 +05:30
Abhi kumar
09dc95cfe9 fix: added fix for metric selection tooltip scroll issue (#9869) 2025-12-26 13:40:19 +05:30
Abhi kumar
d218cd5733 fix: added fix for reduceTo selection based on metric type + code cleanup (#9732)
* fix: added fixes for reduce-to, auto open + metric based default value

* fix: fixed raise condition

* chore: removed unnessasary useeffect from spaceaggregation

* test: added fix for failing test in usequerybuilderoperations

* fix: pr review comments

* fix: pr review changes
2025-12-25 22:54:25 +05:30
167 changed files with 10277 additions and 1164 deletions

4
.github/CODEOWNERS vendored
View File

@@ -47,5 +47,7 @@
/pkg/telemetrytraces/ @srikanthccv
# AuthN / AuthZ Owners
/pkg/authz/ @vikrantgupta25 @therealpandey
# Integration tests
/tests/integration/ @therealpandey

View File

@@ -45,6 +45,7 @@ jobs:
- querier
- ttl
- preference
- logspipelines
sqlstore-provider:
- postgres
- sqlite

View File

@@ -14,8 +14,13 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/licensing/nooplicensing"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/role"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/query-service/app"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
@@ -82,6 +87,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(ctx context.Context, sqlstore sqlstore.SQLStore) factory.ProviderFactory[authz.AuthZ, authz.Config] {
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx))
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, _ role.Module, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser)
},
)
if err != nil {
logger.ErrorContext(ctx, "failed to create signoz", "error", err)

View File

@@ -12,6 +12,7 @@ import (
"github.com/SigNoz/signoz/ee/authz/openfgaschema"
enterpriselicensing "github.com/SigNoz/signoz/ee/licensing"
"github.com/SigNoz/signoz/ee/licensing/httplicensing"
"github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard"
enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app"
"github.com/SigNoz/signoz/ee/sqlschema/postgressqlschema"
"github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore"
@@ -22,7 +23,12 @@ import (
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/role"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
@@ -111,6 +117,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(ctx context.Context, sqlstore sqlstore.SQLStore) factory.ProviderFactory[authz.AuthZ, authz.Config] {
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx))
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, role role.Module, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, role, queryParser, querier, licensing)
},
)
if err != nil {
logger.ErrorContext(ctx, "failed to create signoz", "error", err)

View File

@@ -278,3 +278,13 @@ tokenizer:
token:
# The maximum number of tokens a user can have. This limits the number of concurrent sessions a user can have.
max_per_user: 5
##################### Flagger #####################
flagger:
# Config are the overrides for the feature flags which come directly from the config file.
config:
boolean:
string:
float:
integer:
object:

View File

@@ -206,6 +206,201 @@ paths:
summary: Create session by saml callback
tags:
- sessions
/api/v1/dashboards/{id}/public:
delete:
deprecated: false
description: This endpoints deletes the public sharing config and disables the
public sharing of a dashboard
operationId: DeletePublicDashboard
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Delete public dashboard
tags:
- dashboard
get:
deprecated: false
description: This endpoints returns public sharing config for a dashboard
operationId: GetPublicDashboard
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesGettablePublicDasbhboard'
status:
type: string
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Get public dashboard
tags:
- dashboard
post:
deprecated: false
description: This endpoints creates public sharing config and enables public
sharing of the dashboard
operationId: CreatePublicDashboard
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DashboardtypesPostablePublicDashboard'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TypesIdentifiable'
status:
type: string
type: object
description: Created
"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: Create public dashboard
tags:
- dashboard
put:
deprecated: false
description: This endpoints updates the public sharing config for a dashboard
operationId: UpdatePublicDashboard
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DashboardtypesUpdatablePublicDashboard'
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Update public dashboard
tags:
- dashboard
/api/v1/domains:
get:
deprecated: false
@@ -1286,6 +1481,107 @@ paths:
summary: Update api key
tags:
- users
/api/v1/public/dashboards/{id}:
get:
deprecated: false
description: This endpoints returns the sanitized dashboard data for public
access
operationId: GetPublicDashboardData
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesGettablePublicDashboardData'
status:
type: string
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:
- anonymous:
- public-dashboard:read
summary: Get public dashboard data
tags:
- dashboard
/api/v1/public/dashboards/{id}/widgets/{idx}/query_range:
get:
deprecated: false
description: This endpoint return query range results for a widget of public
dashboard
operationId: GetPublicDashboardWidgetQueryRange
parameters:
- in: path
name: id
required: true
schema:
type: string
- in: path
name: idx
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/Querybuildertypesv5QueryRangeResponse'
status:
type: string
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:
- anonymous:
- public-dashboard:read
summary: Get query range result
tags:
- dashboard
/api/v1/resetPassword:
post:
deprecated: false
@@ -1726,6 +2022,51 @@ paths:
summary: Update user preference
tags:
- preferences
/api/v2/features:
get:
deprecated: false
description: This endpoint returns the supported features and their details
operationId: GetFeatures
responses:
"200":
content:
application/json:
schema:
properties:
data:
items:
$ref: '#/components/schemas/FeaturetypesGettableFeature'
type: array
status:
type: string
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:
- VIEWER
- tokenizer:
- VIEWER
summary: Get features
tags:
- features
/api/v2/orgs/me:
get:
deprecated: false
@@ -2155,6 +2496,60 @@ components:
config:
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
type: object
DashboardtypesDashboard:
properties:
createdAt:
format: date-time
type: string
createdBy:
type: string
data:
$ref: '#/components/schemas/DashboardtypesStorableDashboardData'
id:
type: string
locked:
type: boolean
org_id:
type: string
updatedAt:
format: date-time
type: string
updatedBy:
type: string
type: object
DashboardtypesGettablePublicDasbhboard:
properties:
defaultTimeRange:
type: string
publicPath:
type: string
timeRangeEnabled:
type: boolean
type: object
DashboardtypesGettablePublicDashboardData:
properties:
dashboard:
$ref: '#/components/schemas/DashboardtypesDashboard'
publicDashboard:
$ref: '#/components/schemas/DashboardtypesGettablePublicDasbhboard'
type: object
DashboardtypesPostablePublicDashboard:
properties:
defaultTimeRange:
type: string
timeRangeEnabled:
type: boolean
type: object
DashboardtypesStorableDashboardData:
additionalProperties: {}
type: object
DashboardtypesUpdatablePublicDashboard:
properties:
defaultTimeRange:
type: string
timeRangeEnabled:
type: boolean
type: object
ErrorsJSON:
properties:
code:
@@ -2173,6 +2568,24 @@ components:
message:
type: string
type: object
FeaturetypesGettableFeature:
properties:
defaultVariant:
type: string
description:
type: string
kind:
type: string
name:
type: string
resolvedValue: {}
stage:
type: string
variants:
additionalProperties: {}
nullable: true
type: object
type: object
PreferencetypesPreference:
properties:
allowedScopes:
@@ -2222,6 +2635,52 @@ components:
type:
type: string
type: object
Querybuildertypesv5ExecStats:
properties:
bytesScanned:
minimum: 0
type: integer
durationMs:
minimum: 0
type: integer
rowsScanned:
minimum: 0
type: integer
type: object
Querybuildertypesv5QueryData:
properties:
results:
items: {}
nullable: true
type: array
type: object
Querybuildertypesv5QueryRangeResponse:
properties:
data:
$ref: '#/components/schemas/Querybuildertypesv5QueryData'
meta:
$ref: '#/components/schemas/Querybuildertypesv5ExecStats'
type:
type: string
warning:
$ref: '#/components/schemas/Querybuildertypesv5QueryWarnData'
type: object
Querybuildertypesv5QueryWarnData:
properties:
message:
type: string
url:
type: string
warnings:
items:
$ref: '#/components/schemas/Querybuildertypesv5QueryWarnDataAdditional'
type: array
type: object
Querybuildertypesv5QueryWarnDataAdditional:
properties:
message:
type: string
type: object
RenderErrorResponse:
properties:
error:
@@ -2280,6 +2739,11 @@ components:
ingestion_url:
type: string
type: object
TypesIdentifiable:
properties:
id:
type: string
type: object
TypesInvite:
properties:
createdAt:

View File

@@ -0,0 +1,265 @@
package impldashboard
import (
"context"
"maps"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/role"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/roletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type module struct {
pkgDashboardModule dashboard.Module
store dashboardtypes.Store
settings factory.ScopedProviderSettings
role role.Module
querier querier.Querier
licensing licensing.Licensing
}
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, role role.Module, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
scopedProviderSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard")
pkgDashboardModule := pkgimpldashboard.NewModule(store, settings, analytics, orgGetter, queryParser)
return &module{
pkgDashboardModule: pkgDashboardModule,
store: store,
settings: scopedProviderSettings,
role: role,
querier: querier,
licensing: licensing,
}
}
func (module *module) CreatePublic(ctx context.Context, orgID valuer.UUID, publicDashboard *dashboardtypes.PublicDashboard) error {
_, err := module.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
storablePublicDashboard, err := module.store.GetPublic(ctx, publicDashboard.DashboardID.StringValue())
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return err
}
if storablePublicDashboard != nil {
return errors.Newf(errors.TypeAlreadyExists, dashboardtypes.ErrCodePublicDashboardAlreadyExists, "dashboard with id %s is already public", storablePublicDashboard.DashboardID)
}
role, err := module.role.GetOrCreate(ctx, roletypes.NewRole(roletypes.AnonymousUserRoleName, roletypes.AnonymousUserRoleDescription, roletypes.RoleTypeManaged.StringValue(), orgID))
if err != nil {
return err
}
err = module.role.Assign(ctx, role.ID, orgID, authtypes.MustNewSubject(authtypes.TypeableAnonymous, authtypes.AnonymousUser.StringValue(), orgID, nil))
if err != nil {
return err
}
additionObject := authtypes.MustNewObject(
authtypes.Resource{
Name: dashboardtypes.TypeableMetaResourcePublicDashboard.Name(),
Type: authtypes.TypeMetaResource,
},
authtypes.MustNewSelector(authtypes.TypeMetaResource, publicDashboard.ID.String()),
)
err = module.role.PatchObjects(ctx, orgID, role.ID, authtypes.RelationRead, []*authtypes.Object{additionObject}, nil)
if err != nil {
return err
}
err = module.store.CreatePublic(ctx, dashboardtypes.NewStorablePublicDashboardFromPublicDashboard(publicDashboard))
if err != nil {
return err
}
return nil
}
func (module *module) GetPublic(ctx context.Context, orgID valuer.UUID, dashboardID valuer.UUID) (*dashboardtypes.PublicDashboard, error) {
_, err := module.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
storablePublicDashboard, err := module.store.GetPublic(ctx, dashboardID.StringValue())
if err != nil {
return nil, err
}
return dashboardtypes.NewPublicDashboardFromStorablePublicDashboard(storablePublicDashboard), nil
}
func (module *module) GetDashboardByPublicID(ctx context.Context, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
storableDashboard, err := module.store.GetDashboardByPublicID(ctx, id.StringValue())
if err != nil {
return nil, err
}
return dashboardtypes.NewDashboardFromStorableDashboard(storableDashboard), nil
}
func (module *module) GetPublicDashboardSelectorsAndOrg(ctx context.Context, id valuer.UUID, orgs []*types.Organization) ([]authtypes.Selector, valuer.UUID, error) {
orgIDs := make([]string, len(orgs))
for idx, org := range orgs {
orgIDs[idx] = org.ID.StringValue()
}
storableDashboard, err := module.store.GetDashboardByOrgsAndPublicID(ctx, orgIDs, id.StringValue())
if err != nil {
return nil, valuer.UUID{}, err
}
return []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeMetaResource, id.StringValue()),
}, storableDashboard.OrgID, nil
}
func (module *module) GetPublicWidgetQueryRange(ctx context.Context, id valuer.UUID, widgetIdx, startTime, endTime uint64) (*querybuildertypesv5.QueryRangeResponse, error) {
dashboard, err := module.GetDashboardByPublicID(ctx, id)
if err != nil {
return nil, err
}
query, err := dashboard.GetWidgetQuery(startTime, endTime, widgetIdx, module.settings.Logger())
if err != nil {
return nil, err
}
return module.querier.QueryRange(ctx, dashboard.OrgID, query)
}
func (module *module) UpdatePublic(ctx context.Context, orgID valuer.UUID, publicDashboard *dashboardtypes.PublicDashboard) error {
_, err := module.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
return module.store.UpdatePublic(ctx, dashboardtypes.NewStorablePublicDashboardFromPublicDashboard(publicDashboard))
}
func (module *module) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
dashboard, err := module.Get(ctx, orgID, id)
if err != nil {
return err
}
if dashboard.Locked {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "dashboard is locked, please unlock the dashboard to be delete it")
}
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
err := module.DeletePublic(ctx, orgID, id)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return err
}
err = module.store.Delete(ctx, orgID, id)
if err != nil {
return err
}
return nil
})
if err != nil {
return err
}
return nil
}
func (module *module) DeletePublic(ctx context.Context, orgID valuer.UUID, dashboardID valuer.UUID) error {
_, err := module.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
publicDashboard, err := module.GetPublic(ctx, orgID, dashboardID)
if err != nil {
return err
}
role, err := module.role.GetOrCreate(ctx, roletypes.NewRole(roletypes.AnonymousUserRoleName, roletypes.AnonymousUserRoleDescription, roletypes.RoleTypeManaged.StringValue(), orgID))
if err != nil {
return err
}
deletionObject := authtypes.MustNewObject(
authtypes.Resource{
Name: dashboardtypes.TypeableMetaResourcePublicDashboard.Name(),
Type: authtypes.TypeMetaResource,
},
authtypes.MustNewSelector(authtypes.TypeMetaResource, publicDashboard.ID.String()),
)
err = module.role.PatchObjects(ctx, orgID, role.ID, authtypes.RelationRead, nil, []*authtypes.Object{deletionObject})
if err != nil {
return err
}
err = module.store.DeletePublic(ctx, dashboardID.StringValue())
if err != nil {
return err
}
return nil
}
func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
dashboards, err := module.store.List(ctx, orgID)
if err != nil {
return nil, err
}
publicDashboards, err := module.store.ListPublic(ctx, orgID)
if err != nil {
return nil, err
}
stats := make(map[string]any)
maps.Copy(stats, dashboardtypes.NewStatsFromStorableDashboards(dashboards))
maps.Copy(stats, dashboardtypes.NewStatsFromStorablePublicDashboards(publicDashboards))
return stats, nil
}
func (module *module) Create(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, data dashboardtypes.PostableDashboard) (*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.Create(ctx, orgID, createdBy, creator, data)
}
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.Get(ctx, orgID, id)
}
func (module *module) GetByMetricNames(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]map[string]string, error) {
return module.pkgDashboardModule.GetByMetricNames(ctx, orgID, metricNames)
}
func (module *module) MustGetTypeables() []authtypes.Typeable {
return module.pkgDashboardModule.MustGetTypeables()
}
func (module *module) List(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.List(ctx, orgID)
}
func (module *module) Update(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, data dashboardtypes.UpdatableDashboard, diff int) (*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.Update(ctx, orgID, id, updatedBy, data, diff)
}
func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, role types.Role, lock bool) error {
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedBy, role, lock)
}

View File

@@ -21,10 +21,6 @@ import (
rules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/SigNoz/signoz/pkg/version"
"github.com/gorilla/mux"
)
@@ -101,39 +97,6 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
router.HandleFunc("/api/v1/billing", am.AdminAccess(ah.getBilling)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/portal", am.AdminAccess(ah.LicensingAPI.Portal)).Methods(http.MethodPost)
// dashboards
router.HandleFunc("/api/v1/dashboards/{id}/public", am.AdminAccess(ah.Signoz.Handlers.Dashboard.CreatePublic)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/dashboards/{id}/public", am.AdminAccess(ah.Signoz.Handlers.Dashboard.GetPublic)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/dashboards/{id}/public", am.AdminAccess(ah.Signoz.Handlers.Dashboard.UpdatePublic)).Methods(http.MethodPut)
router.HandleFunc("/api/v1/dashboards/{id}/public", am.AdminAccess(ah.Signoz.Handlers.Dashboard.DeletePublic)).Methods(http.MethodDelete)
// public access for dashboards
router.HandleFunc("/api/v1/public/dashboards/{id}", am.CheckWithoutClaims(
ah.Signoz.Handlers.Dashboard.GetPublicData,
authtypes.RelationRead, authtypes.RelationRead,
dashboardtypes.TypeableMetaResourcePublicDashboard,
func(req *http.Request, orgs []*types.Organization) ([]authtypes.Selector, valuer.UUID, error) {
id, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
return nil, valuer.UUID{}, err
}
return ah.Signoz.Modules.Dashboard.GetPublicDashboardOrgAndSelectors(req.Context(), id, orgs)
})).Methods(http.MethodGet)
router.HandleFunc("/api/v1/public/dashboards/{id}/widgets/{index}/query_range", am.CheckWithoutClaims(
ah.Signoz.Handlers.Dashboard.GetPublicWidgetQueryRange,
authtypes.RelationRead, authtypes.RelationRead,
dashboardtypes.TypeableMetaResourcePublicDashboard,
func(req *http.Request, orgs []*types.Organization) ([]authtypes.Selector, valuer.UUID, error) {
id, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
return nil, valuer.UUID{}, err
}
return ah.Signoz.Modules.Dashboard.GetPublicDashboardOrgAndSelectors(req.Context(), id, orgs)
})).Methods(http.MethodGet)
// v3
router.HandleFunc("/api/v3/licenses", am.AdminAccess(ah.LicensingAPI.Activate)).Methods(http.MethodPost)
router.HandleFunc("/api/v3/licenses", am.AdminAccess(ah.LicensingAPI.Refresh)).Methods(http.MethodPut)

View File

@@ -0,0 +1,29 @@
import { ApiV2Instance as axios } from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorResponseV2, ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { MetricMetadataResponse } from 'types/api/metricsExplorer/v2/getMetricMetadata';
export const getMetricMetadata = async (
metricName: string,
signal?: AbortSignal,
headers?: Record<string, string>,
): Promise<SuccessResponseV2<MetricMetadataResponse> | ErrorResponseV2> => {
try {
const encodedMetricName = encodeURIComponent(metricName);
const response = await axios.get(
`/metrics/metadata?metricName=${encodedMetricName}`,
{
signal,
headers,
},
);
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
return ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};

View File

@@ -59,7 +59,7 @@ jest.mock('providers/preferences/sync/usePreferenceSync', () => ({
preferences: {
columns: [],
formatting: {
maxLines: 2,
maxLines: 1,
format: 'table',
fontSize: 'small',
version: 1,

View File

@@ -0,0 +1,80 @@
.field-variant-badges-container {
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.field-badge {
&.data-type {
display: flex;
height: 20px;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 20px;
background: color-mix(in srgb, var(--bg-vanilla-100) 8%, transparent);
white-space: nowrap;
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.06px;
}
&.type-tag {
display: flex;
align-items: center;
height: 20px;
padding: 0px 6px;
justify-content: center;
gap: 4px;
border-radius: 50px;
text-transform: capitalize;
white-space: nowrap;
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.text {
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.06px;
}
&.attribute {
background: color-mix(in srgb, var(--bg-sienna-400) 10%, transparent);
color: var(--bg-sienna-400);
.dot {
background-color: var(--bg-sienna-400);
}
.text {
color: var(--bg-sienna-400);
}
}
&.resource {
background: color-mix(in srgb, var(--bg-aqua-400) 10%, transparent);
color: var(--bg-aqua-400);
.dot {
background-color: var(--bg-aqua-400);
}
.text {
color: var(--bg-aqua-400);
}
}
}
}

View File

@@ -0,0 +1,69 @@
import './FieldVariantBadges.styles.scss';
import cx from 'classnames';
/**
* Field contexts that should display badges
*/
export enum AllowedFieldContext {
Attribute = 'attribute',
Resource = 'resource',
}
const ALLOWED_FIELD_CONTEXTS = new Set<string>([
AllowedFieldContext.Attribute,
AllowedFieldContext.Resource,
]);
interface FieldVariantBadgesProps {
fieldDataType?: string;
fieldContext?: string;
}
/**
* Determines if a fieldContext badge should be displayed
* Only shows badges for contexts in ALLOWED_FIELD_CONTEXTS
*/
const shouldShowFieldContextBadge = (
fieldContext: string | undefined | null,
): boolean => {
if (!fieldContext) {
return false;
}
return ALLOWED_FIELD_CONTEXTS.has(fieldContext);
};
function FieldVariantBadges({
fieldDataType,
fieldContext,
}: FieldVariantBadgesProps): JSX.Element | null {
// If neither value exists, don't render anything
if (!fieldDataType && !fieldContext) {
return null;
}
// Check if fieldContext should be displayed
const showFieldContext =
fieldContext && shouldShowFieldContextBadge(fieldContext);
return (
<span className="field-variant-badges-container">
{fieldDataType && (
<span className="field-badge data-type">{fieldDataType}</span>
)}
{showFieldContext && (
<section className={cx('field-badge type-tag', fieldContext)}>
<div className="dot" />
<span className="text">{fieldContext}</span>
</section>
)}
</span>
);
}
FieldVariantBadges.defaultProps = {
fieldDataType: undefined,
fieldContext: undefined,
};
export default FieldVariantBadges;

View File

@@ -50,6 +50,13 @@
color: var(--bg-vanilla-400) !important;
font-size: 12px !important;
}
&[type='number']::-webkit-inner-spin-button,
&[type='number']::-webkit-outer-spin-button {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
margin: 0;
}
}
.close-btn {

View File

@@ -14,7 +14,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
// utils
import { FlatLogData } from 'lib/logs/flatLogData';
import { useTimezone } from 'providers/Timezone';
import { useCallback, useMemo, useState } from 'react';
import { memo, useCallback, useMemo } from 'react';
// interfaces
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
@@ -121,10 +121,11 @@ function ListLogView({
}: ListLogViewProps): JSX.Element {
const flattenLogData = useMemo(() => FlatLogData(logData), [logData]);
const [hasActionButtons, setHasActionButtons] = useState<boolean>(false);
const { isHighlighted, isLogsExplorerPage, onLogCopy } = useCopyLogLink(
logData.id,
);
const isReadOnlyLog = !isLogsExplorerPage;
const {
activeLog: activeContextLog,
onAddToQuery: handleAddToQuery,
@@ -180,14 +181,6 @@ function ListLogView({
const logType = getLogIndicatorType(logData);
const handleMouseEnter = (): void => {
setHasActionButtons(true);
};
const handleMouseLeave = (): void => {
setHasActionButtons(false);
};
return (
<>
<Container
@@ -198,8 +191,6 @@ function ListLogView({
}
$isDarkMode={isDarkMode}
$logType={logType}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleDetailedView}
fontSize={fontSize}
>
@@ -251,7 +242,7 @@ function ListLogView({
</div>
</div>
{hasActionButtons && isLogsExplorerPage && (
{!isReadOnlyLog && (
<LogLinesActionButtons
handleShowContext={handleShowContext}
onLogCopy={onLogCopy}
@@ -279,4 +270,4 @@ LogGeneralField.defaultProps = {
linesPerRow: 1,
};
export default ListLogView;
export default memo(ListLogView);

View File

@@ -30,6 +30,11 @@ export const Container = styled(Card)<{
? `margin-bottom:0.3rem;`
: ``}
cursor: pointer;
&:not(:hover) .log-line-action-buttons {
display: none;
}
.ant-card-body {
padding: 0.3rem 0.6rem;

View File

@@ -3,14 +3,15 @@ import './LogLinesActionButtons.styles.scss';
import { LinkOutlined } from '@ant-design/icons';
import { Button, Tooltip } from 'antd';
import { TextSelect } from 'lucide-react';
import { MouseEventHandler } from 'react';
import { memo, MouseEventHandler } from 'react';
export interface LogLinesActionButtonsProps {
handleShowContext: MouseEventHandler<HTMLElement>;
onLogCopy: MouseEventHandler<HTMLElement>;
customClassName?: string;
}
export default function LogLinesActionButtons({
function LogLinesActionButtons({
handleShowContext,
onLogCopy,
customClassName = '',
@@ -40,3 +41,5 @@ export default function LogLinesActionButtons({
LogLinesActionButtons.defaultProps = {
customClassName: '',
};
export default memo(LogLinesActionButtons);

View File

@@ -4,7 +4,6 @@ import LogDetail from 'components/LogDetail';
import { VIEW_TYPES, VIEWS } from 'components/LogDetail/constants';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import LogsExplorerContext from 'container/LogsExplorerContext';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
// hooks
@@ -14,6 +13,7 @@ import { isEmpty, isNumber, isUndefined } from 'lodash-es';
import { useTimezone } from 'providers/Timezone';
import {
KeyboardEvent,
memo,
MouseEvent,
MouseEventHandler,
useCallback,
@@ -47,10 +47,6 @@ function RawLogView({
} = useCopyLogLink(data.id);
const flattenLogData = useMemo(() => FlatLogData(data), [data]);
const {
activeLog: activeContextLog,
onClearActiveLog: handleClearActiveContextLog,
} = useActiveLog();
const {
activeLog,
onSetActiveLog,
@@ -59,7 +55,6 @@ function RawLogView({
onGroupByAttribute,
} = useActiveLog();
const [hasActionButtons, setHasActionButtons] = useState<boolean>(false);
const [selectedTab, setSelectedTab] = useState<VIEWS | undefined>();
const isDarkMode = useIsDarkMode();
@@ -132,7 +127,7 @@ function RawLogView({
const handleClickExpand = useCallback(
(event: MouseEvent) => {
if (activeContextLog || isReadOnly) return;
if (isReadOnly) return;
// Use custom click handler if provided, otherwise use default behavior
if (onLogClick) {
@@ -142,7 +137,7 @@ function RawLogView({
setSelectedTab(VIEW_TYPES.OVERVIEW);
}
},
[activeContextLog, isReadOnly, data, onSetActiveLog, onLogClick],
[isReadOnly, data, onSetActiveLog, onLogClick],
);
const handleCloseLogDetail: DrawerProps['onClose'] = useCallback(
@@ -158,18 +153,6 @@ function RawLogView({
[onClearActiveLog],
);
const handleMouseEnter = useCallback(() => {
if (isReadOnlyLog) return;
setHasActionButtons(true);
}, [isReadOnlyLog]);
const handleMouseLeave = useCallback(() => {
if (isReadOnlyLog) return;
setHasActionButtons(false);
}, [isReadOnlyLog]);
const handleShowContext: MouseEventHandler<HTMLElement> = useCallback(
(event) => {
event.preventDefault();
@@ -196,13 +179,9 @@ function RawLogView({
$isDarkMode={isDarkMode}
$isReadOnly={isReadOnly}
$isHightlightedLog={isUrlHighlighted}
$isActiveLog={
activeLog?.id === data.id || activeContextLog?.id === data.id || isActiveLog
}
$isActiveLog={activeLog?.id === data.id || isActiveLog}
$isCustomHighlighted={isHighlighted}
$logType={logType}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
fontSize={fontSize}
>
<LogStateIndicator
@@ -231,19 +210,13 @@ function RawLogView({
dangerouslySetInnerHTML={html}
/>
{hasActionButtons && (
{!isReadOnlyLog && (
<LogLinesActionButtons
handleShowContext={handleShowContext}
onLogCopy={onLogCopy}
/>
)}
{activeContextLog && (
<LogsExplorerContext
log={activeContextLog}
onClose={handleClearActiveContextLog}
/>
)}
{selectedTab && (
<LogDetail
selectedTab={selectedTab}
@@ -265,4 +238,4 @@ RawLogView.defaultProps = {
isHighlighted: false,
};
export default RawLogView;
export default memo(RawLogView);

View File

@@ -30,6 +30,10 @@ export const RawLogViewContainer = styled(Row)<{
transition: background-color 0.2s ease-in;
&:not(:hover) .log-line-action-buttons {
display: none;
}
.log-state-indicator {
margin: 4px 0;

View File

@@ -16,7 +16,7 @@
z-index: 2;
position: relative;
right: -2px;
width: 240px;
width: 280px;
.font-size-dropdown {
display: flex;
@@ -314,6 +314,23 @@
background-color: var(--bg-ink-200);
cursor: pointer;
}
.name-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
.name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
cursor: pointer;
}
}
&::-webkit-scrollbar {
@@ -402,12 +419,20 @@
cursor: pointer;
}
.name {
flex: 1;
overflow: hidden;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.name-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
width: calc(100% - 26px);
gap: 8px;
min-width: 0;
.name {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
cursor: pointer;
}

View File

@@ -6,8 +6,14 @@ import './LogsFormatOptionsMenu.styles.scss';
import { Button, Input, InputNumber, Popover, Tooltip, Typography } from 'antd';
import { DefaultOptionType } from 'antd/es/select';
import cx from 'classnames';
import FieldVariantBadges from 'components/FieldVariantBadges/FieldVariantBadges';
import { LogViewMode } from 'container/LogsTable';
import { FontSize, OptionsMenuConfig } from 'container/OptionsMenu/types';
import {
buildAttributeKey,
getOptionLabelCounts,
parseAttributeKey,
} from 'container/OptionsMenu/utils';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import {
Check,
@@ -18,7 +24,7 @@ import {
Sliders,
X,
} from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
interface LogsFormatOptionsMenuProps {
items: any;
@@ -208,6 +214,16 @@ function OptionsMenu({
};
}, [selectedValue]);
const optionsLabelCounts = useMemo(() => {
const optionsKeys: { key: string }[] =
addColumn?.options?.map((option) => ({ key: String(option.value) })) || [];
const valuesKeys: { key: string }[] =
addColumn?.value?.map((column) => ({ key: buildAttributeKey(column) })) ||
[];
return getOptionLabelCounts([...optionsKeys, ...valuesKeys]);
}, [addColumn?.options, addColumn?.value]);
return (
<div
className={cx(
@@ -301,33 +317,48 @@ function OptionsMenu({
)}
<div className="column-format-new-options" ref={listRef}>
{addColumn?.options?.map(({ label, value }, index) => (
<div
className={cx('column-name', value === selectedValue && 'selected')}
key={value}
onMouseEnter={(): void => {
if (!initialMouseEnterRef.current) {
setSelectedValue(value as string | null);
}
{addColumn?.options?.map(({ label, value }, index) => {
const { name, fieldContext, fieldDataType } = parseAttributeKey(
String(value),
);
const hasMultipleVariants = (optionsLabelCounts[name] || 0) > 1;
return (
<div
className={cx('column-name', value === selectedValue && 'selected')}
key={value}
onMouseEnter={(): void => {
if (!initialMouseEnterRef.current) {
setSelectedValue(value as string | null);
}
initialMouseEnterRef.current = true;
}}
onMouseMove={(): void => {
// this is added to handle the mouse move explicit event and not the re-rendered on mouse enter event
setSelectedValue(value as string | null);
}}
onClick={(eve): void => {
eve.stopPropagation();
handleColumnSelection(index, addColumn?.options || []);
}}
>
<div className="name">
<Tooltip placement="left" title={label}>
{label}
</Tooltip>
initialMouseEnterRef.current = true;
}}
onMouseMove={(): void => {
// this is added to handle the mouse move explicit event and not the re-rendered on mouse enter event
setSelectedValue(value as string | null);
}}
onClick={(eve): void => {
eve.stopPropagation();
handleColumnSelection(index, addColumn?.options || []);
}}
>
<div className="name-wrapper">
<Tooltip placement="left" title={label}>
<span className="name">{label}</span>
</Tooltip>
{hasMultipleVariants && (
<span className="field-variant-badges">
<FieldVariantBadges
fieldDataType={fieldDataType}
fieldContext={fieldContext}
/>
</span>
)}
</div>
</div>
</div>
))}
);
})}
</div>
</div>
</div>
@@ -416,22 +447,34 @@ function OptionsMenu({
)}
<div className="column-format">
{addColumn?.value?.map(({ name }) => (
<div className="column-name" key={name}>
<div className="name">
<Tooltip placement="left" title={name}>
{name}
{addColumn?.value?.map((column) => {
const uniqueKey = buildAttributeKey(column);
const showBadge = (optionsLabelCounts[column.name] || 0) > 1;
return (
<div className="column-name" key={uniqueKey}>
<Tooltip placement="left" title={column.name}>
<div className="name-wrapper">
<span className="name">{column.name}</span>
{showBadge && (
<span className="field-variant-badges">
<FieldVariantBadges
fieldDataType={column.fieldDataType}
fieldContext={column.fieldContext}
/>
</span>
)}
</div>
</Tooltip>
{addColumn?.value?.length > 1 && (
<X
className="delete-btn"
size={14}
onClick={(): void => addColumn.onRemove(uniqueKey)}
/>
)}
</div>
{addColumn?.value?.length > 1 && (
<X
className="delete-btn"
size={14}
onClick={(): void => addColumn.onRemove(name)}
/>
)}
</div>
))}
);
})}
{addColumn && addColumn?.value?.length === 0 && (
<div className="column-name no-columns-selected">
No columns selected

View File

@@ -1,5 +1,6 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { FontSize } from 'container/OptionsMenu/types';
import { fireEvent, render, waitFor } from 'tests/test-utils';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import LogsFormatOptionsMenu from '../LogsFormatOptionsMenu';
@@ -10,7 +11,7 @@ jest.mock('providers/preferences/sync/usePreferenceSync', () => ({
preferences: {
columns: [],
formatting: {
maxLines: 2,
maxLines: 1,
format: 'table',
fontSize: 'small',
version: 1,
@@ -51,12 +52,21 @@ describe('LogsFormatOptionsMenu (unit)', () => {
selectedOptionFormat="table"
config={{
format: { value: 'table', onChange: formatOnChange },
maxLines: { value: 2, onChange: maxLinesOnChange },
maxLines: { value: 1, onChange: maxLinesOnChange },
fontSize: { value: FontSize.SMALL, onChange: fontSizeOnChange },
addColumn: {
isFetching: false,
value: [],
options: [],
options: [
{
label: 'http.status_code_attribute',
value: 'http.status_code_attribute::dataType_string::context_attribute',
},
{
label: 'http.status_code_attribute',
value: 'http.status_code_attribute::dataType_number::context_resource',
},
],
onFocus: jest.fn(),
onBlur: jest.fn(),
onSearch: jest.fn(),
@@ -154,4 +164,22 @@ describe('LogsFormatOptionsMenu (unit)', () => {
expect(fontSizeOnChange).toHaveBeenCalledWith(FontSize.MEDIUM);
});
});
it('renders FieldVariantBadges for duplicate-name attribute options', async () => {
setup();
// Open the "columns" section and then the add-new-column container by clicking plus
// The popover is already open from setup()
const plusInColumns = document.querySelector(
'.selected-item-content-container .title svg',
) as SVGElement;
fireEvent.click(plusInColumns);
const items = screen.getAllByText('http.status_code_attribute', {
exact: true,
});
expect(items).toHaveLength(2);
expect(screen.getByText('context_attribute')).toBeInTheDocument();
expect(screen.getByText('context_resource')).toBeInTheDocument();
});
});

View File

@@ -560,6 +560,10 @@
border: 1px solid var(--bg-vanilla-300) !important;
background: var(--bg-vanilla-100) !important;
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1) !important;
.ant-select-selection-item {
color: var(--text-ink-400);
}
}
}
}
@@ -569,6 +573,10 @@
border: 1px solid var(--bg-vanilla-300) !important;
background: var(--bg-vanilla-100) !important;
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1) !important;
.ant-select-selection-item {
color: var(--text-ink-400);
}
}
.ant-select-arrow {

View File

@@ -169,6 +169,10 @@
.ant-select-selector {
border: 1px solid var(--bg-vanilla-300) !important;
background: var(--bg-vanilla-100) !important;
.ant-select-selection-item {
color: var(--text-ink-400);
}
}
}
}

View File

@@ -32,6 +32,7 @@ const ADD_ONS_KEYS = {
ORDER_BY: 'order_by',
LIMIT: 'limit',
LEGEND_FORMAT: 'legend_format',
REDUCE_TO: 'reduce_to',
};
const ADD_ONS_KEYS_TO_QUERY_PATH = {
@@ -40,13 +41,14 @@ const ADD_ONS_KEYS_TO_QUERY_PATH = {
[ADD_ONS_KEYS.ORDER_BY]: 'orderBy',
[ADD_ONS_KEYS.LIMIT]: 'limit',
[ADD_ONS_KEYS.LEGEND_FORMAT]: 'legend',
[ADD_ONS_KEYS.REDUCE_TO]: 'reduceTo',
};
const ADD_ONS = [
{
icon: <BarChart2 size={14} />,
label: 'Group By',
key: 'group_by',
key: ADD_ONS_KEYS.GROUP_BY,
description:
'Break down data by attributes like service name, endpoint, status code, or region. Essential for spotting patterns and comparing performance across different segments.',
docLink: 'https://signoz.io/docs/userguide/query-builder-v5/#grouping',
@@ -54,7 +56,7 @@ const ADD_ONS = [
{
icon: <ScrollText size={14} />,
label: 'Having',
key: 'having',
key: ADD_ONS_KEYS.HAVING,
description:
'Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500',
docLink:
@@ -63,7 +65,7 @@ const ADD_ONS = [
{
icon: <ScrollText size={14} />,
label: 'Order By',
key: 'order_by',
key: ADD_ONS_KEYS.ORDER_BY,
description:
'Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers.',
docLink:
@@ -72,7 +74,7 @@ const ADD_ONS = [
{
icon: <ScrollText size={14} />,
label: 'Limit',
key: 'limit',
key: ADD_ONS_KEYS.LIMIT,
description:
'Show only the top/bottom N results. Perfect for focusing on outliers, reducing noise, and improving dashboard performance.',
docLink:
@@ -81,7 +83,7 @@ const ADD_ONS = [
{
icon: <ScrollText size={14} />,
label: 'Legend format',
key: 'legend_format',
key: ADD_ONS_KEYS.LEGEND_FORMAT,
description:
'Customize series labels using variables like {{service.name}}-{{endpoint}}. Makes charts readable at a glance during incident investigation.',
docLink:
@@ -92,7 +94,7 @@ const ADD_ONS = [
const REDUCE_TO = {
icon: <ScrollText size={14} />,
label: 'Reduce to',
key: 'reduce_to',
key: ADD_ONS_KEYS.REDUCE_TO,
description:
'Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value.',
docLink:
@@ -218,10 +220,9 @@ function QueryAddOns({
);
const availableAddOnKeys = new Set(filteredAddOns.map((addOn) => addOn.key));
// Filter and set selected views: add-ons that are both active and available
setSelectedViews(
ADD_ONS.filter(
filteredAddOns.filter(
(addOn) =>
activeAddOnKeys.has(addOn.key) && availableAddOnKeys.has(addOn.key),
),
@@ -375,6 +376,7 @@ function QueryAddOns({
<div className="add-on-content" data-testid="limit-content">
<InputWithLabel
label="Limit"
type="number"
onChange={handleChangeLimit}
initialValue={query?.limit ?? undefined}
placeholder="Enter limit"

View File

@@ -0,0 +1,118 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable react/display-name */
import '@testing-library/jest-dom';
import { jest } from '@jest/globals';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { render, screen } from 'tests/test-utils';
import { Having, IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { UseQueryOperations } from 'types/common/operations.types';
import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder';
import { QueryV2 } from '../QueryV2';
// Local mocks for domain-specific heavy child components
jest.mock(
'../QueryAggregation/QueryAggregation',
() =>
function () {
return <div>QueryAggregation</div>;
},
);
jest.mock(
'../MerticsAggregateSection/MetricsAggregateSection',
() =>
function () {
return <div>MetricsAggregateSection</div>;
},
);
// Mock hooks
jest.mock('hooks/queryBuilder/useQueryBuilder');
jest.mock('hooks/queryBuilder/useQueryBuilderOperations');
const mockedUseQueryBuilder = jest.mocked(useQueryBuilder);
const mockedUseQueryOperations = jest.mocked(
useQueryOperations,
) as jest.MockedFunction<UseQueryOperations>;
describe('QueryV2 - base render', () => {
beforeEach(() => {
const mockCloneQuery = jest.fn() as jest.MockedFunction<
(type: string, q: IBuilderQuery) => void
>;
mockedUseQueryBuilder.mockReturnValue(({
// Only fields used by QueryV2
cloneQuery: mockCloneQuery,
panelType: null,
} as unknown) as QueryBuilderContextType);
mockedUseQueryOperations.mockReturnValue({
isTracePanelType: false,
isMetricsDataSource: false,
operators: [],
spaceAggregationOptions: [],
listOfAdditionalFilters: [],
handleChangeOperator: jest.fn(),
handleSpaceAggregationChange: jest.fn(),
handleChangeAggregatorAttribute: jest.fn(),
handleChangeDataSource: jest.fn(),
handleDeleteQuery: jest.fn(),
handleChangeQueryData: (jest.fn() as unknown) as ReturnType<UseQueryOperations>['handleChangeQueryData'],
handleChangeFormulaData: jest.fn(),
handleQueryFunctionsUpdates: jest.fn(),
listOfAdditionalFormulaFilters: [],
});
});
afterEach(() => {
jest.clearAllMocks();
});
it('renders limit input when dataSource is logs', () => {
const baseQuery: IBuilderQuery = {
queryName: 'A',
dataSource: DataSource.LOGS,
aggregateOperator: '',
aggregations: [],
timeAggregation: '',
spaceAggregation: '',
temporality: '',
functions: [],
filter: undefined,
filters: { items: [], op: 'AND' },
groupBy: [],
expression: '',
disabled: false,
having: [] as Having[],
limit: 10,
stepInterval: null,
orderBy: [],
legend: 'A',
};
render(
<QueryV2
index={0}
isAvailableToDisable
query={baseQuery}
version="v4"
onSignalSourceChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
signalSourceChangeEnabled={false}
queriesCount={1}
showTraceOperator={false}
hasTraceOperator={false}
/>,
);
// Ensure the Limit add-on input is present and is of type number
const limitInput = screen.getByPlaceholderText(
'Enter limit',
) as HTMLInputElement;
expect(limitInput).toBeInTheDocument();
expect(limitInput).toHaveAttribute('type', 'number');
expect(limitInput).toHaveAttribute('name', 'limit');
expect(limitInput).toHaveAttribute('data-testid', 'input-Limit');
});
});

View File

@@ -1,6 +1,12 @@
/* eslint-disable */
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
within,
} from 'tests/test-utils';
import QueryAddOns from '../QueryV2/QueryAddOns/QueryAddOns';
import { PANEL_TYPES } from 'constants/queryBuilder';
@@ -55,16 +61,7 @@ jest.mock('../QueryV2/QueryAddOns/HavingFilter/HavingFilter', () => ({
),
}));
jest.mock(
'container/QueryBuilder/filters/ReduceToFilter/ReduceToFilter',
() => ({
ReduceToFilter: ({ onChange }: any) => (
<button data-testid="reduce-to" onClick={() => onChange('sum')}>
ReduceToFilter
</button>
),
}),
);
// ReduceToFilter is not mocked - we test the actual Ant Design Select component
function baseQuery(overrides: Partial<any> = {}): any {
return {
@@ -140,7 +137,7 @@ describe('QueryAddOns', () => {
expect(screen.getByTestId('order-by-content')).toBeInTheDocument();
});
it('limit input auto-opens when limit is set and changing it calls handler', () => {
it('limit input auto-opens when limit is set and changing it calls handler', async () => {
render(
<QueryAddOns
query={baseQuery({ limit: 5 })}
@@ -183,4 +180,88 @@ describe('QueryAddOns', () => {
expect(screen.getByTestId('limit-content')).toBeInTheDocument();
expect(limitInput.value).toBe('7');
});
it('shows reduce-to add-on when showReduceTo is true', () => {
render(
<QueryAddOns
query={baseQuery()}
version="v5"
isListViewPanel={false}
showReduceTo
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
isForTraceOperator={false}
/>,
);
expect(screen.getByTestId('query-add-on-reduce_to')).toBeInTheDocument();
});
it('auto-opens reduce-to content when reduceTo is set', () => {
render(
<QueryAddOns
query={baseQuery({ reduceTo: 'sum' })}
version="v5"
isListViewPanel={false}
showReduceTo
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
isForTraceOperator={false}
/>,
);
expect(screen.getByTestId('reduce-to-content')).toBeInTheDocument();
});
it('calls handleSetQueryData when reduce-to value changes', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const query = baseQuery({
reduceTo: 'avg',
aggregations: [{ id: 'a', operator: 'count', reduceTo: 'avg' }],
});
render(
<QueryAddOns
query={query}
version="v5"
isListViewPanel={false}
showReduceTo
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
isForTraceOperator={false}
/>,
);
// Wait for the reduce-to content section to be visible (it auto-opens when reduceTo is set)
await waitFor(() => {
expect(screen.getByTestId('reduce-to-content')).toBeInTheDocument();
});
// Get the Select component by its role (combobox)
// The Select is within the reduce-to-content section
const reduceToContent = screen.getByTestId('reduce-to-content');
const selectCombobox = within(reduceToContent).getByRole('combobox');
// Open the dropdown by clicking on the combobox
await user.click(selectCombobox);
// Wait for the dropdown listbox to appear
await screen.findByRole('listbox');
// Find and click the "Sum" option
const sumOption = await screen.findByText('Sum of values in timeframe');
await user.click(sumOption);
// Verify the handler was called with the correct value
await waitFor(() => {
expect(mockHandleSetQueryData).toHaveBeenCalledWith(0, {
...query,
aggregations: [
{
...(query.aggregations?.[0] as any),
reduceTo: 'sum',
},
],
});
});
});
});

View File

@@ -421,11 +421,16 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
...currentFilter,
value: currentFilter.value.filter((val) => val !== value),
};
if (newFilter.value.length === 0) {
query.filters.items = query.filters.items.filter(
(item) => !isEqual(item.key?.key, filter.attributeKey.key),
);
if (query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(
query.filter.expression,
[filter.attributeKey.key],
);
}
} else {
query.filters.items = query.filters.items.map((item) => {
if (isEqual(item.key?.key, filter.attributeKey.key)) {
@@ -435,6 +440,16 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
});
}
} else {
const newFilter = {
...currentFilter,
value: currentFilter.value === value ? null : currentFilter.value,
};
if (newFilter.value === null && query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(
query.filter.expression,
[filter.attributeKey.key],
);
}
query.filters.items = query.filters.items.filter(
(item) => !isEqual(item.key?.key, filter.attributeKey.key),
);

View File

@@ -1,11 +1,18 @@
import './styles.scss';
import { Select } from 'antd';
import { WarningFilled } from '@ant-design/icons';
import { Select, Tooltip } from 'antd';
import { DefaultOptionType } from 'antd/es/select';
import classNames from 'classnames';
import { useMemo } from 'react';
import { UniversalYAxisUnitMappings } from './constants';
import { UniversalYAxisUnit, YAxisUnitSelectorProps } from './types';
import { getYAxisCategories, mapMetricUnitToUniversalUnit } from './utils';
import {
getUniversalNameFromMetricUnit,
getYAxisCategories,
mapMetricUnitToUniversalUnit,
} from './utils';
function YAxisUnitSelector({
value,
@@ -14,9 +21,24 @@ function YAxisUnitSelector({
loading = false,
'data-testid': dataTestId,
source,
initialValue,
}: YAxisUnitSelectorProps): JSX.Element {
const universalUnit = mapMetricUnitToUniversalUnit(value);
const incompatibleUnitMessage = useMemo(() => {
if (!initialValue || !value || loading) return '';
const initialUniversalUnit = mapMetricUnitToUniversalUnit(initialValue);
const currentUniversalUnit = mapMetricUnitToUniversalUnit(value);
if (initialUniversalUnit !== currentUniversalUnit) {
const initialUniversalUnitName = getUniversalNameFromMetricUnit(
initialValue,
);
const currentUniversalUnitName = getUniversalNameFromMetricUnit(value);
return `Unit mismatch. Saved unit is ${initialUniversalUnitName}, but ${currentUniversalUnitName} is selected.`;
}
return '';
}, [initialValue, value, loading]);
const handleSearch = (
searchTerm: string,
currentOption: DefaultOptionType | undefined,
@@ -49,6 +71,16 @@ function YAxisUnitSelector({
placeholder={placeholder}
filterOption={(input, option): boolean => handleSearch(input, option)}
loading={loading}
suffixIcon={
incompatibleUnitMessage ? (
<Tooltip title={incompatibleUnitMessage}>
<WarningFilled />
</Tooltip>
) : undefined
}
className={classNames({
'warning-state': incompatibleUnitMessage,
})}
data-testid={dataTestId}
>
{categories.map((category) => (

View File

@@ -91,4 +91,36 @@ describe('YAxisUnitSelector', () => {
expect(screen.getByText('Bytes (B)')).toBeInTheDocument();
expect(screen.getByText('Seconds (s)')).toBeInTheDocument();
});
it('shows warning message when incompatible unit is selected', () => {
render(
<YAxisUnitSelector
source={YAxisSource.ALERTS}
value="By"
onChange={mockOnChange}
initialValue="s"
/>,
);
const warningIcon = screen.getByLabelText('warning');
expect(warningIcon).toBeInTheDocument();
fireEvent.mouseOver(warningIcon);
return screen
.findByText(
'Unit mismatch. Saved unit is Seconds (s), but Bytes (B) is selected.',
)
.then((el) => expect(el).toBeInTheDocument());
});
it('does not show warning message when compatible unit is selected', () => {
render(
<YAxisUnitSelector
source={YAxisSource.ALERTS}
value="s"
onChange={mockOnChange}
initialValue="s"
/>,
);
const warningIcon = screen.queryByLabelText('warning');
expect(warningIcon).not.toBeInTheDocument();
});
});

View File

@@ -3,3 +3,13 @@
width: 220px;
}
}
.warning-state {
.ant-select-selector {
border-color: var(--bg-amber-400) !important;
}
.anticon {
color: var(--bg-amber-400) !important;
}
}

View File

@@ -6,6 +6,7 @@ export interface YAxisUnitSelectorProps {
disabled?: boolean;
'data-testid'?: string;
source: YAxisSource;
initialValue?: string;
}
export enum UniversalYAxisUnit {

View File

@@ -1,5 +1,7 @@
import ROUTES from './routes';
export const DOCS_BASE_URL = process.env.DOCS_BASE_URL || 'https://signoz.io';
export const WITHOUT_SESSION_PATH = ['/redirect'];
export const AUTH0_REDIRECT_PATH = '/redirect';

View File

@@ -55,6 +55,7 @@ export const REACT_QUERY_KEY = {
GET_METRIC_DETAILS: 'GET_METRIC_DETAILS',
GET_RELATED_METRICS: 'GET_RELATED_METRICS',
GET_INSPECT_METRICS_DETAILS: 'GET_INSPECT_METRICS_DETAILS',
GET_METRIC_METADATA: 'GET_METRIC_METADATA',
// Traces Funnels Query Keys
GET_DOMAINS_LIST: 'GET_DOMAINS_LIST',

View File

@@ -5,9 +5,11 @@ import { useCreateAlertState } from 'container/CreateAlertV2/context';
import ChartPreviewComponent from 'container/FormAlertRules/ChartPreview';
import PlotTag from 'container/NewWidget/LeftContainer/WidgetGraph/PlotTag';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useState } from 'react';
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
import { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { AlertDef } from 'types/api/alerts/def';
import { EQueryType } from 'types/common/dashboard';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -18,7 +20,13 @@ export interface ChartPreviewProps {
function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
const { currentQuery, panelType, stagedQuery } = useQueryBuilder();
const { thresholdState, alertState, setAlertState } = useCreateAlertState();
const {
alertType,
thresholdState,
alertState,
setAlertState,
isEditMode,
} = useCreateAlertState();
const { selectedTime: globalSelectedInterval } = useSelector<
AppState,
GlobalReducer
@@ -27,6 +35,25 @@ function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
const yAxisUnit = alertState.yAxisUnit || '';
const fetchYAxisUnit =
!isEditMode && alertType === AlertTypes.METRICS_BASED_ALERT;
const selectedQueryName = thresholdState.selectedQuery;
const { yAxisUnit: initialYAxisUnit, isLoading } = useGetYAxisUnit(
selectedQueryName,
{
enabled: fetchYAxisUnit,
},
);
// Every time a new metric is selected, set the y-axis unit to its unit value if present
// Only for metrics-based alerts
useEffect(() => {
if (fetchYAxisUnit) {
setAlertState({ type: 'SET_Y_AXIS_UNIT', payload: initialYAxisUnit });
}
}, [initialYAxisUnit, setAlertState, fetchYAxisUnit]);
const headline = (
<div className="chart-preview-headline">
<PlotTag
@@ -34,11 +61,13 @@ function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
panelType={panelType || PANEL_TYPES.TIME_SERIES}
/>
<YAxisUnitSelector
value={alertState.yAxisUnit}
value={yAxisUnit}
initialValue={initialYAxisUnit}
onChange={(value): void => {
setAlertState({ type: 'SET_Y_AXIS_UNIT', payload: value });
}}
source={YAxisSource.ALERTS}
loading={isLoading}
/>
</div>
);

View File

@@ -120,7 +120,6 @@ function FullView({
originalGraphType: selectedPanelType,
};
}
updatedQuery.builder.queryData[0].pageSize = 10;
return {
query: updatedQuery,
graphType: PANEL_TYPES.LIST,

View File

@@ -137,7 +137,6 @@ function GridCardGraph({
originalGraphType: widget.panelTypes,
};
}
updatedQuery.builder.queryData[0].pageSize = 10;
const initialDataSource = updatedQuery.builder.queryData[0].dataSource;
return {
query: updatedQuery,

View File

@@ -24,6 +24,8 @@ const TABLE_WIDGET_TITLE = 'Table Widget';
const WIDGET_HEADER_SEARCH = 'widget-header-search';
const WIDGET_HEADER_SEARCH_INPUT = 'widget-header-search-input';
const TEST_WIDGET_TITLE_RESOLVED = 'Test Widget Title';
const CREATE_ALERTS_TEXT = 'Create Alerts';
const WIDGET_HEADER_OPTIONS_ID = 'widget-header-options';
const mockStore = configureStore([thunk]);
const createMockStore = (): ReturnType<typeof mockStore> =>
@@ -101,6 +103,9 @@ jest.mock('lucide-react', () => ({
CircleX: (): JSX.Element => <svg data-testid="lucide-circle-x" />,
TriangleAlert: (): JSX.Element => <svg data-testid="lucide-triangle-alert" />,
X: (): JSX.Element => <svg data-testid="lucide-x" />,
SquareArrowOutUpRight: (): JSX.Element => (
<svg data-testid="lucide-square-arrow-out-up-right" />
),
}));
jest.mock('antd', () => ({
...jest.requireActual('antd'),
@@ -391,7 +396,7 @@ describe('WidgetHeader', () => {
/>,
);
const moreOptionsIcon = screen.getByTestId('widget-header-options');
const moreOptionsIcon = screen.getByTestId(WIDGET_HEADER_OPTIONS_ID);
expect(moreOptionsIcon).toBeInTheDocument();
});
@@ -455,4 +460,66 @@ describe('WidgetHeader', () => {
expect(screen.getByTestId('threshold')).toBeInTheDocument();
});
describe('Create Alerts Menu Item', () => {
it('renders Create Alerts menu item with external link icon when included in headerMenuList', async () => {
render(
<WidgetHeader
title={TEST_WIDGET_TITLE}
widget={mockWidget}
onView={mockOnView}
queryResponse={mockQueryResponse}
isWarning={false}
isFetchingResponse={false}
tableProcessedDataRef={tableProcessedDataRef}
setSearchTerm={mockSetSearchTerm}
headerMenuList={[MenuItemKeys.CreateAlerts]}
/>,
);
const moreOptionsIcon = await screen.findByTestId(WIDGET_HEADER_OPTIONS_ID);
expect(moreOptionsIcon).toBeInTheDocument();
await userEvent.hover(moreOptionsIcon);
await screen.findByText(CREATE_ALERTS_TEXT);
const externalLinkIcon = await screen.findByTestId(
'lucide-square-arrow-out-up-right',
);
expect(externalLinkIcon).toBeInTheDocument();
});
it('Create Alerts menu item is enabled and clickable', async () => {
const mockCreateAlertsHandler = jest.fn();
const useCreateAlerts = jest.requireMock(
'hooks/queryBuilder/useCreateAlerts',
).default;
useCreateAlerts.mockReturnValue(mockCreateAlertsHandler);
render(
<WidgetHeader
title={TEST_WIDGET_TITLE}
widget={mockWidget}
onView={mockOnView}
queryResponse={mockQueryResponse}
isWarning={false}
isFetchingResponse={false}
tableProcessedDataRef={tableProcessedDataRef}
setSearchTerm={mockSetSearchTerm}
headerMenuList={[MenuItemKeys.CreateAlerts]}
/>,
);
expect(useCreateAlerts).toHaveBeenCalledWith(mockWidget, 'dashboardView');
const moreOptionsIcon = await screen.findByTestId(WIDGET_HEADER_OPTIONS_ID);
await userEvent.hover(moreOptionsIcon);
const createAlertsMenuItem = await screen.findByText(CREATE_ALERTS_TEXT);
// Verify the menu item is clickable by actually clicking it
await userEvent.click(createAlertsMenuItem);
expect(mockCreateAlertsHandler).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -26,7 +26,7 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { isEmpty } from 'lodash-es';
import { CircleX, X } from 'lucide-react';
import { CircleX, SquareArrowOutUpRight, X } from 'lucide-react';
import { unparse } from 'papaparse';
import { useAppContext } from 'providers/App/App';
import { ReactNode, useCallback, useMemo, useState } from 'react';
@@ -185,7 +185,18 @@ function WidgetHeader({
{
key: MenuItemKeys.CreateAlerts,
icon: <AlertOutlined />,
label: MENUITEM_KEYS_VS_LABELS[MenuItemKeys.CreateAlerts],
label: (
<span
style={{
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
}}
>
{MENUITEM_KEYS_VS_LABELS[MenuItemKeys.CreateAlerts]}
<SquareArrowOutUpRight size={10} />
</span>
),
isVisible: headerMenuList?.includes(MenuItemKeys.CreateAlerts) || false,
disabled: false,
},

View File

@@ -5,7 +5,7 @@ import { MenuItemKeys } from './contants';
export interface MenuItem {
key: MenuItemKeys;
icon: ReactNode;
label: string;
label: ReactNode;
isVisible: boolean;
disabled: boolean;
danger?: boolean;

View File

@@ -35,7 +35,7 @@ jest.mock('providers/preferences/sync/usePreferenceSync', () => ({
preferences: {
columns: [],
formatting: {
maxLines: 2,
maxLines: 1,
format: 'table',
fontSize: 'small',
version: 1,

View File

@@ -119,7 +119,7 @@ jest.mock('providers/preferences/sync/usePreferenceSync', () => ({
preferences: {
columns: [],
formatting: {
maxLines: 2,
maxLines: 1,
format: 'table',
fontSize: 'small',
version: 1,

View File

@@ -58,6 +58,27 @@
.explore-content {
padding: 0 8px;
.y-axis-unit-selector-container {
display: flex;
align-items: center;
gap: 10px;
padding-top: 10px;
margin-bottom: 10px;
.save-unit-container {
display: flex;
align-items: center;
gap: 10px;
.ant-btn {
border-radius: 2px;
.ant-typography {
font-size: 12px;
}
}
}
}
.ant-space {
margin-top: 10px;
margin-bottom: 20px;
@@ -75,6 +96,14 @@
.time-series-view {
min-width: 100%;
width: 100%;
position: relative;
.no-unit-warning {
position: absolute;
top: 30px;
right: 40px;
z-index: 1000;
}
}
.time-series-container {

View File

@@ -1,7 +1,7 @@
import './Explorer.styles.scss';
import * as Sentry from '@sentry/react';
import { Switch } from 'antd';
import { Switch, Tooltip } from 'antd';
import logEvent from 'api/common/logEvent';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import WarningPopover from 'components/WarningPopover/WarningPopover';
@@ -25,10 +25,14 @@ import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToD
import { v4 as uuid } from 'uuid';
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
// import QuerySection from './QuerySection';
import MetricDetails from '../MetricDetails/MetricDetails';
import TimeSeries from './TimeSeries';
import { ExplorerTabs } from './types';
import { splitQueryIntoOneChartPerQuery } from './utils';
import {
getMetricUnits,
splitQueryIntoOneChartPerQuery,
useGetMetrics,
} from './utils';
const ONE_CHART_PER_QUERY_ENABLED_KEY = 'isOneChartPerQueryEnabled';
@@ -40,6 +44,34 @@ function Explorer(): JSX.Element {
currentQuery,
} = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
const [isMetricDetailsOpen, setIsMetricDetailsOpen] = useState(false);
const metricNames = useMemo(() => {
const currentMetricNames: string[] = [];
stagedQuery?.builder.queryData.forEach((query) => {
if (query.aggregateAttribute?.key) {
currentMetricNames.push(query.aggregateAttribute?.key);
}
});
return currentMetricNames;
}, [stagedQuery]);
const {
metrics,
isLoading: isMetricUnitsLoading,
isError: isMetricUnitsError,
} = useGetMetrics(metricNames);
const units = useMemo(() => getMetricUnits(metrics), [metrics]);
const areAllMetricUnitsSame = useMemo(
() =>
!isMetricUnitsLoading &&
!isMetricUnitsError &&
units.length > 0 &&
units.every((unit) => unit && unit === units[0]),
[units, isMetricUnitsLoading, isMetricUnitsError],
);
const [searchParams, setSearchParams] = useSearchParams();
const isOneChartPerQueryEnabled =
@@ -48,7 +80,66 @@ function Explorer(): JSX.Element {
const [showOneChartPerQuery, toggleShowOneChartPerQuery] = useState(
isOneChartPerQueryEnabled,
);
const [disableOneChartPerQuery, toggleDisableOneChartPerQuery] = useState(
false,
);
const [selectedTab] = useState<ExplorerTabs>(ExplorerTabs.TIME_SERIES);
const [yAxisUnit, setYAxisUnit] = useState<string | undefined>();
const unitsLength = useMemo(() => units.length, [units]);
const firstUnit = useMemo(() => units?.[0], [units]);
useEffect(() => {
// Set the y axis unit to the first metric unit if
// 1. There is one metric unit and it is not empty
// 2. All metric units are the same and not empty
// Else, set the y axis unit to empty if
// 1. There are more than one metric units and they are not the same
// 2. There are no metric units
// 3. There is exactly one metric unit but it is empty/undefined
if (unitsLength === 0) {
setYAxisUnit(undefined);
} else if (unitsLength === 1 && firstUnit) {
setYAxisUnit(firstUnit);
} else if (unitsLength === 1 && !firstUnit) {
setYAxisUnit(undefined);
} else if (areAllMetricUnitsSame) {
if (firstUnit) {
setYAxisUnit(firstUnit);
} else {
setYAxisUnit(undefined);
}
} else if (unitsLength > 1 && !areAllMetricUnitsSame) {
setYAxisUnit(undefined);
}
}, [unitsLength, firstUnit, areAllMetricUnitsSame]);
useEffect(() => {
// Don't apply logic during loading to avoid overwriting user preferences
if (isMetricUnitsLoading) {
return;
}
// Disable one chart per query if -
// 1. There are more than one metric
// 2. The metric units are not the same
if (units.length > 1 && !areAllMetricUnitsSame) {
toggleShowOneChartPerQuery(true);
toggleDisableOneChartPerQuery(true);
} else if (units.length <= 1) {
toggleShowOneChartPerQuery(false);
toggleDisableOneChartPerQuery(true);
} else {
// When units are the same and loading is complete, restore URL-based preference
toggleShowOneChartPerQuery(isOneChartPerQueryEnabled);
toggleDisableOneChartPerQuery(false);
}
}, [
units,
areAllMetricUnitsSame,
isMetricUnitsLoading,
isOneChartPerQueryEnabled,
]);
const handleToggleShowOneChartPerQuery = (): void => {
toggleShowOneChartPerQuery(!showOneChartPerQuery);
@@ -68,15 +159,20 @@ function Explorer(): JSX.Element {
[updateAllQueriesOperators],
);
const exportDefaultQuery = useMemo(
() =>
updateAllQueriesOperators(
currentQuery || initialQueriesMap[DataSource.METRICS],
PANEL_TYPES.TIME_SERIES,
DataSource.METRICS,
),
[currentQuery, updateAllQueriesOperators],
);
const exportDefaultQuery = useMemo(() => {
const query = updateAllQueriesOperators(
currentQuery || initialQueriesMap[DataSource.METRICS],
PANEL_TYPES.TIME_SERIES,
DataSource.METRICS,
);
if (yAxisUnit && !query.unit) {
return {
...query,
unit: yAxisUnit,
};
}
return query;
}, [currentQuery, updateAllQueriesOperators, yAxisUnit]);
useShareBuilderUrl({ defaultValue: defaultQuery });
@@ -90,8 +186,16 @@ function Explorer(): JSX.Element {
const widgetId = uuid();
let query = queryToExport || exportDefaultQuery;
if (yAxisUnit && !query.unit) {
query = {
...query,
unit: yAxisUnit,
};
}
const dashboardEditView = generateExportToDashboardLink({
query: queryToExport || exportDefaultQuery,
query,
panelType: PANEL_TYPES.TIME_SERIES,
dashboardId: dashboard.id,
widgetId,
@@ -99,17 +203,33 @@ function Explorer(): JSX.Element {
safeNavigate(dashboardEditView);
},
[exportDefaultQuery, safeNavigate],
[exportDefaultQuery, safeNavigate, yAxisUnit],
);
const splitedQueries = useMemo(
() =>
splitQueryIntoOneChartPerQuery(
stagedQuery || initialQueriesMap[DataSource.METRICS],
metricNames,
units,
),
[stagedQuery],
[stagedQuery, metricNames, units],
);
const [selectedMetricName, setSelectedMetricName] = useState<string | null>(
null,
);
const handleOpenMetricDetails = (metricName: string): void => {
setIsMetricDetailsOpen(true);
setSelectedMetricName(metricName);
};
const handleCloseMetricDetails = (): void => {
setIsMetricDetailsOpen(false);
setSelectedMetricName(null);
};
useEffect(() => {
logEvent(MetricsExplorerEvents.TabChanged, {
[MetricsExplorerEventKeys.Tab]: 'explorer',
@@ -123,17 +243,44 @@ function Explorer(): JSX.Element {
const [warning, setWarning] = useState<Warning | undefined>(undefined);
const oneChartPerQueryDisabledTooltip = useMemo(() => {
if (splitedQueries.length <= 1) {
return 'One chart per query cannot be toggled for a single query.';
}
if (units.length <= 1) {
return 'One chart per query cannot be toggled when there is only one metric.';
}
if (disableOneChartPerQuery) {
return 'One chart per query cannot be disabled for multiple queries with different units.';
}
return undefined;
}, [disableOneChartPerQuery, splitedQueries.length, units.length]);
// Show the y axis unit selector if -
// 1. There is only one metric
// 2. The metric has no saved unit
const showYAxisUnitSelector = useMemo(
() => !isMetricUnitsLoading && units.length === 1 && !units[0],
[units, isMetricUnitsLoading],
);
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div className="metrics-explorer-explore-container">
<div className="explore-header">
<div className="explore-header-left-actions">
<span>1 chart/query</span>
<Switch
checked={showOneChartPerQuery}
onChange={handleToggleShowOneChartPerQuery}
size="small"
/>
<Tooltip
open={disableOneChartPerQuery ? undefined : false}
title={oneChartPerQueryDisabledTooltip}
>
<Switch
checked={showOneChartPerQuery}
onChange={handleToggleShowOneChartPerQuery}
disabled={disableOneChartPerQuery || splitedQueries.length <= 1}
size="small"
/>
</Tooltip>
</div>
<div className="explore-header-right-actions">
{!isEmpty(warning) && <WarningPopover warningData={warning} />}
@@ -174,6 +321,16 @@ function Explorer(): JSX.Element {
<TimeSeries
showOneChartPerQuery={showOneChartPerQuery}
setWarning={setWarning}
areAllMetricUnitsSame={areAllMetricUnitsSame}
isMetricUnitsLoading={isMetricUnitsLoading}
isMetricUnitsError={isMetricUnitsError}
metricUnits={units}
metricNames={metricNames}
metrics={metrics}
handleOpenMetricDetails={handleOpenMetricDetails}
yAxisUnit={yAxisUnit}
setYAxisUnit={setYAxisUnit}
showYAxisUnitSelector={showYAxisUnitSelector}
/>
)}
{/* TODO: Enable once we have resolved all related metrics issues */}
@@ -187,9 +344,17 @@ function Explorer(): JSX.Element {
query={exportDefaultQuery}
sourcepage={DataSource.METRICS}
onExport={handleExport}
isOneChartPerQuery={false}
isOneChartPerQuery={showOneChartPerQuery}
splitedQueries={splitedQueries}
/>
{isMetricDetailsOpen && (
<MetricDetails
metricName={selectedMetricName}
isOpen={isMetricDetailsOpen}
onClose={handleCloseMetricDetails}
isModalTimeSelection={false}
/>
)}
</Sentry.ErrorBoundary>
);
}

View File

@@ -1,14 +1,18 @@
import { Color } from '@signozhq/design-tokens';
import { Tooltip, Typography } from 'antd';
import { isAxiosError } from 'axios';
import classNames from 'classnames';
import YAxisUnitSelector from 'components/YAxisUnitSelector';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter/BuilderUnits';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { useMemo, useState } from 'react';
import { AlertTriangle } from 'lucide-react';
import { useMemo } from 'react';
import { useQueries } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
@@ -24,6 +28,13 @@ import { splitQueryIntoOneChartPerQuery } from './utils';
function TimeSeries({
showOneChartPerQuery,
setWarning,
isMetricUnitsLoading,
metricUnits,
metricNames,
handleOpenMetricDetails,
yAxisUnit,
setYAxisUnit,
showYAxisUnitSelector,
}: TimeSeriesProps): JSX.Element {
const { stagedQuery, currentQuery } = useQueryBuilder();
@@ -56,13 +67,14 @@ function TimeSeries({
showOneChartPerQuery
? splitQueryIntoOneChartPerQuery(
stagedQuery || initialQueriesMap[DataSource.METRICS],
metricNames,
metricUnits,
)
: [stagedQuery || initialQueriesMap[DataSource.METRICS]],
[showOneChartPerQuery, stagedQuery],
// eslint-disable-next-line react-hooks/exhaustive-deps
[showOneChartPerQuery, stagedQuery, JSON.stringify(metricUnits)],
);
const [yAxisUnit, setYAxisUnit] = useState<string>('');
const queries = useQueries(
queryPayloads.map((payload, index) => ({
queryKey: [
@@ -126,32 +138,148 @@ function TimeSeries({
setYAxisUnit(value);
};
// TODO: Enable once we have resolved all related metrics v2 api issues
// Show the save unit button if
// 1. There is only one metric
// 2. The metric has no saved unit
// 3. The user has selected a unit
// const showSaveUnitButton = useMemo(
// () =>
// metricUnits.length === 1 &&
// Boolean(metrics?.[0]) &&
// !metricUnits[0] &&
// yAxisUnit,
// [metricUnits, metrics, yAxisUnit],
// );
// const {
// mutate: updateMetricMetadata,
// isLoading: isUpdatingMetricMetadata,
// } = useUpdateMetricMetadata();
// const handleSaveUnit = (): void => {
// updateMetricMetadata(
// {
// metricName: metricNames[0],
// payload: {
// unit: yAxisUnit,
// description: metrics[0]?.description ?? '',
// metricType: metrics[0]?.type as MetricType,
// temporality: metrics[0]?.temporality,
// },
// },
// {
// onSuccess: () => {
// notifications.success({
// message: 'Unit saved successfully',
// });
// queryClient.invalidateQueries([
// REACT_QUERY_KEY.GET_METRIC_DETAILS,
// metricNames[0],
// ]);
// },
// onError: () => {
// notifications.error({
// message: 'Failed to save unit',
// });
// },
// },
// );
// };
return (
<>
<BuilderUnitsFilter onChange={onUnitChangeHandler} yAxisUnit={yAxisUnit} />
<div className="y-axis-unit-selector-container">
{showYAxisUnitSelector && (
<>
<YAxisUnitSelector
onChange={onUnitChangeHandler}
value={yAxisUnit}
source={YAxisSource.EXPLORER}
data-testid="y-axis-unit-selector"
/>
{/* TODO: Enable once we have resolved all related metrics v2 api issues */}
{/* {showSaveUnitButton && (
<div className="save-unit-container">
<Typography.Text>
Save the selected unit for this metric?
</Typography.Text>
<Button
type="primary"
size="small"
disabled={isUpdatingMetricMetadata}
onClick={handleSaveUnit}
>
<Typography.Paragraph>Yes</Typography.Paragraph>
</Button>
</div>
)} */}
</>
)}
</div>
<div
className={classNames({
'time-series-container': changeLayoutForOneChartPerQuery,
})}
>
{responseData.map((datapoint, index) => (
<div
className="time-series-view"
// eslint-disable-next-line react/no-array-index-key
key={index}
>
<TimeSeriesView
isFilterApplied={false}
isError={queries[index].isError}
isLoading={queries[index].isLoading}
data={datapoint}
yAxisUnit={yAxisUnit}
dataSource={DataSource.METRICS}
error={queries[index].error as APIError}
setWarning={setWarning}
/>
</div>
))}
{responseData.map((datapoint, index) => {
const isQueryDataItem = index < metricNames.length;
const metricName = isQueryDataItem ? metricNames[index] : undefined;
const metricUnit = isQueryDataItem ? metricUnits[index] : undefined;
// Show the no unit warning if -
// 1. The metric query is not loading
// 2. The metric units are not loading
// 3. There are more than one metric
// 4. The current metric unit is empty
// 5. Is a queryData item
const isMetricUnitEmpty =
isQueryDataItem &&
!queries[index].isLoading &&
!isMetricUnitsLoading &&
metricUnits.length > 1 &&
!metricUnit &&
metricName;
const currentYAxisUnit = yAxisUnit || metricUnit;
return (
<div
className="time-series-view"
// eslint-disable-next-line react/no-array-index-key
key={index}
>
{isMetricUnitEmpty && metricName && (
<Tooltip
className="no-unit-warning"
title={
<Typography.Text>
This metric does not have a unit. Please set one for it in the{' '}
<Typography.Link
onClick={(): void => handleOpenMetricDetails(metricName)}
>
metric details
</Typography.Link>{' '}
page.
</Typography.Text>
}
>
<AlertTriangle size={16} color={Color.BG_AMBER_400} />
</Tooltip>
)}
<TimeSeriesView
isFilterApplied={false}
isError={queries[index].isError}
isLoading={queries[index].isLoading || isMetricUnitsLoading}
data={datapoint}
yAxisUnit={currentYAxisUnit}
dataSource={DataSource.METRICS}
error={queries[index].error as APIError}
setWarning={setWarning}
/>
</div>
);
})}
</div>
</>
);

View File

@@ -1,4 +1,6 @@
import { render, screen } from '@testing-library/react';
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import * as useOptionsMenuHooks from 'container/OptionsMenu';
import * as useUpdateDashboardHooks from 'hooks/dashboard/useUpdateDashboard';
@@ -12,13 +14,18 @@ import { MemoryRouter } from 'react-router-dom';
import { useSearchParams } from 'react-router-dom-v5-compat';
import store from 'store';
import { LicenseEvent } from 'types/api/licensesV3/getActive';
import { DataSource } from 'types/common/queryBuilder';
import { MetricMetadata } from 'types/api/metricsExplorer/v2/getMetricMetadata';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder';
import Explorer from '../Explorer';
import * as useGetMetricsHooks from '../utils';
const mockSetSearchParams = jest.fn();
const queryClient = new QueryClient();
const mockUpdateAllQueriesOperators = jest.fn();
const mockUpdateAllQueriesOperators = jest
.fn()
.mockReturnValue(initialQueriesMap[DataSource.METRICS]);
const mockUseQueryBuilderData = {
handleRunQuery: jest.fn(),
stagedQuery: initialQueriesMap[DataSource.METRICS],
@@ -126,6 +133,30 @@ jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue({
...mockUseQueryBuilderData,
} as any);
const Y_AXIS_UNIT_SELECTOR_TEST_ID = 'y-axis-unit-selector';
const mockMetric: MetricMetadata = {
type: MetricType.SUM,
description: 'metric1 description',
unit: 'metric1 unit',
temporality: Temporality.CUMULATIVE,
isMonotonic: true,
};
function renderExplorer(): void {
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Provider store={store}>
<ErrorModalProvider>
<Explorer />
</ErrorModalProvider>
</Provider>
</MemoryRouter>
</QueryClientProvider>,
);
}
describe('Explorer', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -142,17 +173,7 @@ describe('Explorer', () => {
mockSetSearchParams,
]);
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Provider store={store}>
<ErrorModalProvider>
<Explorer />
</ErrorModalProvider>
</Provider>
</MemoryRouter>
</QueryClientProvider>,
);
renderExplorer();
expect(mockUpdateAllQueriesOperators).toHaveBeenCalledWith(
initialQueriesMap[DataSource.METRICS],
@@ -166,18 +187,13 @@ describe('Explorer', () => {
new URLSearchParams({ isOneChartPerQueryEnabled: 'true' }),
mockSetSearchParams,
]);
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false,
isError: false,
metrics: [mockMetric, mockMetric],
});
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Provider store={store}>
<ErrorModalProvider>
<Explorer />
</ErrorModalProvider>
</Provider>
</MemoryRouter>
</QueryClientProvider>,
);
renderExplorer();
const toggle = screen.getByRole('switch');
expect(toggle).toBeChecked();
@@ -188,20 +204,132 @@ describe('Explorer', () => {
new URLSearchParams({ isOneChartPerQueryEnabled: 'false' }),
mockSetSearchParams,
]);
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false,
isError: false,
metrics: [mockMetric, mockMetric],
});
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Provider store={store}>
<ErrorModalProvider>
<Explorer />
</ErrorModalProvider>
</Provider>
</MemoryRouter>
</QueryClientProvider>,
);
renderExplorer();
const toggle = screen.getByRole('switch');
expect(toggle).not.toBeChecked();
});
it('should not render y axis unit selector for single metric which has a unit', () => {
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false,
isError: false,
metrics: [mockMetric],
});
renderExplorer();
const yAxisUnitSelector = screen.queryByTestId(Y_AXIS_UNIT_SELECTOR_TEST_ID);
expect(yAxisUnitSelector).not.toBeInTheDocument();
});
it('should not render y axis unit selector for mutliple metrics with same unit', () => {
(useSearchParams as jest.Mock).mockReturnValueOnce([
new URLSearchParams({ isOneChartPerQueryEnabled: 'true' }),
mockSetSearchParams,
]);
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false,
isError: false,
metrics: [mockMetric, mockMetric],
});
renderExplorer();
const yAxisUnitSelector = screen.queryByTestId(Y_AXIS_UNIT_SELECTOR_TEST_ID);
expect(yAxisUnitSelector).not.toBeInTheDocument();
});
it('should hide y axis unit selector for multiple metrics with different units', () => {
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false,
isError: false,
metrics: [mockMetric, mockMetric],
});
renderExplorer();
const yAxisUnitSelector = screen.queryByTestId(Y_AXIS_UNIT_SELECTOR_TEST_ID);
expect(yAxisUnitSelector).not.toBeInTheDocument();
// One chart per query toggle should be disabled
const oneChartPerQueryToggle = screen.getByRole('switch');
expect(oneChartPerQueryToggle).toBeDisabled();
});
it('should render empty y axis unit selector for a single metric with no unit', () => {
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false,
isError: false,
metrics: [
{
type: MetricType.SUM,
description: 'metric1 description',
unit: '',
temporality: Temporality.CUMULATIVE,
isMonotonic: true,
},
],
});
renderExplorer();
const yAxisUnitSelector = screen.queryByTestId(Y_AXIS_UNIT_SELECTOR_TEST_ID);
expect(yAxisUnitSelector).toBeInTheDocument();
expect(yAxisUnitSelector).toHaveTextContent('Please select a unit');
});
it('one chart per query should be off and disabled when there is only one query', () => {
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false,
isError: false,
metrics: [mockMetric],
});
renderExplorer();
const oneChartPerQueryToggle = screen.getByRole('switch');
expect(oneChartPerQueryToggle).not.toBeChecked();
expect(oneChartPerQueryToggle).toBeDisabled();
});
it('one chart per query should enabled by default when there are multiple metrics with the same unit', () => {
const mockQueryData = {
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
aggregateAttribute: {
...(initialQueriesMap[DataSource.METRICS].builder.queryData[0]
.aggregateAttribute as BaseAutocompleteData),
key: 'metric1',
},
};
const mockStagedQueryWithMultipleQueries = {
...initialQueriesMap[DataSource.METRICS],
builder: {
...initialQueriesMap[DataSource.METRICS].builder,
queryData: [mockQueryData, mockQueryData],
},
};
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue(({
...mockUseQueryBuilderData,
stagedQuery: mockStagedQueryWithMultipleQueries,
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false,
isError: false,
metrics: [mockMetric, mockMetric],
});
renderExplorer();
const oneChartPerQueryToggle = screen.getByRole('switch');
expect(oneChartPerQueryToggle).toBeEnabled();
});
});

View File

@@ -0,0 +1,180 @@
import { render, RenderResult, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { UpdateMetricMetadataResponse } from 'api/metricsExplorer/updateMetricMetadata';
import * as useUpdateMetricMetadataHooks from 'hooks/metricsExplorer/useUpdateMetricMetadata';
import { UseUpdateMetricMetadataProps } from 'hooks/metricsExplorer/useUpdateMetricMetadata';
import { UseMutationResult } from 'react-query';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { MetricMetadata } from 'types/api/metricsExplorer/v2/getMetricMetadata';
import TimeSeries from '../TimeSeries';
import { TimeSeriesProps } from '../types';
type MockUpdateMetricMetadata = UseMutationResult<
SuccessResponse<UpdateMetricMetadataResponse> | ErrorResponse,
Error,
UseUpdateMetricMetadataProps
>;
const mockUpdateMetricMetadata = jest.fn();
jest
.spyOn(useUpdateMetricMetadataHooks, 'useUpdateMetricMetadata')
.mockReturnValue(({
mutate: mockUpdateMetricMetadata,
isLoading: false,
} as Partial<MockUpdateMetricMetadata>) as MockUpdateMetricMetadata);
jest.mock('container/TimeSeriesView/TimeSeriesView', () => ({
__esModule: true,
default: jest.fn().mockReturnValue(
<div role="img" aria-label="warning">
TimeSeriesView
</div>,
),
}));
jest.mock('react-query', () => ({
...jest.requireActual('react-query'),
useQueryClient: jest.fn().mockReturnValue({
invalidateQueries: jest.fn(),
}),
useQueries: jest.fn().mockImplementation((queries: any[]) =>
queries.map(() => ({
data: undefined,
isLoading: false,
isError: false,
error: undefined,
})),
),
}));
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: jest.fn().mockReturnValue({
globalTime: {
selectedTime: '5min',
maxTime: 1713738000000,
minTime: 1713734400000,
},
}),
}));
const mockMetric: MetricMetadata = {
type: MetricType.SUM,
description: 'metric1 description',
unit: 'metric1 unit',
temporality: Temporality.CUMULATIVE,
isMonotonic: true,
};
const mockSetWarning = jest.fn();
const mockSetIsMetricDetailsOpen = jest.fn();
const mockSetYAxisUnit = jest.fn();
function renderTimeSeries(
overrides: Partial<TimeSeriesProps> = {},
): RenderResult {
return render(
<TimeSeries
showOneChartPerQuery={false}
setWarning={mockSetWarning}
areAllMetricUnitsSame={false}
isMetricUnitsLoading={false}
metricUnits={[]}
metricNames={[]}
metrics={[]}
isMetricUnitsError={false}
handleOpenMetricDetails={mockSetIsMetricDetailsOpen}
yAxisUnit="count"
setYAxisUnit={mockSetYAxisUnit}
showYAxisUnitSelector={false}
// eslint-disable-next-line react/jsx-props-no-spreading
{...overrides}
/>,
);
}
describe('TimeSeries', () => {
it('should render a warning icon when a metric has no unit among multiple metrics', () => {
const user = userEvent.setup();
const { container } = renderTimeSeries({
metricUnits: ['', 'count'],
metricNames: ['metric1', 'metric2'],
metrics: [undefined, undefined],
});
const alertIcon = container.querySelector('.no-unit-warning') as HTMLElement;
user.hover(alertIcon);
waitFor(() =>
expect(
screen.findByText('This metric does not have a unit'),
).toBeInTheDocument(),
);
});
it('clicking on warning icon tooltip should open metric details modal', async () => {
const user = userEvent.setup();
const { container } = renderTimeSeries({
metricUnits: ['', 'count'],
metricNames: ['metric1', 'metric2'],
metrics: [mockMetric, mockMetric],
yAxisUnit: 'seconds',
});
const alertIcon = container.querySelector('.no-unit-warning') as HTMLElement;
user.hover(alertIcon);
const metricDetailsLink = await screen.findByText('metric details');
user.click(metricDetailsLink);
waitFor(() =>
expect(mockSetIsMetricDetailsOpen).toHaveBeenCalledWith('metric1'),
);
});
// TODO: Unskip this test once the save unit button is implemented
// Tracking at - https://github.com/SigNoz/engineering-pod/issues/3495
it.skip('shows Save unit button when metric had no unit but one is selected', () => {
const { findByText, getByRole } = renderTimeSeries({
metricUnits: [undefined],
metricNames: ['metric1'],
metrics: [mockMetric],
yAxisUnit: 'seconds',
});
expect(
findByText('Save the selected unit for this metric?'),
).toBeInTheDocument();
const yesButton = getByRole('button', { name: 'Yes' });
expect(yesButton).toBeInTheDocument();
expect(yesButton).toBeEnabled();
});
// TODO: Unskip this test once the save unit button is implemented
// Tracking at - https://github.com/SigNoz/engineering-pod/issues/3495
it.skip('clicking on save unit button shoould upated metric metadata', () => {
const user = userEvent.setup();
const { getByRole } = renderTimeSeries({
metricUnits: [''],
metricNames: ['metric1'],
metrics: [mockMetric],
yAxisUnit: 'seconds',
});
const yesButton = getByRole('button', { name: /Yes/i });
user.click(yesButton);
expect(mockUpdateMetricMetadata).toHaveBeenCalledWith(
{
metricName: 'metric1',
payload: expect.objectContaining({ unit: 'seconds' }),
},
expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}),
);
});
});

View File

@@ -0,0 +1,161 @@
import { renderHook } from '@testing-library/react';
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { initialQueriesMap } from 'constants/queryBuilder';
import * as useGetMultipleMetricsHook from 'hooks/metricsExplorer/useGetMultipleMetrics';
import { UseQueryResult } from 'react-query';
import { SuccessResponseV2 } from 'types/api';
import {
MetricMetadata,
MetricMetadataResponse,
} from 'types/api/metricsExplorer/v2/getMetricMetadata';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderFormula,
IBuilderQuery,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import {
getMetricUnits,
splitQueryIntoOneChartPerQuery,
useGetMetrics,
} from '../utils';
const MOCK_QUERY_DATA_1: IBuilderQuery = {
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
aggregateAttribute: {
...(initialQueriesMap[DataSource.METRICS].builder.queryData[0]
.aggregateAttribute as BaseAutocompleteData),
key: 'metric1',
},
};
const MOCK_QUERY_DATA_2: IBuilderQuery = {
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
aggregateAttribute: {
...(initialQueriesMap[DataSource.METRICS].builder.queryData[0]
.aggregateAttribute as BaseAutocompleteData),
key: 'metric2',
},
};
const MOCK_FORMULA_DATA: IBuilderFormula = {
expression: '1 + 1',
disabled: false,
queryName: 'Mock Formula',
legend: 'Mock Legend',
};
const MOCK_QUERY_WITH_MULTIPLE_QUERY_DATA: Query = {
...initialQueriesMap[DataSource.METRICS],
builder: {
...initialQueriesMap[DataSource.METRICS].builder,
queryData: [MOCK_QUERY_DATA_1, MOCK_QUERY_DATA_2],
queryFormulas: [MOCK_FORMULA_DATA, MOCK_FORMULA_DATA],
},
};
describe('splitQueryIntoOneChartPerQuery', () => {
it('should split a query with multiple queryData to multiple distinct queries, each with a single queryData', () => {
const result = splitQueryIntoOneChartPerQuery(
MOCK_QUERY_WITH_MULTIPLE_QUERY_DATA,
['metric1', 'metric2'],
[undefined, 'unit2'],
);
expect(result).toHaveLength(4);
// Verify query 1 has the correct data
expect(result[0].builder.queryData).toHaveLength(1);
expect(result[0].builder.queryData[0]).toEqual(MOCK_QUERY_DATA_1);
expect(result[0].builder.queryFormulas).toHaveLength(0);
expect(result[0].unit).toBeUndefined();
// Verify query 2 has the correct data
expect(result[1].builder.queryData).toHaveLength(1);
expect(result[1].builder.queryData[0]).toEqual(MOCK_QUERY_DATA_2);
expect(result[1].builder.queryFormulas).toHaveLength(0);
expect(result[1].unit).toBe('unit2');
// Verify query 3 has the correct data
expect(result[2].builder.queryFormulas).toHaveLength(1);
expect(result[2].builder.queryFormulas[0]).toEqual(MOCK_FORMULA_DATA);
expect(result[2].builder.queryData).toHaveLength(2); // 2 disabled queries
expect(result[2].builder.queryData[0].disabled).toBe(true);
expect(result[2].builder.queryData[1].disabled).toBe(true);
expect(result[2].unit).toBeUndefined();
// Verify query 4 has the correct data
expect(result[3].builder.queryFormulas).toHaveLength(1);
expect(result[3].builder.queryFormulas[0]).toEqual(MOCK_FORMULA_DATA);
expect(result[3].builder.queryData).toHaveLength(2); // 2 disabled queries
expect(result[3].builder.queryData[0].disabled).toBe(true);
expect(result[3].builder.queryData[1].disabled).toBe(true);
expect(result[3].unit).toBeUndefined();
});
});
const MOCK_METRIC_METADATA: MetricMetadata = {
description: 'Metric 1 description',
unit: 'unit1',
type: MetricType.GAUGE,
temporality: Temporality.DELTA,
isMonotonic: true,
};
describe('useGetMetrics', () => {
beforeEach(() => {
jest
.spyOn(useGetMultipleMetricsHook, 'useGetMultipleMetrics')
.mockReturnValue([
({
isLoading: false,
isError: false,
data: {
httpStatusCode: 200,
data: {
status: 'success',
data: MOCK_METRIC_METADATA,
},
},
} as Partial<
UseQueryResult<SuccessResponseV2<MetricMetadataResponse>, Error>
>) as UseQueryResult<SuccessResponseV2<MetricMetadataResponse>, Error>,
]);
});
it('should return the correct metrics data', () => {
const { result } = renderHook(() => useGetMetrics(['metric1']));
expect(result.current.metrics).toHaveLength(1);
expect(result.current.metrics[0]).toBeDefined();
expect(result.current.metrics[0]).toEqual(MOCK_METRIC_METADATA);
expect(result.current.isLoading).toBe(false);
expect(result.current.isError).toBe(false);
});
it('should return array of undefined values of correct length when metrics data is not yet loaded', () => {
jest
.spyOn(useGetMultipleMetricsHook, 'useGetMultipleMetrics')
.mockReturnValue([
({
isLoading: true,
isError: false,
} as Partial<
UseQueryResult<SuccessResponseV2<MetricMetadataResponse>, Error>
>) as UseQueryResult<SuccessResponseV2<MetricMetadataResponse>, Error>,
]);
const { result } = renderHook(() => useGetMetrics(['metric1']));
expect(result.current.metrics).toHaveLength(1);
expect(result.current.metrics[0]).toBeUndefined();
});
});
describe('getMetricUnits', () => {
it('should return the same unit for units that are not known to the universal unit mapper', () => {
const result = getMetricUnits([MOCK_METRIC_METADATA]);
expect(result).toHaveLength(1);
expect(result[0]).toEqual(MOCK_METRIC_METADATA.unit);
});
it('should return universal unit for units that are known to the universal unit mapper', () => {
const result = getMetricUnits([{ ...MOCK_METRIC_METADATA, unit: 'seconds' }]);
expect(result).toHaveLength(1);
expect(result[0]).toBe('s');
});
});

View File

@@ -3,6 +3,7 @@ import { Dispatch, SetStateAction } from 'react';
import { UseQueryResult } from 'react-query';
import { SuccessResponse, Warning } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { MetricMetadata } from 'types/api/metricsExplorer/v2/getMetricMetadata';
export enum ExplorerTabs {
TIME_SERIES = 'time-series',
@@ -12,6 +13,16 @@ export enum ExplorerTabs {
export interface TimeSeriesProps {
showOneChartPerQuery: boolean;
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
areAllMetricUnitsSame: boolean;
isMetricUnitsLoading: boolean;
isMetricUnitsError: boolean;
metricUnits: (string | undefined)[];
metricNames: string[];
metrics: (MetricMetadata | undefined)[];
handleOpenMetricDetails: (metricName: string) => void;
yAxisUnit: string | undefined;
setYAxisUnit: (unit: string) => void;
showYAxisUnitSelector: boolean;
}
export interface RelatedMetricsProps {

View File

@@ -1,20 +1,40 @@
import { mapMetricUnitToUniversalUnit } from 'components/YAxisUnitSelector/utils';
import { useGetMultipleMetrics } from 'hooks/metricsExplorer/useGetMultipleMetrics';
import { MetricMetadata } from 'types/api/metricsExplorer/v2/getMetricMetadata';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuid } from 'uuid';
export const splitQueryIntoOneChartPerQuery = (query: Query): Query[] => {
/**
* Split a query with multiple queryData to multiple distinct queries, each with a single queryData.
* @param query - The query to split
* @param units - The units of the metrics, can be undefined if the metric has no unit
* @returns The split queries
*/
export const splitQueryIntoOneChartPerQuery = (
query: Query,
metricNames: string[],
units: (string | undefined)[],
): Query[] => {
const queries: Query[] = [];
query.builder.queryData.forEach((currentQuery) => {
const newQuery = {
...query,
id: uuid(),
builder: {
...query.builder,
queryData: [currentQuery],
queryFormulas: [],
},
};
queries.push(newQuery);
if (currentQuery.aggregateAttribute?.key) {
const metricIndex = metricNames.indexOf(
currentQuery.aggregateAttribute?.key,
);
const unit = metricIndex >= 0 ? units[metricIndex] : undefined;
const newQuery = {
...query,
id: uuid(),
builder: {
...query.builder,
queryData: [currentQuery],
queryFormulas: [],
},
unit,
};
queries.push(newQuery);
}
});
query.builder.queryFormulas.forEach((currentFormula) => {
@@ -35,3 +55,43 @@ export const splitQueryIntoOneChartPerQuery = (query: Query): Query[] => {
return queries;
};
/**
* Hook to get data for multiple metrics with a synchronous loading and error state
* @param metricNames - The names of the metrics to get
* @param isEnabled - Whether the hook is enabled
* @returns The loading state, the metrics data, and the error state
*/
export function useGetMetrics(
metricNames: string[],
isEnabled = true,
): {
isLoading: boolean;
isError: boolean;
metrics: (MetricMetadata | undefined)[];
} {
const metricsData = useGetMultipleMetrics(metricNames, {
enabled: metricNames.length > 0 && isEnabled,
});
return {
isLoading: metricsData.some((metric) => metric.isLoading),
metrics: metricsData
.map((metric) => metric.data?.data)
.map((data) => data?.data),
isError: metricsData.some((metric) => metric.isError),
};
}
/**
* To get the units of the metrics in the universal unit standard.
* If the unit is not known to the universal unit mapper, it will return the unit as is.
* @param metrics - The metrics to get the units for
* @returns The units of the metrics, can be undefined if the metric has no unit
*/
export function getMetricUnits(
metrics: (MetricMetadata | undefined)[],
): (string | undefined)[] {
return metrics
.map((metric) => metric?.unit)
.map((unit) => mapMetricUnitToUniversalUnit(unit) || undefined);
}

View File

@@ -131,8 +131,8 @@ function MetricDetails({
>
Open in Explorer
</Button>
{/* Show the based on the feature flag. Will remove before releasing the feature */}
{showInspectFeature && (
{/* Show the inspect button if the metric type is GAUGE */}
{showInspectFeature && openInspectModal && (
<Button
className="inspect-metrics-button"
aria-label="Inspect Metric"

View File

@@ -11,7 +11,7 @@ export interface MetricDetailsProps {
isOpen: boolean;
metricName: string | null;
isModalTimeSelection: boolean;
openInspectModal: (metricName: string) => void;
openInspectModal?: (metricName: string) => void;
}
export interface DashboardsAndAlertsPopoverProps {

View File

@@ -0,0 +1,290 @@
/* eslint-disable react/jsx-props-no-spreading */
import { render as rtlRender, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { AppContext } from 'providers/App/App';
import { IAppContext } from 'providers/App/types';
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import React from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { LegendPosition, Widgets } from 'types/api/dashboard/getAll';
import { EQueryType } from 'types/common/dashboard';
import { ROLES } from 'types/roles';
import RightContainer, { RightContainerProps } from '../index';
import { timeItems, timePreferance, timePreferenceType } from '../timeItems';
const mockStore = configureStore([thunk]);
const createMockStore = (): ReturnType<typeof mockStore> =>
mockStore({
app: {
role: 'ADMIN',
user: {
userId: 'test-user-id',
email: 'test@signoz.io',
name: 'TestUser',
},
isLoggedIn: true,
org: [],
},
globalTime: {
minTime: '2023-01-01T00:00:00Z',
maxTime: '2023-01-02T00:00:00Z',
},
});
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: false,
},
},
});
const createMockAppContext = (): Partial<IAppContext> => ({
user: {
accessJwt: '',
refreshJwt: '',
id: '',
email: '',
displayName: '',
createdAt: 0,
organization: '',
orgId: '',
role: 'ADMIN' as ROLES,
},
});
const mockWidget: Widgets = {
id: 'test-widget-id',
title: 'Test Widget',
description: 'Test Description',
panelTypes: PANEL_TYPES.TIME_SERIES,
query: {
builder: {
queryData: [],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [],
clickhouse_sql: [],
id: 'query-id',
queryType: 'builder' as EQueryType,
},
timePreferance: 'GLOBAL_TIME' as timePreferenceType,
opacity: '',
nullZeroValues: '',
yAxisUnit: '',
fillSpans: false,
softMin: null,
softMax: null,
selectedLogFields: [],
selectedTracesFields: [],
};
const render = (ui: React.ReactElement): ReturnType<typeof rtlRender> =>
rtlRender(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Provider store={createMockStore()}>
<AppContext.Provider value={createMockAppContext() as IAppContext}>
<ErrorModalProvider>
<DashboardProvider>
<QueryBuilderProvider>{ui}</QueryBuilderProvider>
</DashboardProvider>
</ErrorModalProvider>
</AppContext.Provider>
</Provider>
</QueryClientProvider>
</MemoryRouter>,
);
// eslint-disable-next-line sonarjs/no-duplicate-string
jest.mock('hooks/queryBuilder/useCreateAlerts', () => ({
__esModule: true,
default: jest.fn(() => jest.fn()),
}));
jest.mock('lucide-react', () => ({
...jest.requireActual('lucide-react'),
ConciergeBell: (): JSX.Element => <svg data-testid="lucide-concierge-bell" />,
SquareArrowOutUpRight: (): JSX.Element => (
<svg data-testid="lucide-square-arrow-out-up-right" />
),
Plus: (): JSX.Element => <svg data-testid="lucide-plus" />,
}));
describe('RightContainer - Alerts Section', () => {
const defaultProps: RightContainerProps = {
title: 'Test Widget',
setTitle: jest.fn(),
description: 'Test Description',
setDescription: jest.fn(),
opacity: '1',
setOpacity: jest.fn(),
selectedNullZeroValue: '',
setSelectedNullZeroValue: jest.fn(),
selectedGraph: PANEL_TYPES.TIME_SERIES,
setSelectedTime: jest.fn(),
selectedTime: timeItems[0] as timePreferance,
yAxisUnit: '',
stackedBarChart: false,
setStackedBarChart: jest.fn(),
bucketWidth: 0,
bucketCount: 0,
combineHistogram: false,
setCombineHistogram: jest.fn(),
setBucketWidth: jest.fn(),
setBucketCount: jest.fn(),
setYAxisUnit: jest.fn(),
decimalPrecision: 2 as const,
setDecimalPrecision: jest.fn(),
setGraphHandler: jest.fn(),
thresholds: [],
setThresholds: jest.fn(),
selectedWidget: mockWidget,
isFillSpans: false,
setIsFillSpans: jest.fn(),
softMin: null,
softMax: null,
columnUnits: {},
setColumnUnits: jest.fn(),
setSoftMin: jest.fn(),
setSoftMax: jest.fn(),
isLogScale: false,
setIsLogScale: jest.fn(),
legendPosition: LegendPosition.BOTTOM,
setLegendPosition: jest.fn(),
customLegendColors: {},
setCustomLegendColors: jest.fn(),
queryResponse: undefined,
contextLinks: { linksData: [] },
setContextLinks: jest.fn(),
enableDrillDown: false,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders alerts section for TIME_SERIES panel type', () => {
render(<RightContainer {...defaultProps} />);
const alertsSection = screen.getByText('Alerts').closest('section');
expect(alertsSection).toBeInTheDocument();
expect(alertsSection).toHaveClass('alerts');
});
it('renders alerts section with correct text and SquareArrowOutUpRight icon', () => {
render(<RightContainer {...defaultProps} />);
expect(
screen.getByTestId('lucide-square-arrow-out-up-right'),
).toBeInTheDocument();
expect(screen.getByText('Alerts')).toBeInTheDocument();
});
it('calls onCreateAlertsHandler when alerts section is clicked', async () => {
const mockCreateAlertsHandler = jest.fn();
const useCreateAlerts = jest.requireMock('hooks/queryBuilder/useCreateAlerts')
.default;
useCreateAlerts.mockReturnValue(mockCreateAlertsHandler);
render(<RightContainer {...defaultProps} />);
const alertsSection = screen.getByText('Alerts').closest('section');
expect(alertsSection).toBeInTheDocument();
await userEvent.click(alertsSection as HTMLElement);
expect(mockCreateAlertsHandler).toHaveBeenCalledTimes(1);
});
it('passes correct parameters to useCreateAlerts hook', () => {
const useCreateAlerts = jest.requireMock('hooks/queryBuilder/useCreateAlerts')
.default;
render(<RightContainer {...defaultProps} />);
expect(useCreateAlerts).toHaveBeenCalledWith(mockWidget, 'panelView');
});
it('renders alerts section for VALUE panel type', () => {
render(
<RightContainer
{...defaultProps}
selectedGraph={PANEL_TYPES.VALUE}
selectedWidget={{ ...mockWidget, panelTypes: PANEL_TYPES.VALUE }}
/>,
);
expect(screen.getByText('Alerts')).toBeInTheDocument();
});
it('renders alerts section for BAR panel type', () => {
render(
<RightContainer
{...defaultProps}
selectedGraph={PANEL_TYPES.BAR}
selectedWidget={{ ...mockWidget, panelTypes: PANEL_TYPES.BAR }}
/>,
);
expect(screen.getByText('Alerts')).toBeInTheDocument();
});
it('does not render alerts section for TABLE panel type', () => {
render(
<RightContainer
{...defaultProps}
selectedGraph={PANEL_TYPES.TABLE}
selectedWidget={{ ...mockWidget, panelTypes: PANEL_TYPES.TABLE }}
/>,
);
expect(screen.queryByText('Alerts')).not.toBeInTheDocument();
});
it('does not render alerts section for LIST panel type', () => {
render(
<RightContainer
{...defaultProps}
selectedGraph={PANEL_TYPES.LIST}
selectedWidget={{ ...mockWidget, panelTypes: PANEL_TYPES.LIST }}
/>,
);
expect(screen.queryByText('Alerts')).not.toBeInTheDocument();
});
it('does not render alerts section for PIE panel type', () => {
render(
<RightContainer
{...defaultProps}
selectedGraph={PANEL_TYPES.PIE}
selectedWidget={{ ...mockWidget, panelTypes: PANEL_TYPES.PIE }}
/>,
);
expect(screen.queryByText('Alerts')).not.toBeInTheDocument();
});
it('does not render alerts section for HISTOGRAM panel type', () => {
render(
<RightContainer
{...defaultProps}
selectedGraph={PANEL_TYPES.HISTOGRAM}
selectedWidget={{ ...mockWidget, panelTypes: PANEL_TYPES.HISTOGRAM }}
/>,
);
expect(screen.queryByText('Alerts')).not.toBeInTheDocument();
});
});

View File

@@ -20,7 +20,13 @@ import GraphTypes, {
} from 'container/NewDashboard/ComponentsSlider/menuItems';
import useCreateAlerts from 'hooks/queryBuilder/useCreateAlerts';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { ConciergeBell, LineChart, Plus, Spline } from 'lucide-react';
import {
ConciergeBell,
LineChart,
Plus,
Spline,
SquareArrowOutUpRight,
} from 'lucide-react';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import {
Dispatch,
@@ -140,11 +146,7 @@ function RightContainer({
const selectedGraphType =
GraphTypes.find((e) => e.name === selectedGraph)?.display || '';
const onCreateAlertsHandler = useCreateAlerts(
selectedWidget,
'panelView',
thresholds,
);
const onCreateAlertsHandler = useCreateAlerts(selectedWidget, 'panelView');
const allowThreshold = panelTypeVsThreshold[selectedGraph];
const allowSoftMinMax = panelTypeVsSoftMinMax[selectedGraph];
@@ -530,6 +532,7 @@ function RightContainer({
<div className="left-section">
<ConciergeBell size={14} className="bell-icon" />
<Typography.Text className="alerts-text">Alerts</Typography.Text>
<SquareArrowOutUpRight size={10} className="info-icon" />
</div>
<Plus size={14} className="plus-icon" />
</section>
@@ -560,7 +563,7 @@ function RightContainer({
);
}
interface RightContainerProps {
export interface RightContainerProps {
title: string;
setTitle: Dispatch<SetStateAction<string>>;
description: string;

View File

@@ -370,10 +370,6 @@ function NewWidget({
// this has been moved here from the left container
const [requestData, setRequestData] = useState<GetQueryResultsProps>(() => {
const updatedQuery = cloneDeep(stagedQuery || initialQueriesMap.metrics);
if (updatedQuery?.builder?.queryData?.[0]) {
updatedQuery.builder.queryData[0].pageSize = 10;
}
if (selectedWidget) {
if (selectedGraph === PANEL_TYPES.LIST) {
return {
@@ -419,16 +415,12 @@ function NewWidget({
useEffect(() => {
if (stagedQuery) {
setIsLoadingPanelData(false);
const updatedStagedQuery = cloneDeep(stagedQuery);
if (updatedStagedQuery?.builder?.queryData?.[0]) {
updatedStagedQuery.builder.queryData[0].pageSize = 10;
}
setRequestData((prev) => ({
...prev,
selectedTime: selectedTime.enum || prev.selectedTime,
globalSelectedInterval: customGlobalSelectedInterval,
graphType: getGraphType(selectedGraph || selectedWidget.panelTypes),
query: updatedStagedQuery,
query: stagedQuery,
fillGaps: selectedWidget.fillSpans || false,
isLogScale: selectedWidget.isLogScale || false,
formatForWeb:

View File

@@ -14,6 +14,7 @@ import {
} from 'antd';
import logEvent from 'api/common/logEvent';
import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
import { DOCS_BASE_URL } from 'constants/app';
import ROUTES from 'constants/routes';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import history from 'lib/history';
@@ -143,7 +144,7 @@ function OnboardingAddDataSource(): JSX.Element {
] = useState<boolean>(false);
const [docsUrl, setDocsUrl] = useState<string>(
'https://signoz.io/docs/instrumentation/',
`${DOCS_BASE_URL}/docs/instrumentation/`,
);
const [selectedDataSource, setSelectedDataSource] = useState<Entity | null>(
@@ -188,7 +189,9 @@ function OnboardingAddDataSource(): JSX.Element {
}
// Step 1: Parse the URL
const urlObj = new URL(url);
const fullUrl = url.startsWith('/') ? `${DOCS_BASE_URL}${url}` : url;
const urlObj = new URL(fullUrl);
// Step 2: Update or add the 'source' parameter
urlObj.searchParams.set('source', 'onboarding');

View File

@@ -1,6 +1,7 @@
import { Skeleton, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import { AxiosError } from 'axios';
import { DOCS_BASE_URL } from 'constants/app';
import { useGetDeploymentsData } from 'hooks/CustomDomain/useGetDeploymentsData';
import { useGetAllIngestionsKeys } from 'hooks/IngestionKeys/useGetAllIngestionKeys';
import { useNotifications } from 'hooks/useNotifications';
@@ -93,7 +94,7 @@ export default function OnboardingIngestionDetails(): JSX.Element {
<span>
Find your ingestion URL and learn more about sending data to SigNoz{' '}
<a
href="https://signoz.io/docs/ingestion/signoz-cloud/overview/"
href={`${DOCS_BASE_URL}/docs/ingestion/signoz-cloud/overview/`}
target="_blank"
className="learn-more"
rel="noreferrer"
@@ -184,7 +185,7 @@ export default function OnboardingIngestionDetails(): JSX.Element {
<span>
We support{' '}
<a
href="https://signoz.io/docs/ingestion/signoz-cloud/keys/"
href={`${DOCS_BASE_URL}/docs/ingestion/signoz-cloud/keys/`}
target="_blank"
className="learn-more"
rel="noreferrer"

View File

@@ -1,22 +1,76 @@
import { SearchOutlined } from '@ant-design/icons';
import { Input, Spin, Typography } from 'antd';
import { Input, Spin } from 'antd';
import { BaseOptionType } from 'antd/es/select';
import FieldVariantBadges from 'components/FieldVariantBadges/FieldVariantBadges';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { FieldTitle } from '../styles';
import { OptionsMenuConfig } from '../types';
import {
buildAttributeKey,
getOptionLabelCounts,
parseAttributeKey,
} from '../utils';
import {
AddColumnItem,
AddColumnSelect,
AddColumnWrapper,
DeleteOutlinedIcon,
Name,
NameWrapper,
OptionContent,
SearchIconWrapper,
} from './styles';
function OptionRenderer({
option,
optionsLabelCounts,
}: {
option: BaseOptionType;
optionsLabelCounts: Record<string, number>;
}): JSX.Element {
const { label, data } = option;
const key = data?.value as string;
const { name, fieldContext, fieldDataType } = parseAttributeKey(key);
const hasMultipleVariants = (optionsLabelCounts[name] || 0) > 1;
return (
<OptionContent>
<span className="option-label">{label}</span>
{hasMultipleVariants && (
<FieldVariantBadges
fieldDataType={fieldDataType}
fieldContext={fieldContext}
/>
)}
</OptionContent>
);
}
function AddColumnField({ config }: AddColumnFieldProps): JSX.Element | null {
const { t } = useTranslation(['trace']);
const isDarkMode = useIsDarkMode();
const optionsLabelCounts = useMemo(() => {
if (!config) return {};
const optionsKeys: { key: string }[] =
config?.options?.map((option) => ({ key: String(option.value) })) || [];
const valuesKeys: { key: string }[] =
config?.value?.map((column) => ({ key: buildAttributeKey(column) })) || [];
return getOptionLabelCounts([...optionsKeys, ...valuesKeys]);
}, [config]);
const renderOption = useCallback(
function renderOption(option: BaseOptionType): JSX.Element {
return (
<OptionRenderer option={option} optionsLabelCounts={optionsLabelCounts} />
);
},
[optionsLabelCounts],
);
if (!config) return null;
return (
@@ -33,21 +87,32 @@ function AddColumnField({ config }: AddColumnFieldProps): JSX.Element | null {
value={[]}
onSelect={config.onSelect}
onSearch={config.onSearch}
onFocus={config.onFocus}
onBlur={config.onBlur}
notFoundContent={config.isFetching ? <Spin size="small" /> : null}
optionRender={renderOption}
/>
<SearchIconWrapper $isDarkMode={isDarkMode}>
<SearchOutlined />
</SearchIconWrapper>
</Input.Group>
{config.value?.map(({ name }) => (
<AddColumnItem direction="horizontal" key={name}>
<Typography>{name}</Typography>
<DeleteOutlinedIcon onClick={(): void => config.onRemove(name)} />
</AddColumnItem>
))}
{config.value?.map((column) => {
const uniqueKey = buildAttributeKey(column);
const showBadge = (optionsLabelCounts[column.name] || 0) > 1;
return (
<AddColumnItem key={uniqueKey}>
<NameWrapper>
<Name>{column.name}</Name>
{showBadge && (
<FieldVariantBadges
fieldDataType={column.fieldDataType}
fieldContext={column.fieldContext}
/>
)}
</NameWrapper>
<DeleteOutlinedIcon onClick={(): void => config.onRemove(uniqueKey)} />
</AddColumnItem>
);
})}
</AddColumnWrapper>
);
}

View File

@@ -28,8 +28,9 @@ export const AddColumnWrapper = styled(Space)`
width: 100%;
`;
export const AddColumnItem = styled(Space)`
export const AddColumnItem = styled.div`
width: 100%;
min-width: 300px;
display: flex;
justify-content: space-between;
`;
@@ -37,3 +38,35 @@ export const AddColumnItem = styled(Space)`
export const DeleteOutlinedIcon = styled(DeleteOutlined)`
color: red;
`;
export const OptionContent = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: 8px;
min-width: 0;
.option-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`;
export const NameWrapper = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
width: calc(100% - 26px);
gap: 8px;
min-width: 0;
`;
export const Name = styled.span`
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;

View File

@@ -37,7 +37,7 @@ describe('useOptionsMenu', () => {
columns: [],
formatting: {
format: 'raw',
maxLines: 2,
maxLines: 1,
fontSize: 'small',
},
},
@@ -49,7 +49,7 @@ describe('useOptionsMenu', () => {
columns: [],
formatting: {
format: 'raw',
maxLines: 2,
maxLines: 1,
fontSize: 'small',
},
},

View File

@@ -7,7 +7,7 @@ export const URL_OPTIONS = 'options';
export const defaultOptionsQuery: OptionsQuery = {
selectColumns: [],
maxLines: 2,
maxLines: 1,
format: 'raw',
fontSize: FontSize.SMALL,
};
@@ -52,7 +52,7 @@ export const defaultTraceSelectedColumns: TelemetryFieldKey[] = [
name: 'duration_nano',
signal: 'traces',
fieldContext: 'span',
fieldDataType: '',
fieldDataType: 'number',
},
{
name: 'http_method',

View File

@@ -41,7 +41,18 @@ function OptionsMenu({
return (
<OptionsContainer>
<Popover placement="bottom" trigger="click" content={OptionsContent}>
<Popover
placement="bottom"
trigger="click"
content={OptionsContent}
onOpenChange={(open: boolean): void => {
if (!open) {
config?.addColumn?.onBlur?.();
} else {
config?.addColumn?.onFocus?.();
}
}}
>
<Space align="center">
{t('options_menu.options')}
<SettingIcon />

View File

@@ -31,12 +31,11 @@ export type OptionsMenuConfig = {
};
maxLines?: Pick<InputNumberProps, 'value' | 'onChange'>;
fontSize?: FontSizeProps;
addColumn?: Pick<
SelectProps,
'options' | 'onSelect' | 'onFocus' | 'onSearch' | 'onBlur'
> & {
addColumn?: Pick<SelectProps, 'options' | 'onSelect' | 'onSearch'> & {
isFetching: boolean;
value: TelemetryFieldKey[];
onRemove: (key: string) => void;
onFocus?: () => void;
onBlur?: () => void;
};
};

View File

@@ -36,7 +36,7 @@ import {
OptionsMenuConfig,
OptionsQuery,
} from './types';
import { getOptionsFromKeys } from './utils';
import { buildAttributeKey, getOptionsFromKeys } from './utils';
interface UseOptionsMenuProps {
storageKey?: string;
@@ -262,7 +262,7 @@ const useOptionsMenu = ({
}, [dataSource, initialOptions, initialSelectedColumns]);
const selectedColumnKeys = useMemo(
() => preferences?.columns?.map(({ name }) => name) || [],
() => preferences?.columns?.map(buildAttributeKey) || [],
[preferences?.columns],
);
@@ -292,7 +292,7 @@ const useOptionsMenu = ({
const column = [
...searchedAttributeKeys,
...(preferences?.columns || []),
].find(({ name }) => name === key);
].find((k) => buildAttributeKey(k) === key);
if (!column) return acc;
return [...acc, column];
@@ -321,7 +321,7 @@ const useOptionsMenu = ({
const handleRemoveSelectedColumn = useCallback(
(columnKey: string) => {
const newSelectedColumns = preferences?.columns?.filter(
({ name }) => name !== columnKey,
(k) => buildAttributeKey(k) !== columnKey,
);
if (!newSelectedColumns?.length && dataSource !== DataSource.LOGS) {

View File

@@ -1,16 +1,42 @@
import { SelectProps } from 'antd';
import { TelemetryFieldKey } from 'api/v5/v5';
export const buildAttributeKey = (k: TelemetryFieldKey): string =>
`${k?.name || ''}::${k?.fieldContext || ''}::${k?.fieldDataType || ''}`;
export const getOptionsFromKeys = (
keys: TelemetryFieldKey[],
selectedKeys: (string | undefined)[],
selectedKeys: string[],
): SelectProps['options'] => {
const options = keys.map(({ name }) => ({
label: name,
value: name,
const options = keys.map((k) => ({
label: k.name,
value: buildAttributeKey(k),
}));
return options.filter(
({ value }) => !selectedKeys.find((key) => key === value),
);
};
export const parseAttributeKey = (
key: string,
): {
name: string;
fieldContext: string;
fieldDataType: string;
} => {
const [name = '', fieldContext = '', fieldDataType = ''] = key.split('::');
return { name, fieldContext, fieldDataType };
};
export const getOptionLabelCounts = (
options: Array<{ key: string }>,
): Record<string, number> => {
const labelCounts: Record<string, number> = {};
(options || []).forEach(({ key }) => {
const { name } = parseAttributeKey(key || '');
if (!name) return;
labelCounts[name] = (labelCounts[name] || 0) + 1;
});
return labelCounts;
};

View File

@@ -132,11 +132,20 @@ function UplotPanelWrapper({
[selectedGraph, widget?.panelTypes, widget?.stackedBarChart],
);
const chartData = getUPlotChartData(
queryResponse?.data?.payload,
widget.fillSpans,
stackedBarChart,
hiddenGraph,
const chartData = useMemo(
() =>
getUPlotChartData(
queryResponse?.data?.payload,
widget.fillSpans,
stackedBarChart,
hiddenGraph,
),
[
queryResponse?.data?.payload,
widget.fillSpans,
stackedBarChart,
hiddenGraph,
],
);
useEffect(() => {
@@ -293,7 +302,7 @@ function UplotPanelWrapper({
)}
{isFullViewMode && setGraphVisibility && !stackedBarChart && (
<GraphManager
data={getUPlotChartData(queryResponse?.data?.payload, widget.fillSpans)}
data={chartData}
name={widget.id}
options={options}
yAxisUnit={widget.yAxisUnit}

View File

@@ -62,7 +62,7 @@ jest.mock('providers/preferences/sync/usePreferenceSync', () => ({
preferences: {
columns: [],
formatting: {
maxLines: 2,
maxLines: 1,
format: 'table',
fontSize: 'small',
version: 1,

View File

@@ -206,6 +206,10 @@
.ant-select-selector {
border-color: var(--bg-vanilla-300);
background: var(--bg-vanilla-300);
.ant-select-selection-item {
color: var(--text-ink-400);
}
}
.ant-input-number {

View File

@@ -242,6 +242,7 @@ export function Formula({
</div>
<InputWithLabel
label="Limit"
type="number"
onChange={(value): void => handleChangeLimit(Number(value))}
initialValue={formula?.limit ?? undefined}
placeholder="Enter limit"

View File

@@ -1,7 +1,5 @@
import { Select } from 'antd';
import { ATTRIBUTE_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
import { useEffect, useState } from 'react';
import { MetricAggregateOperator } from 'types/common/queryBuilder';
interface SpaceAggregationOptionsProps {
panelType: PANEL_TYPES | null;
@@ -22,39 +20,13 @@ export default function SpaceAggregationOptions({
operators,
qbVersion,
}: SpaceAggregationOptionsProps): JSX.Element {
const placeHolderText =
panelType === PANEL_TYPES.VALUE || qbVersion === 'v3' ? 'Sum' : 'Sum By';
const [defaultValue, setDefaultValue] = useState(
selectedValue || placeHolderText,
);
useEffect(() => {
if (!selectedValue) {
if (
aggregatorAttributeType === ATTRIBUTE_TYPES.HISTOGRAM ||
aggregatorAttributeType === ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM
) {
setDefaultValue(MetricAggregateOperator.P90);
onSelect(MetricAggregateOperator.P90);
} else if (aggregatorAttributeType === ATTRIBUTE_TYPES.SUM) {
setDefaultValue(MetricAggregateOperator.SUM);
onSelect(MetricAggregateOperator.SUM);
} else if (aggregatorAttributeType === ATTRIBUTE_TYPES.GAUGE) {
setDefaultValue(MetricAggregateOperator.AVG);
onSelect(MetricAggregateOperator.AVG);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [aggregatorAttributeType]);
return (
<div
className="spaceAggregationOptionsContainer"
key={aggregatorAttributeType}
>
<Select
defaultValue={defaultValue}
defaultValue={selectedValue}
style={{ minWidth: '5.625rem' }}
disabled={disabled}
onChange={onSelect}

View File

@@ -0,0 +1,16 @@
.selectOptionContainer {
display: flex;
gap: 8px;
justify-content: space-between;
align-items: center;
overflow-x: auto;
&::-webkit-scrollbar {
width: 0.2rem;
height: 0.2rem;
}
}
.option-renderer-tooltip {
pointer-events: none;
}

View File

@@ -1,4 +1,4 @@
import './QueryBuilderSearch.styles.scss';
import './OptionRenderer.styles.scss';
import { Tooltip } from 'antd';
@@ -13,7 +13,11 @@ function OptionRenderer({
return (
<span className="option">
{type ? (
<Tooltip title={`${value}`} placement="topLeft">
<Tooltip
title={`${value}`}
placement="topLeft"
rootClassName="option-renderer-tooltip"
>
<div className="selectOptionContainer">
<div className="option-value">{value}</div>
<div className="option-meta-data-container">
@@ -29,7 +33,11 @@ function OptionRenderer({
</div>
</Tooltip>
) : (
<Tooltip title={label} placement="topLeft">
<Tooltip
title={label}
placement="topLeft"
rootClassName="option-renderer-tooltip"
>
<span>{label}</span>
</Tooltip>
)}

View File

@@ -5,19 +5,6 @@
gap: 12px;
}
.selectOptionContainer {
display: flex;
gap: 8px;
justify-content: space-between;
align-items: center;
overflow-x: auto;
&::-webkit-scrollbar {
width: 0.2rem;
height: 0.2rem;
}
}
.logs-popup {
&.hide-scroll {
.rc-virtual-list-holder {

View File

@@ -0,0 +1,88 @@
import { render, screen } from '@testing-library/react';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { ReduceToFilter } from './ReduceToFilter';
const mockOnChange = jest.fn();
function baseQuery(overrides: Partial<IBuilderQuery> = {}): IBuilderQuery {
return {
dataSource: 'traces',
aggregations: [],
groupBy: [],
orderBy: [],
legend: '',
limit: null,
having: { expression: '' },
...overrides,
} as IBuilderQuery;
}
describe('ReduceToFilter', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('initializes with default avg when no reduceTo is set', () => {
render(<ReduceToFilter query={baseQuery()} onChange={mockOnChange} />);
expect(screen.getByTestId('reduce-to')).toBeInTheDocument();
expect(
screen.getByText('Average of values in timeframe'),
).toBeInTheDocument();
});
it('initializes from query.aggregations[0].reduceTo', () => {
render(
<ReduceToFilter
query={baseQuery({
aggregations: [{ reduceTo: 'sum' } as any],
aggregateAttribute: { key: 'test', type: MetricType.SUM },
})}
onChange={mockOnChange}
/>,
);
expect(screen.getByText('Sum of values in timeframe')).toBeInTheDocument();
});
it('initializes from query.reduceTo when aggregations[0].reduceTo is not set', () => {
render(
<ReduceToFilter
query={baseQuery({
reduceTo: 'max',
aggregateAttribute: { key: 'test', type: MetricType.GAUGE },
})}
onChange={mockOnChange}
/>,
);
expect(screen.getByText('Max of values in timeframe')).toBeInTheDocument();
});
it('updates to sum when aggregateAttribute.type is SUM', async () => {
const { rerender } = render(
<ReduceToFilter
query={baseQuery({
aggregateAttribute: { key: 'test', type: MetricType.GAUGE },
})}
onChange={mockOnChange}
/>,
);
rerender(
<ReduceToFilter
query={baseQuery({
aggregateAttribute: { key: 'test2', type: MetricType.SUM },
})}
onChange={mockOnChange}
/>,
);
const reduceToFilterText = (await screen.findByText(
'Sum of values in timeframe',
)) as HTMLElement;
expect(reduceToFilterText).toBeInTheDocument();
});
});

View File

@@ -1,6 +1,7 @@
import { Select } from 'antd';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { REDUCE_TO_VALUES } from 'constants/queryBuilder';
import { memo } from 'react';
import { memo, useEffect, useRef, useState } from 'react';
import { MetricAggregation } from 'types/api/v5/queryRange';
// ** Types
import { ReduceOperators } from 'types/common/queryBuilder';
@@ -12,19 +13,46 @@ export const ReduceToFilter = memo(function ReduceToFilter({
query,
onChange,
}: ReduceToFilterProps): JSX.Element {
const reduceToValue =
(query.aggregations?.[0] as MetricAggregation)?.reduceTo || query.reduceTo;
const currentValue =
REDUCE_TO_VALUES.find((option) => option.value === reduceToValue) ||
REDUCE_TO_VALUES[0];
const isMounted = useRef<boolean>(false);
const [currentValue, setCurrentValue] = useState<
SelectOption<ReduceOperators, string>
>(REDUCE_TO_VALUES[2]); // default to avg
const handleChange = (
newValue: SelectOption<ReduceOperators, string>,
): void => {
setCurrentValue(newValue);
onChange(newValue.value);
};
useEffect(
() => {
if (!isMounted.current) {
const reduceToValue =
(query.aggregations?.[0] as MetricAggregation)?.reduceTo || query.reduceTo;
setCurrentValue(
REDUCE_TO_VALUES.find((option) => option.value === reduceToValue) ||
REDUCE_TO_VALUES[2],
);
isMounted.current = true;
return;
}
const aggregationAttributeType = query.aggregateAttribute?.type as
| MetricType
| undefined;
if (aggregationAttributeType === MetricType.SUM) {
handleChange(REDUCE_TO_VALUES[1]);
} else {
handleChange(REDUCE_TO_VALUES[2]);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[query.aggregateAttribute?.key],
);
return (
<Select
placeholder="Reduce to"

View File

@@ -8,6 +8,7 @@ import {
getViewQuery,
isValidQueryName,
} from '../drilldownUtils';
import { METRIC_TO_LOGS_TRACES_MAPPINGS } from '../metricsCorrelationUtils';
// Mock the transformMetricsToLogsTraces function since it's not exported
// We'll test it indirectly through getViewQuery
@@ -117,6 +118,7 @@ describe('drilldownUtils', () => {
{
queryName: 'metrics_query',
dataSource: 'metrics' as any,
aggregations: [{ metricName: 'signoz_test_metric' }] as any,
groupBy: [],
expression: '',
disabled: false,
@@ -144,6 +146,16 @@ describe('drilldownUtils', () => {
];
it('should transform metrics query when drilling down to logs', () => {
const mappingsByAttr = Object.fromEntries(
METRIC_TO_LOGS_TRACES_MAPPINGS.map((m) => [m.attribute, m]),
) as Record<
string,
{ newAttribute: string; valueMappings: Record<string, string> }
>;
const spanKindMapping = mappingsByAttr['span.kind'];
const spanKindKey = spanKindMapping.newAttribute;
const spanKindServer = spanKindMapping.valueMappings.SPAN_KIND_SERVER;
const result = getViewQuery(
mockMetricsQuery,
mockFilters,
@@ -161,20 +173,30 @@ describe('drilldownUtils', () => {
// Verify transformations were applied
if (filterExpression) {
// Rule 2: operation → name
expect(filterExpression).toContain('name = "GET"');
expect(filterExpression).not.toContain('operation = "GET"');
expect(filterExpression).toContain(`name = 'GET'`);
expect(filterExpression).not.toContain(`operation = 'GET'`);
// Rule 3: span.kind → kind
expect(filterExpression).toContain('kind = 2');
expect(filterExpression).not.toContain('span.kind = SPAN_KIND_SERVER');
expect(filterExpression).toContain(`${spanKindKey} = '${spanKindServer}'`);
expect(filterExpression).not.toContain(`span.kind = SPAN_KIND_SERVER`);
// Rule 4: status.code → status_code_string with value mapping
expect(filterExpression).toContain('status_code_string = Ok');
expect(filterExpression).not.toContain('status.code = STATUS_CODE_OK');
expect(filterExpression).toContain(`status_code_string = 'Ok'`);
expect(filterExpression).not.toContain(`status.code = STATUS_CODE_OK`);
}
});
it('should transform metrics query when drilling down to traces', () => {
const mappingsByAttr = Object.fromEntries(
METRIC_TO_LOGS_TRACES_MAPPINGS.map((m) => [m.attribute, m]),
) as Record<
string,
{ newAttribute: string; valueMappings: Record<string, string> }
>;
const spanKindMapping = mappingsByAttr['span.kind'];
const spanKindKey = spanKindMapping.newAttribute;
const spanKindServer = spanKindMapping.valueMappings.SPAN_KIND_SERVER;
const result = getViewQuery(
mockMetricsQuery,
mockFilters,
@@ -192,44 +214,30 @@ describe('drilldownUtils', () => {
// Verify transformations were applied
if (filterExpression) {
// Rule 2: operation → name
expect(filterExpression).toContain('name = "GET"');
expect(filterExpression).not.toContain('operation = "GET"');
expect(filterExpression).toContain(`name = 'GET'`);
expect(filterExpression).not.toContain(`operation = 'GET'`);
// Rule 3: span.kind → kind
expect(filterExpression).toContain('kind = 2');
expect(filterExpression).not.toContain('span.kind = SPAN_KIND_SERVER');
expect(filterExpression).toContain(`${spanKindKey} = '${spanKindServer}'`);
expect(filterExpression).not.toContain(`span.kind = SPAN_KIND_SERVER`);
// Rule 4: status.code → status_code_string with value mapping
expect(filterExpression).toContain('status_code_string = Ok');
expect(filterExpression).not.toContain('status.code = STATUS_CODE_OK');
}
});
it('should NOT transform metrics query when drilling down to metrics', () => {
const result = getViewQuery(
mockMetricsQuery,
mockFilters,
'view_metrics',
'metrics_query',
);
expect(result).not.toBeNull();
expect(result?.builder.queryData).toHaveLength(1);
// Check that the filter expression was NOT transformed
const filterExpression = result?.builder.queryData[0]?.filter?.expression;
expect(filterExpression).toBeDefined();
// Verify NO transformations were applied
if (filterExpression) {
// Should still contain original metric format
expect(filterExpression).toContain('operation = "GET"');
expect(filterExpression).toContain('span.kind = SPAN_KIND_SERVER');
expect(filterExpression).toContain('status.code = STATUS_CODE_OK');
expect(filterExpression).toContain(`status_code_string = 'Ok'`);
expect(filterExpression).not.toContain(`status.code = STATUS_CODE_OK`);
}
});
it('should handle complex filter expressions with multiple transformations', () => {
const mappingsByAttr = Object.fromEntries(
METRIC_TO_LOGS_TRACES_MAPPINGS.map((m) => [m.attribute, m]),
) as Record<
string,
{ newAttribute: string; valueMappings: Record<string, string> }
>;
const spanKindMapping = mappingsByAttr['span.kind'];
const spanKindKey = spanKindMapping.newAttribute;
const spanKindClient = spanKindMapping.valueMappings.SPAN_KIND_CLIENT;
const complexQuery: Query = {
...mockMetricsQuery,
builder: {
@@ -258,10 +266,10 @@ describe('drilldownUtils', () => {
if (filterExpression) {
// All transformations should be applied
expect(filterExpression).toContain('name = "POST"');
expect(filterExpression).toContain('kind = 3');
expect(filterExpression).toContain('status_code_string = Error');
expect(filterExpression).toContain('http.status_code = 500');
expect(filterExpression).toContain(`name = 'POST'`);
expect(filterExpression).toContain(`${spanKindKey} = '${spanKindClient}'`);
expect(filterExpression).toContain(`status_code_string = 'Error'`);
expect(filterExpression).toContain(`http.status_code = 500`);
}
});
@@ -299,13 +307,12 @@ describe('drilldownUtils', () => {
});
it('should handle all status code value mappings correctly', () => {
const statusCodeTests = [
{ input: 'STATUS_CODE_UNSET', expected: 'Unset' },
{ input: 'STATUS_CODE_OK', expected: 'Ok' },
{ input: 'STATUS_CODE_ERROR', expected: 'Error' },
];
const mappingsByAttr = Object.fromEntries(
METRIC_TO_LOGS_TRACES_MAPPINGS.map((m) => [m.attribute, m]),
) as Record<string, { valueMappings: Record<string, string> }>;
const statusMap = mappingsByAttr['status.code'].valueMappings;
statusCodeTests.forEach(({ input, expected }) => {
Object.entries(statusMap).forEach(([input, expected]) => {
const testQuery: Query = {
...mockMetricsQuery,
builder: {
@@ -329,19 +336,18 @@ describe('drilldownUtils', () => {
);
const filterExpression = result?.builder.queryData[0]?.filter?.expression;
expect(filterExpression).toContain(`status_code_string = ${expected}`);
expect(filterExpression).toContain(`status_code_string = '${expected}'`);
expect(filterExpression).not.toContain(`status.code = ${input}`);
});
});
it('should handle quoted status code values (browser scenario)', () => {
const statusCodeTests = [
{ input: '"STATUS_CODE_UNSET"', expected: '"Unset"' },
{ input: '"STATUS_CODE_OK"', expected: '"Ok"' },
{ input: '"STATUS_CODE_ERROR"', expected: '"Error"' },
];
const mappingsByAttr = Object.fromEntries(
METRIC_TO_LOGS_TRACES_MAPPINGS.map((m) => [m.attribute, m]),
) as Record<string, { valueMappings: Record<string, string> }>;
const statusMap = mappingsByAttr['status.code'].valueMappings;
statusCodeTests.forEach(({ input, expected }) => {
Object.entries(statusMap).forEach(([input, expected]) => {
const testQuery: Query = {
...mockMetricsQuery,
builder: {
@@ -350,7 +356,7 @@ describe('drilldownUtils', () => {
{
...mockMetricsQuery.builder.queryData[0],
filter: {
expression: `status.code = ${input}`,
expression: `status.code = "${input}"`,
},
},
],
@@ -366,12 +372,22 @@ describe('drilldownUtils', () => {
const filterExpression = result?.builder.queryData[0]?.filter?.expression;
// Should preserve the quoting from the original expression
expect(filterExpression).toContain(`status_code_string = ${expected}`);
expect(filterExpression).not.toContain(`status.code = ${input}`);
expect(filterExpression).toContain(`status_code_string = '${expected}'`);
expect(filterExpression).not.toContain(`status.code = "${input}"`);
});
});
it('should preserve non-metric attributes during transformation', () => {
const mappingsByAttr = Object.fromEntries(
METRIC_TO_LOGS_TRACES_MAPPINGS.map((m) => [m.attribute, m]),
) as Record<
string,
{ newAttribute: string; valueMappings: Record<string, string> }
>;
const spanKindMapping = mappingsByAttr['span.kind'];
const spanKindKey = spanKindMapping.newAttribute;
const spanKindServer = spanKindMapping.valueMappings.SPAN_KIND_SERVER;
const mixedQuery: Query = {
...mockMetricsQuery,
builder: {
@@ -398,8 +414,8 @@ describe('drilldownUtils', () => {
if (filterExpression) {
// Transformed attributes
expect(filterExpression).toContain('name = "GET"');
expect(filterExpression).toContain('kind = 2');
expect(filterExpression).toContain(`name = 'GET'`);
expect(filterExpression).toContain(`${spanKindKey} = '${spanKindServer}'`);
// Preserved non-metric attributes
expect(filterExpression).toContain('service = "test-service"');
@@ -408,15 +424,17 @@ describe('drilldownUtils', () => {
});
it('should handle all span.kind value mappings correctly', () => {
const spanKindTests = [
{ input: 'SPAN_KIND_INTERNAL', expected: '1' },
{ input: 'SPAN_KIND_CONSUMER', expected: '5' },
{ input: 'SPAN_KIND_CLIENT', expected: '3' },
{ input: 'SPAN_KIND_PRODUCER', expected: '4' },
{ input: 'SPAN_KIND_SERVER', expected: '2' },
];
const mappingsByAttr = Object.fromEntries(
METRIC_TO_LOGS_TRACES_MAPPINGS.map((m) => [m.attribute, m]),
) as Record<
string,
{ newAttribute: string; valueMappings: Record<string, string> }
>;
const spanKindMapping = mappingsByAttr['span.kind'];
const spanKindKey = spanKindMapping.newAttribute;
const spanKindMap = spanKindMapping.valueMappings;
spanKindTests.forEach(({ input, expected }) => {
Object.entries(spanKindMap).forEach(([input, expected]) => {
const testQuery: Query = {
...mockMetricsQuery,
builder: {
@@ -440,9 +458,48 @@ describe('drilldownUtils', () => {
);
const filterExpression = result?.builder.queryData[0]?.filter?.expression;
expect(filterExpression).toContain(`kind = ${expected}`);
expect(filterExpression).toContain(`${spanKindKey} = '${expected}'`);
expect(filterExpression).not.toContain(`span.kind = ${input}`);
});
});
it('should not transform when the source query is not metrics (logs/traces sources)', () => {
(['logs', 'traces'] as const).forEach((source) => {
const nonMetricsQuery: Query = {
...mockMetricsQuery,
builder: {
...mockMetricsQuery.builder,
queryData: [
{
...mockMetricsQuery.builder.queryData[0],
dataSource: source as any,
filter: {
expression:
'operation = "GET" AND span.kind = SPAN_KIND_SERVER AND status.code = STATUS_CODE_OK',
},
},
],
},
};
const result = getViewQuery(
nonMetricsQuery,
mockFilters,
source === 'logs' ? 'view_logs' : 'view_traces',
'metrics_query',
);
const expr = result?.builder.queryData[0]?.filter?.expression || '';
// Should remain unchanged (no metric-to-logs/traces transformations)
expect(expr).toContain('operation = "GET"');
expect(expr).toContain('span.kind = SPAN_KIND_SERVER');
expect(expr).toContain('status.code = STATUS_CODE_OK');
// And should not contain transformed counterparts
expect(expr).not.toContain(`name = 'GET'`);
expect(expr).not.toContain(`kind = '2'`);
expect(expr).not.toContain(`status_code_string = 'Ok'`);
});
});
});
});

View File

@@ -5,6 +5,11 @@ import {
OPERATORS,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { isApmMetric } from 'container/PanelWrapper/utils';
import {
METRIC_TO_LOGS_TRACES_MAPPINGS,
replaceKeysAndValuesInExpression,
} from 'container/QueryTable/Drilldown/metricsCorrelationUtils';
import cloneDeep from 'lodash-es/cloneDeep';
import {
BaseAutocompleteData,
@@ -15,6 +20,7 @@ import {
Query,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { v4 as uuid } from 'uuid';
export function getBaseMeta(
@@ -270,125 +276,6 @@ const VIEW_QUERY_MAP: Record<string, IBuilderQuery> = {
view_traces: initialQueryBuilderFormValuesMap.traces,
};
/**
* TEMP LOGIC - TO BE REMOVED LATER
* Transforms metric query filters to logs/traces format
* Applies the following transformations:
* - Rule 2: operation → name
* - Rule 3: span.kind → kind
* - Rule 4: status.code → status_code_string with value mapping
* - Rule 5: http.status_code type conversion
*/
const transformMetricsToLogsTraces = (
filterExpression: string | undefined,
): string | undefined => {
if (!filterExpression) return filterExpression;
// ===========================================
// MAPPING OBJECTS - ALL TRANSFORMATIONS DEFINED HERE
// ===========================================
const METRIC_TO_LOGS_TRACES_MAPPINGS = {
// Rule 2: operation → name
attributeRenames: {
operation: 'name',
},
// Rule 3: span.kind → kind with value mapping
spanKindMapping: {
attribute: 'span.kind',
newAttribute: 'kind',
valueMappings: {
SPAN_KIND_INTERNAL: '1',
SPAN_KIND_SERVER: '2',
SPAN_KIND_CLIENT: '3',
SPAN_KIND_PRODUCER: '4',
SPAN_KIND_CONSUMER: '5',
},
},
// Rule 4: status.code → status_code_string with value mapping
statusCodeMapping: {
attribute: 'status.code',
newAttribute: 'status_code_string',
valueMappings: {
// From metrics format → To logs/traces format
STATUS_CODE_UNSET: 'Unset',
STATUS_CODE_OK: 'Ok',
STATUS_CODE_ERROR: 'Error',
},
},
// Rule 5: http.status_code type conversion
typeConversions: {
'http.status_code': 'number',
},
};
// ===========================================
let transformedExpression = filterExpression;
// Apply attribute renames
Object.entries(METRIC_TO_LOGS_TRACES_MAPPINGS.attributeRenames).forEach(
([oldAttr, newAttr]) => {
const regex = new RegExp(`\\b${oldAttr}\\b`, 'g');
transformedExpression = transformedExpression.replace(regex, newAttr);
},
);
// Apply span.kind → kind transformation
const { spanKindMapping } = METRIC_TO_LOGS_TRACES_MAPPINGS;
if (spanKindMapping) {
// Replace attribute name - use word boundaries to avoid partial matches
const attrRegex = new RegExp(
`\\b${spanKindMapping.attribute.replace(/\./g, '\\.')}\\b`,
'g',
);
transformedExpression = transformedExpression.replace(
attrRegex,
spanKindMapping.newAttribute,
);
// Replace values
Object.entries(spanKindMapping.valueMappings).forEach(
([oldValue, newValue]) => {
const valueRegex = new RegExp(`\\b${oldValue}\\b`, 'g');
transformedExpression = transformedExpression.replace(valueRegex, newValue);
},
);
}
// Apply status.code → status_code_string transformation
const { statusCodeMapping } = METRIC_TO_LOGS_TRACES_MAPPINGS;
if (statusCodeMapping) {
// Replace attribute name - use word boundaries to avoid partial matches
// This prevents http.status_code from being transformed
const attrRegex = new RegExp(
`\\b${statusCodeMapping.attribute.replace(/\./g, '\\.')}\\b`,
'g',
);
transformedExpression = transformedExpression.replace(
attrRegex,
statusCodeMapping.newAttribute,
);
// Replace values
Object.entries(statusCodeMapping.valueMappings).forEach(
([oldValue, newValue]) => {
const valueRegex = new RegExp(`\\b${oldValue}\\b`, 'g');
transformedExpression = transformedExpression.replace(
valueRegex,
`${newValue}`,
);
},
);
}
// Note: Type conversions (Rule 5) would need more complex parsing
// of the filter expression to implement properly
return transformedExpression;
};
export const getViewQuery = (
query: Query,
filtersToAdd: FilterData[],
@@ -448,9 +335,15 @@ export const getViewQuery = (
// TEMP LOGIC - TO BE REMOVED LATER
// ===========================================
// Apply metric-to-logs/traces transformations
if (key === 'view_logs' || key === 'view_traces') {
const transformedExpression = transformMetricsToLogsTraces(
newFilterExpression?.expression,
const specificQuery = getQueryData(query, queryName);
const isMetricQuery = specificQuery?.dataSource === 'metrics';
const metricName = (specificQuery?.aggregations?.[0] as MetricAggregation)
?.metricName;
if (isMetricQuery && isApmMetric(metricName || '')) {
const transformedExpression = replaceKeysAndValuesInExpression(
newFilterExpression?.expression || '',
METRIC_TO_LOGS_TRACES_MAPPINGS,
);
newQuery.builder.queryData[0].filter = {
expression: transformedExpression || '',

View File

@@ -0,0 +1,174 @@
/* eslint-disable sonarjs/cognitive-complexity */
/* eslint-disable no-continue */
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { IQueryPair } from 'types/antlrQueryTypes';
import { extractQueryPairs } from 'utils/queryContextUtils';
import { isQuoted, unquote } from 'utils/stringUtils';
import { isFunctionOperator, isNonValueOperator } from 'utils/tokenUtils';
type KeyValueMapping = {
attribute: string;
newAttribute: string;
valueMappings: Record<string, string>;
};
export const METRIC_TO_LOGS_TRACES_MAPPINGS: KeyValueMapping[] = [
{
attribute: 'operation',
newAttribute: 'name',
valueMappings: {},
},
{
attribute: 'span.kind',
newAttribute: 'kind_string',
valueMappings: {
SPAN_KIND_INTERNAL: 'Internal',
SPAN_KIND_SERVER: 'Server',
SPAN_KIND_CLIENT: 'Client',
SPAN_KIND_PRODUCER: 'Producer',
SPAN_KIND_CONSUMER: 'Consumer',
},
},
{
attribute: 'status.code',
newAttribute: 'status_code_string',
valueMappings: {
STATUS_CODE_UNSET: 'Unset',
STATUS_CODE_OK: 'Ok',
STATUS_CODE_ERROR: 'Error',
},
},
];
// Logic for rewriting key/values in an expression using provided mappings.
function modifyKeyVal(pair: IQueryPair, mapping: KeyValueMapping): string {
const newKey = mapping.newAttribute;
const op = pair.operator;
const operator = pair.hasNegation
? getOperatorValue(`NOT_${pair.operator}`.toUpperCase())
: getOperatorValue(pair.operator.toUpperCase());
// Map a single value token using valueMappings, skipping variables
const mapOne = (val: string | undefined): string | undefined => {
if (val == null) return val;
const t = String(val).trim();
// Skip variables for now. We will handle them later.
if (t.startsWith('$')) return t;
const raw = isQuoted(t) ? unquote(t) : t;
return mapping.valueMappings[raw] ?? raw;
};
// Function-style operator: op(newKey, value?)
if (isFunctionOperator(op)) {
let mapped: string | string[] | undefined;
if (pair.isMultiValue && Array.isArray(pair.valueList)) {
mapped = pair.valueList.map((v) => mapOne(v) as string);
} else if (typeof pair.value !== 'undefined') {
mapped = mapOne(pair.value);
}
const hasValue =
typeof mapped !== 'undefined' &&
!(Array.isArray(mapped) && mapped.length === 0);
if (!hasValue) {
return `${op}(${newKey})`;
}
const formatted = formatValueForExpression(mapped as any, op);
return `${op}(${newKey}, ${formatted})`;
}
// Non-value operator: e.g., exists / not exists
if (isNonValueOperator(op)) {
return `${newKey} ${operator}`;
}
// Standard key-operator-value
let mapped: string | string[] | undefined;
if (pair.isMultiValue && Array.isArray(pair.valueList)) {
mapped = pair.valueList.map((v) => mapOne(v) as string);
} else if (typeof pair.value !== 'undefined') {
mapped = mapOne(pair.value);
}
const formatted = formatValueForExpression(mapped as any, op);
return `${newKey} ${operator} ${formatted}`;
}
// Replace keys/values in an expression using provided mappings.
// wires parsing, ordering, and reconstruction.
export function replaceKeysAndValuesInExpression(
expression: string,
mappingList: KeyValueMapping[],
): string {
if (!expression || !mappingList || mappingList.length === 0) {
return expression;
}
const attributeToMapping = new Map<string, KeyValueMapping>(
mappingList.map((m) => [m.attribute.trim().toLowerCase(), m]),
);
const pairs: IQueryPair[] = extractQueryPairs(expression);
type PairWithBounds = {
pair: IQueryPair;
start: number;
end: number;
};
const withBounds: PairWithBounds[] = [];
for (let i = 0; i < pairs.length; i += 1) {
const pair = pairs[i];
// Require complete positions for safe slicing
if (!pair?.position) continue;
const start =
pair.position.keyStart ??
pair.position.operatorStart ??
pair.position.valueStart;
const end =
pair.position.valueEnd ?? pair.position.operatorEnd ?? pair.position.keyEnd;
if (
typeof start === 'number' &&
typeof end === 'number' &&
start >= 0 &&
end >= start
) {
withBounds.push({ pair, start, end });
}
}
// Process in source order
withBounds.sort((a, b) => a.start - b.start);
let startIdx = 0;
const resultParts: string[] = [];
for (let i = 0; i < withBounds.length; i += 1) {
const item = withBounds[i];
const sourceKey = item.pair?.key?.trim().toLowerCase();
if (!sourceKey) continue;
const mapping = attributeToMapping.get(sourceKey);
if (!mapping) {
continue;
}
// Add unchanged prefix up to the start of this pair
resultParts.push(expression.slice(startIdx, item.start));
// Replacement produced by modifyKeyVal
const replacement = modifyKeyVal(item.pair, mapping);
resultParts.push(replacement);
// Advance cursor past this pair
startIdx = item.end + 1;
}
// Append the remainder of the expression
resultParts.push(expression.slice(startIdx));
return resultParts.join('');
}

View File

@@ -1,3 +1,6 @@
/* eslint-disable sonarjs/no-duplicate-string */
import userEvent from '@testing-library/user-event';
import { ENVIRONMENT } from 'constants/env';
import { rest, server } from 'mocks-server/server';
import { render, screen, waitFor } from 'tests/test-utils';
@@ -186,3 +189,68 @@ describe('Traces ListView - Error and Empty States', () => {
});
});
});
describe('AttributeKey handling (UI)', () => {
const BASE_URL = ENVIRONMENT.baseURL;
beforeEach(() => {
server.use(
rest.get(`${BASE_URL}/api/v1/fields/keys`, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
complete: true,
keys: {
attributeKeys: [
{
name: 'http.status_code_attribute',
signal: 'traces',
fieldContext: 'attribute',
fieldDataType: 'string',
},
{
name: 'http.status_code_attribute',
signal: 'traces',
fieldContext: 'attribute',
fieldDataType: 'number',
},
{
name: 'test options',
signal: 'traces',
fieldContext: 'attribute',
fieldDataType: 'string',
},
],
},
},
}),
),
),
);
});
it('opens Options menu and shows attribute variants in the Add Column select', async () => {
renderListView({ isFilterApplied: true });
verifyControlsVisibility();
// Open options popover
const optionsButton = screen.getByText(/options_menu.options|options/i);
await userEvent.click(optionsButton);
const selectSearchInput = document.querySelector(
'.ant-popover-content input.ant-select-selection-search-input',
) as HTMLInputElement;
expect(selectSearchInput).toBeInTheDocument();
await userEvent.click(selectSearchInput);
// Ensure dropdown items have rendered by waiting for a known option
await screen.findByText('test options');
expect(screen.getAllByText('http.status_code_attribute').length).toBe(2);
});
});

View File

@@ -0,0 +1,32 @@
import { getMetricMetadata } from 'api/metricsExplorer/v2/getMetricMetadata';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useQueries, UseQueryOptions, UseQueryResult } from 'react-query';
import { SuccessResponseV2 } from 'types/api';
import { MetricMetadataResponse } from 'types/api/metricsExplorer/v2/getMetricMetadata';
type QueryResult = UseQueryResult<
SuccessResponseV2<MetricMetadataResponse>,
Error
>;
type UseGetMultipleMetrics = (
metricNames: string[],
options?: UseQueryOptions<SuccessResponseV2<MetricMetadataResponse>, Error>,
headers?: Record<string, string>,
) => QueryResult[];
export const useGetMultipleMetrics: UseGetMultipleMetrics = (
metricNames,
options,
headers,
) =>
useQueries(
metricNames.map(
(metricName) =>
({
queryKey: [REACT_QUERY_KEY.GET_METRIC_METADATA, metricName],
queryFn: ({ signal }) => getMetricMetadata(metricName, signal, headers),
...options,
} as UseQueryOptions<SuccessResponseV2<MetricMetadataResponse>, Error>),
),
);

View File

@@ -5,7 +5,7 @@ import updateMetricMetadata, {
import { useMutation, UseMutationResult } from 'react-query';
import { ErrorResponse, SuccessResponse } from 'types/api';
interface UseUpdateMetricMetadataProps {
export interface UseUpdateMetricMetadataProps {
metricName: string;
payload: UpdateMetricMetadataProps;
}

View File

@@ -188,7 +188,7 @@ describe('useQueryBuilderOperations - Empty Aggregate Attribute Type', () => {
timeAggregation: MetricAggregateOperator.RATE,
metricName: 'new_sum_metric',
temporality: '',
spaceAggregation: '',
spaceAggregation: MetricAggregateOperator.SUM,
},
],
}),
@@ -239,7 +239,7 @@ describe('useQueryBuilderOperations - Empty Aggregate Attribute Type', () => {
timeAggregation: MetricAggregateOperator.RATE,
metricName: 'new_sum_metric',
temporality: '',
spaceAggregation: '',
spaceAggregation: MetricAggregateOperator.SUM,
},
],
}),
@@ -315,7 +315,7 @@ describe('useQueryBuilderOperations - Empty Aggregate Attribute Type', () => {
timeAggregation: MetricAggregateOperator.AVG,
metricName: 'new_gauge',
temporality: '',
spaceAggregation: '',
spaceAggregation: MetricAggregateOperator.AVG,
},
],
}),

View File

@@ -6,10 +6,8 @@ import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { MenuItemKeys } from 'container/GridCardLayout/WidgetHeader/contants';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import { useNotifications } from 'hooks/useNotifications';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import history from 'lib/history';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useCallback, useMemo } from 'react';
@@ -20,11 +18,7 @@ import { IDashboardVariable, Widgets } from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getGraphType } from 'utils/getGraphType';
const useCreateAlerts = (
widget?: Widgets,
caller?: string,
thresholds?: ThresholdProps[],
): VoidFunction => {
const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
const queryRangeMutation = useMutation(getSubstituteVars);
const { selectedTime: globalSelectedInterval } = useSelector<
@@ -83,9 +77,7 @@ const useCreateAlerts = (
QueryParams.panelTypes
}=${widget.panelTypes}&version=${ENTITY_VERSION_V5}`;
history.push(url, {
thresholds,
});
window.open(url, '_blank', 'noreferrer');
},
onError: () => {
notifications.error({

View File

@@ -317,7 +317,7 @@ export const useQueryOperations: UseQueryOperations = ({
timeAggregation: MetricAggregateOperator.RATE,
metricName: newQuery.aggregateAttribute?.key || '',
temporality: '',
spaceAggregation: '',
spaceAggregation: MetricAggregateOperator.SUM,
},
];
} else if (newQuery.aggregateAttribute?.type === ATTRIBUTE_TYPES.GAUGE) {
@@ -326,7 +326,20 @@ export const useQueryOperations: UseQueryOperations = ({
timeAggregation: MetricAggregateOperator.AVG,
metricName: newQuery.aggregateAttribute?.key || '',
temporality: '',
spaceAggregation: '',
spaceAggregation: MetricAggregateOperator.AVG,
},
];
} else if (
newQuery.aggregateAttribute?.type === ATTRIBUTE_TYPES.HISTOGRAM ||
newQuery.aggregateAttribute?.type ===
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM
) {
newQuery.aggregations = [
{
timeAggregation: '',
metricName: newQuery.aggregateAttribute?.key || '',
temporality: '',
spaceAggregation: MetricAggregateOperator.P90,
},
];
} else {

View File

@@ -1,8 +1,6 @@
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import { AxiosError, AxiosResponse } from 'axios';
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
import { ErrorResponse } from 'react-router-dom-v5-compat';
import { SuccessResponse } from 'types/api';
import { QueryKeyValueSuggestionsResponseProps } from 'types/api/querySuggestions/types';
export const useGetQueryKeyValueSuggestions = ({
@@ -11,13 +9,15 @@ export const useGetQueryKeyValueSuggestions = ({
searchText,
signalSource,
metricName,
options,
}: {
key: string;
signal: 'traces' | 'logs' | 'metrics';
searchText?: string;
signalSource?: 'meter' | '';
options?: UseQueryOptions<
SuccessResponse<QueryKeyValueSuggestionsResponseProps> | ErrorResponse
AxiosResponse<QueryKeyValueSuggestionsResponseProps>,
AxiosError
>;
metricName?: string;
}): UseQueryResult<
@@ -41,4 +41,5 @@ export const useGetQueryKeyValueSuggestions = ({
signalSource: signalSource as 'meter' | '',
metricName: metricName || '',
}),
...options,
});

View File

@@ -0,0 +1,238 @@
import { renderHook } from '@testing-library/react';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { useGetMetrics } from 'container/MetricsExplorer/Explorer/utils';
import { MetricMetadata } from 'types/api/metricsExplorer/v2/getMetricMetadata';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder';
import { useQueryBuilder } from './queryBuilder/useQueryBuilder';
import useGetYAxisUnit from './useGetYAxisUnit';
jest.mock('./queryBuilder/useQueryBuilder');
jest.mock('container/MetricsExplorer/Explorer/utils', () => ({
...jest.requireActual('container/MetricsExplorer/Explorer/utils'),
useGetMetrics: jest.fn(),
}));
const mockUseQueryBuilder = useQueryBuilder as jest.MockedFunction<
typeof useQueryBuilder
>;
const mockUseGetMetrics = useGetMetrics as jest.MockedFunction<
typeof useGetMetrics
>;
const MOCK_METRIC_1 = {
unit: UniversalYAxisUnit.BYTES,
} as MetricMetadata;
const MOCK_METRIC_2 = {
unit: UniversalYAxisUnit.SECONDS,
} as MetricMetadata;
const MOCK_METRIC_3 = {
unit: '',
} as MetricMetadata;
function createMockCurrentQuery(
queryType: EQueryType,
queryData: Query['builder']['queryData'] = [],
): Query {
return {
queryType,
promql: [],
builder: {
queryData,
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [],
id: 'test-id',
};
}
describe('useGetYAxisUnit', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseGetMetrics.mockReturnValue({
isLoading: false,
isError: false,
metrics: [],
});
mockUseQueryBuilder.mockReturnValue(({
currentQuery: undefined,
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
});
it('should return undefined yAxisUnit and not call useGetMetrics when currentQuery is null', async () => {
const { result } = renderHook(() => useGetYAxisUnit());
expect(result.current.yAxisUnit).toBeUndefined();
expect(result.current.isLoading).toBe(false);
expect(result.current.isError).toBe(false);
expect(mockUseGetMetrics).toHaveBeenCalledWith([], false);
});
it('should return undefined yAxisUnit when queryType is PROM', async () => {
const mockCurrentQuery = createMockCurrentQuery(EQueryType.PROM);
mockUseQueryBuilder.mockReturnValueOnce(({
currentQuery: mockCurrentQuery,
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
const { result } = renderHook(() => useGetYAxisUnit());
expect(result.current.yAxisUnit).toBeUndefined();
expect(mockUseGetMetrics).toHaveBeenCalledWith([], false);
});
it('should return undefined yAxisUnit when queryType is CLICKHOUSE', async () => {
const mockCurrentQuery = createMockCurrentQuery(EQueryType.CLICKHOUSE);
mockUseQueryBuilder.mockReturnValueOnce(({
currentQuery: mockCurrentQuery,
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
const { result } = renderHook(() => useGetYAxisUnit());
expect(result.current.yAxisUnit).toBeUndefined();
expect(result.current.isLoading).toBe(false);
expect(result.current.isError).toBe(false);
expect(mockUseGetMetrics).toHaveBeenCalledWith([], false);
});
it('should return undefined yAxisUnit when dataSource is TRACES', async () => {
const mockCurrentQuery = createMockCurrentQuery(EQueryType.QUERY_BUILDER, [
{
dataSource: DataSource.TRACES,
aggregateAttribute: { key: 'trace_metric' },
} as Query['builder']['queryData'][0],
]);
mockUseQueryBuilder.mockReturnValueOnce(({
currentQuery: mockCurrentQuery,
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
const { result } = renderHook(() => useGetYAxisUnit());
expect(result.current.yAxisUnit).toBeUndefined();
expect(result.current.isLoading).toBe(false);
expect(result.current.isError).toBe(false);
expect(mockUseGetMetrics).toHaveBeenCalledWith([], false);
});
it('should return undefined yAxisUnit when dataSource is LOGS', async () => {
const mockCurrentQuery = createMockCurrentQuery(EQueryType.QUERY_BUILDER, [
{
dataSource: DataSource.LOGS,
aggregateAttribute: { key: 'log_metric' },
} as Query['builder']['queryData'][number],
]);
mockUseQueryBuilder.mockReturnValueOnce(({
currentQuery: mockCurrentQuery,
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
const { result } = renderHook(() => useGetYAxisUnit());
expect(result.current.yAxisUnit).toBeUndefined();
expect(result.current.isLoading).toBe(false);
expect(result.current.isError).toBe(false);
expect(mockUseGetMetrics).toHaveBeenCalledWith([], false);
});
it('should extract all metric names from queryData when no selected query name is provided', () => {
const mockCurrentQuery = createMockCurrentQuery(EQueryType.QUERY_BUILDER, [
{
dataSource: DataSource.METRICS,
aggregateAttribute: { key: 'metric1' },
queryName: 'query1',
} as Query['builder']['queryData'][number],
{
dataSource: DataSource.METRICS,
aggregateAttribute: { key: 'metric2' },
queryName: 'query2',
} as Query['builder']['queryData'][number],
]);
mockUseQueryBuilder.mockReturnValueOnce(({
stagedQuery: mockCurrentQuery,
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
renderHook(() => useGetYAxisUnit());
expect(mockUseGetMetrics).toHaveBeenCalledWith(['metric1', 'metric2'], true);
});
it('should extract metric name for the selected query only when one is provided', () => {
const mockCurrentQuery = createMockCurrentQuery(EQueryType.QUERY_BUILDER, [
{
dataSource: DataSource.METRICS,
aggregateAttribute: { key: 'metric1' },
queryName: 'query1',
} as Query['builder']['queryData'][number],
{
dataSource: DataSource.METRICS,
aggregateAttribute: { key: 'metric2' },
queryName: 'query2',
} as Query['builder']['queryData'][number],
]);
mockUseQueryBuilder.mockReturnValueOnce(({
stagedQuery: mockCurrentQuery,
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
renderHook(() => useGetYAxisUnit('query2'));
expect(mockUseGetMetrics).toHaveBeenCalledWith(['metric2'], true);
});
it('should return the unit when there is a single metric with a non-empty unit', async () => {
mockUseGetMetrics.mockReturnValue({
isLoading: false,
isError: false,
metrics: [MOCK_METRIC_1],
});
const { result } = renderHook(() => useGetYAxisUnit());
expect(result.current.yAxisUnit).toBe(UniversalYAxisUnit.BYTES);
expect(result.current.isLoading).toBe(false);
expect(result.current.isError).toBe(false);
});
it('should return undefined when there is a single metric with no unit', async () => {
mockUseGetMetrics.mockReturnValue({
isLoading: false,
isError: false,
metrics: [MOCK_METRIC_3],
});
const { result } = renderHook(() => useGetYAxisUnit());
expect(result.current.yAxisUnit).toBeUndefined();
expect(result.current.isLoading).toBe(false);
expect(result.current.isError).toBe(false);
});
it('should return the unit when all metrics have the same non-empty unit', async () => {
mockUseGetMetrics.mockReturnValue({
isLoading: false,
isError: false,
metrics: [MOCK_METRIC_1, MOCK_METRIC_1],
});
const { result } = renderHook(() => useGetYAxisUnit());
expect(result.current.yAxisUnit).toBe(UniversalYAxisUnit.BYTES);
expect(result.current.isLoading).toBe(false);
expect(result.current.isError).toBe(false);
});
it('should return undefined when metrics have different units', async () => {
mockUseGetMetrics.mockReturnValueOnce({
isLoading: false,
isError: false,
metrics: [MOCK_METRIC_1, MOCK_METRIC_2],
});
const { result } = renderHook(() => useGetYAxisUnit());
expect(result.current.yAxisUnit).toBeUndefined();
expect(result.current.isLoading).toBe(false);
expect(result.current.isError).toBe(false);
});
});

View File

@@ -0,0 +1,108 @@
import {
getMetricUnits,
useGetMetrics,
} from 'container/MetricsExplorer/Explorer/utils';
import { useEffect, useMemo, useState } from 'react';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { useQueryBuilder } from './queryBuilder/useQueryBuilder';
interface UseGetYAxisUnitResult {
yAxisUnit: string | undefined;
isLoading: boolean;
isError: boolean;
}
/**
* Hook to get the y-axis unit for a given metrics-based query.
* @param selectedQueryName - The name of the query to get the y-axis unit for.
* @param params.enabled - Active state of the hook.
* @returns `{ yAxisUnit, isLoading, isError }` The y-axis unit, loading state, and error state
*/
function useGetYAxisUnit(
selectedQueryName?: string,
params: {
enabled?: boolean;
} = {
enabled: true,
},
): UseGetYAxisUnitResult {
const { stagedQuery } = useQueryBuilder();
const [yAxisUnit, setYAxisUnit] = useState<string | undefined>();
const metricNames: string[] | null = useMemo(() => {
// If the query type is not QUERY_BUILDER, return null
if (stagedQuery?.queryType !== EQueryType.QUERY_BUILDER) {
return null;
}
// If the data source is not METRICS, return null
const dataSource = stagedQuery?.builder?.queryData?.[0]?.dataSource;
if (dataSource !== DataSource.METRICS) {
return null;
}
const currentMetricNames: string[] = [];
// If a selected query name is provided, return the metric name for that query only
if (selectedQueryName) {
stagedQuery?.builder?.queryData?.forEach((query) => {
if (
query.queryName === selectedQueryName &&
query.aggregateAttribute?.key
) {
currentMetricNames.push(query.aggregateAttribute?.key);
}
});
return currentMetricNames.length ? currentMetricNames : null;
}
// Else, return all metric names
stagedQuery?.builder?.queryData?.forEach((query) => {
if (query.aggregateAttribute?.key) {
currentMetricNames.push(query.aggregateAttribute?.key);
}
});
return currentMetricNames.length ? currentMetricNames : null;
}, [
selectedQueryName,
stagedQuery?.builder?.queryData,
stagedQuery?.queryType,
]);
const { metrics, isLoading, isError } = useGetMetrics(
metricNames ?? [],
!!metricNames && params?.enabled,
);
const units = useMemo(() => getMetricUnits(metrics), [metrics]);
const areAllMetricUnitsSame = useMemo(
() => units.every((unit) => unit === units[0]),
[units],
);
useEffect(() => {
// If there are no metrics, set the y-axis unit to undefined
if (units.length === 0) {
setYAxisUnit(undefined);
// If there is one metric and it has a non-empty unit, set the y-axis unit to it
} else if (units.length === 1 && units[0] !== '') {
setYAxisUnit(units[0]);
// If all metrics have the same non-empty unit, set the y-axis unit to it
} else if (areAllMetricUnitsSame) {
if (units[0] !== '') {
setYAxisUnit(units[0]);
} else {
setYAxisUnit(undefined);
}
// If there is more than one metric and they have different units, set the y-axis unit to undefined
} else if (units.length > 1 && !areAllMetricUnitsSame) {
setYAxisUnit(undefined);
// If there is one metric and it has an empty unit, set the y-axis unit to undefined
} else if (units.length === 1 && units[0] === '') {
setYAxisUnit(undefined);
}
}, [units, areAllMetricUnitsSame]);
return { yAxisUnit, isLoading, isError };
}
export default useGetYAxisUnit;

View File

@@ -1,6 +1,6 @@
import { themeColors } from 'constants/theme';
import getLabelName from 'lib/getLabelName';
import { cloneDeep, isUndefined } from 'lodash-es';
import { isUndefined } from 'lodash-es';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { QueryData } from 'types/api/widgets/getQuery';
@@ -8,7 +8,7 @@ import { normalizePlotValue } from './dataUtils';
import { generateColor } from './generateColor';
function getXAxisTimestamps(seriesList: QueryData[]): number[] {
const timestamps = new Set();
const timestamps = new Set<number>();
seriesList.forEach((series: { values?: [number, string][] }) => {
if (series?.values) {
@@ -18,54 +18,71 @@ function getXAxisTimestamps(seriesList: QueryData[]): number[] {
}
});
const timestampsArr: number[] | unknown[] = Array.from(timestamps) || [];
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return timestampsArr.sort((a, b) => a - b);
const timestampsArr = Array.from(timestamps);
timestampsArr.sort((a, b) => a - b);
return timestampsArr;
}
function fillMissingXAxisTimestamps(timestampArr: number[], data: any[]): any {
// eslint-disable-next-line sonarjs/cognitive-complexity
function fillMissingXAxisTimestamps(
timestampArr: number[],
data: Array<{ values?: [number, string][] }>,
): (number | null)[][] {
// Generate a set of all timestamps in the range
const allTimestampsSet = new Set(timestampArr);
const processedData = cloneDeep(data);
const result: (number | null)[][] = [];
// Fill missing timestamps with null values
processedData.forEach((entry: { values: (number | null)[][] }) => {
const existingTimestamps = new Set(
(entry?.values ?? []).map((value) => value[0]),
);
// Process each series entry
for (let i = 0; i < data.length; i++) {
const entry = data[i];
if (!entry?.values) {
result.push([]);
} else {
// Build Set of existing timestamps directly (avoid intermediate array)
const existingTimestamps = new Set<number>();
const valuesMap = new Map<number, number | null>();
const missingTimestamps = Array.from(allTimestampsSet).filter(
(timestamp) => !existingTimestamps.has(timestamp),
);
for (let j = 0; j < entry.values.length; j++) {
const [timestamp, value] = entry.values[j];
existingTimestamps.add(timestamp);
valuesMap.set(timestamp, normalizePlotValue(value));
}
missingTimestamps.forEach((timestamp) => {
const value = null;
// Find missing timestamps by iterating Set directly (avoid Array.from + filter)
const missingTimestamps: number[] = [];
const allTimestampsArray = Array.from(allTimestampsSet);
for (let k = 0; k < allTimestampsArray.length; k++) {
const timestamp = allTimestampsArray[k];
if (!existingTimestamps.has(timestamp)) {
missingTimestamps.push(timestamp);
}
}
entry?.values?.push([timestamp, value]);
});
// Add missing timestamps to map
for (let j = 0; j < missingTimestamps.length; j++) {
valuesMap.set(missingTimestamps[j], null);
}
entry?.values?.forEach((v) => {
// eslint-disable-next-line no-param-reassign
v[1] = normalizePlotValue(v[1]);
});
// Build sorted array of values
const sortedTimestamps = Array.from(valuesMap.keys()).sort((a, b) => a - b);
const yValues = sortedTimestamps.map((timestamp) => {
const value = valuesMap.get(timestamp);
return value !== undefined ? value : null;
});
result.push(yValues);
}
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
entry?.values?.sort((a, b) => a[0] - b[0]);
});
return processedData.map((entry: { values: [number, string][] }) =>
entry?.values?.map((value) => value[1]),
);
return result;
}
function getStackedSeries(val: any): any {
const series = cloneDeep(val) || [];
function getStackedSeries(val: (number | null)[][]): (number | null)[][] {
const series = val ? val.map((row: (number | null)[]) => [...row]) : [];
for (let i = series.length - 2; i >= 0; i--) {
for (let j = 0; j < series[i].length; j++) {
series[i][j] += series[i + 1][j];
series[i][j] = (series[i][j] || 0) + (series[i + 1][j] || 0);
}
}
@@ -110,6 +127,7 @@ const processAnomalyDetectionData = (
queryIndex < anomalyDetectionData.length;
queryIndex++
) {
const queryData = anomalyDetectionData[queryIndex];
const {
series,
predictedSeries,
@@ -117,7 +135,7 @@ const processAnomalyDetectionData = (
lowerBoundSeries,
queryName,
legend,
} = anomalyDetectionData[queryIndex];
} = queryData;
for (let index = 0; index < series?.length; index++) {
const label = getLabelName(
@@ -129,14 +147,30 @@ const processAnomalyDetectionData = (
const objKey =
anomalyDetectionData.length > 1 ? `${queryName}-${label}` : label;
// Single iteration instead of 5 separate map operations
const { values: seriesValues } = series[index];
const { values: predictedValues } = predictedSeries[index];
const { values: upperBoundValues } = upperBoundSeries[index];
const { values: lowerBoundValues } = lowerBoundSeries[index];
// eslint-disable-next-line prefer-destructuring
const length = seriesValues.length;
const timestamps: number[] = new Array(length);
const values: number[] = new Array(length);
const predicted: number[] = new Array(length);
const upperBound: number[] = new Array(length);
const lowerBound: number[] = new Array(length);
for (let i = 0; i < length; i++) {
timestamps[i] = seriesValues[i].timestamp / 1000;
values[i] = seriesValues[i].value;
predicted[i] = predictedValues[i].value;
upperBound[i] = upperBoundValues[i].value;
lowerBound[i] = lowerBoundValues[i].value;
}
processedData[objKey] = {
data: [
series[index].values.map((v: { timestamp: number }) => v.timestamp / 1000),
series[index].values.map((v: { value: number }) => v.value),
predictedSeries[index].values.map((v: { value: number }) => v.value),
upperBoundSeries[index].values.map((v: { value: number }) => v.value),
lowerBoundSeries[index].values.map((v: { value: number }) => v.value),
],
data: [timestamps, values, predicted, upperBound, lowerBound],
color: generateColor(
objKey,
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
@@ -152,14 +186,7 @@ const processAnomalyDetectionData = (
export const getUplotChartDataForAnomalyDetection = (
apiResponse: MetricRangePayloadProps,
isDarkMode: boolean,
): Record<
string,
{
[x: string]: any;
data: number[][];
color: string;
}
> => {
): Record<string, { [x: string]: any; data: number[][]; color: string }> => {
const anomalyDetectionData = apiResponse?.data?.newResult?.data?.result;
return processAnomalyDetectionData(anomalyDetectionData, isDarkMode);
};

View File

@@ -68,7 +68,7 @@ jest.mock('providers/preferences/sync/usePreferenceSync', () => ({
preferences: {
columns: [],
formatting: {
maxLines: 2,
maxLines: 1,
format: 'table',
fontSize: 'small',
version: 1,

View File

@@ -59,7 +59,7 @@ export const optionMenuReturn = {
id: 'dbName--string--tag--true',
},
],
maxLines: 2,
maxLines: 1,
format: 'list',
},
handleOptionsChange: jest.fn(),

View File

@@ -160,7 +160,7 @@ describe('logsLoaderConfig', () => {
expect(result).toEqual({
columns: defaultLogsSelectedColumns,
formatting: {
maxLines: 2,
maxLines: 1,
format: 'table' as LogViewMode,
fontSize: 'small' as FontSize,
version: 1,

View File

@@ -41,7 +41,7 @@ describe('logsUpdaterConfig', () => {
const mockPreferences: Preferences = {
columns: [],
formatting: {
maxLines: 2,
maxLines: 1,
format: 'table' as LogViewMode,
fontSize: 'small' as FontSize,
version: 1,
@@ -80,7 +80,7 @@ describe('logsUpdaterConfig', () => {
dataType: DataTypes.String,
},
],
maxLines: 2,
maxLines: 1,
});
logsUpdater.updateColumns(newColumns, PreferenceMode.DIRECT);
@@ -97,7 +97,7 @@ describe('logsUpdaterConfig', () => {
mockLocalStorage[LOCALSTORAGE.LOGS_LIST_OPTIONS],
);
expect(storedData.selectColumns).toEqual(newColumns);
expect(storedData.maxLines).toBe(2); // Should preserve other fields
expect(storedData.maxLines).toBe(1); // Should preserve other fields
// Should not update saved view preferences
expect(setSavedViewPreferences).not.toHaveBeenCalled();
@@ -153,7 +153,7 @@ describe('logsUpdaterConfig', () => {
dataType: DataTypes.String,
},
],
maxLines: 2,
maxLines: 1,
format: 'table',
});
@@ -206,7 +206,7 @@ describe('logsUpdaterConfig', () => {
dataType: DataTypes.String,
},
],
maxLines: 2,
maxLines: 1,
format: 'table',
});
@@ -216,7 +216,7 @@ describe('logsUpdaterConfig', () => {
const storedData = JSON.parse(
mockLocalStorage[LOCALSTORAGE.LOGS_LIST_OPTIONS],
);
expect(storedData.maxLines).toBe(2); // Should remain the same
expect(storedData.maxLines).toBe(1); // Should remain the same
expect(storedData.format).toBe('table'); // Should remain the same
// Should update saved view preferences

View File

@@ -113,7 +113,7 @@ describe('tracesUpdaterConfig', () => {
expect(mockSetSavedViewPreferences).toHaveBeenCalledWith({
columns: mockColumns,
formatting: {
maxLines: 2,
maxLines: 1,
format: 'table',
fontSize: 'small',
version: 1,

View File

@@ -46,7 +46,7 @@ describe('usePreferenceUpdater', () => {
const mockPreferences: Preferences = {
columns: [],
formatting: {
maxLines: 2,
maxLines: 1,
format: 'table' as LogViewMode,
fontSize: 'small' as FontSize,
version: 1,

View File

@@ -39,7 +39,7 @@ const logsLoaders = {
return {
columns: validLogColumns.length > 0 ? validLogColumns : [],
formatting: {
maxLines: parsed.maxLines ?? 2,
maxLines: parsed.maxLines ?? 1,
format: parsed.format ?? 'table',
fontSize: parsed.fontSize ?? 'small',
version: parsed.version ?? 1,
@@ -65,7 +65,7 @@ const logsLoaders = {
return {
columns: validLogColumns.length > 0 ? validLogColumns : [],
formatting: {
maxLines: options.maxLines ?? 2,
maxLines: options.maxLines ?? 1,
format: options.format ?? 'table',
fontSize: options.fontSize ?? 'small',
version: options.version ?? 1,
@@ -80,7 +80,7 @@ const logsLoaders = {
} => ({
columns: defaultLogsSelectedColumns,
formatting: {
maxLines: 2,
maxLines: 1,
format: 'table',
fontSize: 'small' as FontSize,
version: 1,

View File

@@ -23,7 +23,7 @@ const getLogsUpdaterConfig = (
return {
columns: newColumns,
formatting: {
maxLines: 2,
maxLines: 1,
format: 'table',
fontSize: 'small' as FontSize,
version: 1,

View File

@@ -21,7 +21,7 @@ const getTracesUpdaterConfig = (
setSavedViewPreferences({
columns: newColumns,
formatting: {
maxLines: 2,
maxLines: 1,
format: 'table',
fontSize: 'small' as FontSize,
version: 1,

View File

@@ -58,7 +58,7 @@ export function usePreferenceSync({
updateExtraDataSelectColumns(parsedExtraData?.selectColumns) ||
defaultLogsSelectedColumns;
formatting = {
maxLines: parsedExtraData?.maxLines ?? 2,
maxLines: parsedExtraData?.maxLines ?? 1,
format: parsedExtraData?.format ?? 'table',
fontSize: parsedExtraData?.fontSize ?? 'small',
version: parsedExtraData?.version ?? 1,

View File

@@ -0,0 +1,15 @@
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
export interface MetricMetadata {
description: string;
type: MetricType;
unit: string;
temporality: Temporality;
isMonotonic: boolean;
}
export interface MetricMetadataResponse {
status: string;
data: MetricMetadata;
}

View File

@@ -48,6 +48,7 @@ const plugins = [
SENTRY_DSN: process.env.SENTRY_DSN,
TUNNEL_URL: process.env.TUNNEL_URL,
TUNNEL_DOMAIN: process.env.TUNNEL_DOMAIN,
DOCS_BASE_URL: process.env.DOCS_BASE_URL,
}),
}),
sentryWebpackPlugin({

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