Compare commits

...

29 Commits

Author SHA1 Message Date
aks07
07cdb2d4ae test(trace-details): add preview-fields hover card e2e 2026-06-25 00:01:40 +05:30
aks07
9318050f74 test(trace-details): add span details drawer e2e 2026-06-24 23:54:29 +05:30
aks07
626b6c3153 test(trace-details): add analytics panel e2e 2026-06-24 23:51:43 +05:30
aks07
8e9f533b58 test(trace-details): add highlight-errors filter e2e 2026-06-24 23:42:48 +05:30
aks07
3428d3a2e2 test(trace-details): add waterfall e2e + row instrumentation 2026-06-24 19:43:01 +05:30
aks07
521f43a37a test(trace-details): add flamegraph e2e + canvas test hook 2026-06-24 18:03:08 +05:30
aks07
a90e706038 test(trace-details): add e2e helper and large-trace fixture 2026-06-24 17:49:12 +05:30
aks07
8a4b234ee7 feat(trace-details): fix failing test 2026-06-22 18:55:28 +05:30
aks07
bddc61d22b feat(trace-details): remove unused trace details v2 code 2026-06-22 18:55:28 +05:30
aks07
604b5e2a4a feat(trace-details): remove Trace Details V2 page and its module import 2026-06-22 18:55:28 +05:30
aks07
e2d840345b fix(trace-details): fix serviceName path in trace funnel 2026-06-22 18:55:28 +05:30
aks07
e6071a7cb8 feat(trace-details): remove usage of getTraceV2 from V3 code 2026-06-22 18:55:28 +05:30
aks07
d4b3a34d10 feat(trace-details): move events out from v2 to v3 before cleanup 2026-06-22 18:55:28 +05:30
aks07
972cd00c68 feat(trace-details): move span logs out from v2 to v3 before cleanup 2026-06-22 18:55:28 +05:30
aks07
9aff84c276 feat(trace-details): move no-data component from v2 code to v3 before v2 cleanup 2026-06-22 18:55:28 +05:30
Ashwin Bhatkal
4dda1e0ab5 feat(dashboards): views-first V2 dashboards list with filters, saved views, and tabbed new-dashboard modal (#11682)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat(dashboard-v2): add persisted views store, types, and filter-query helpers

* feat(dashboard-v2): add filter state hook and filter zone

* feat(dashboard-v2): add views rail with save and dirty-state flow

* feat(dashboard-v2): add list status bar with rail collapse

* feat(dashboard-v2): add favorites and recently-viewed to dashboard rows

* feat(dashboard-v2): add persisted visible-columns store

* feat(dashboard-v2): add tabbed new-dashboard modal (blank / template / import)

* feat(dashboard-v2): full-width skeleton loading state

* feat(dashboard-v2): compose views, filters, and inline metadata into the list page

* chore(dashboard-v2): remove superseded create dropdown and standalone modals

* feat(dashboard-v2): add duplicate (clone) action to dashboard rows

* refactor(dashboards-v2): move toPostableTags to utils next to its inverse

* refactor(dashboards-v2): use signoz Button for view rows & delete action

* refactor(dashboards-v2): rename filterStatesEqual to areFilterStatesEqual
2026-06-22 13:06:05 +00:00
Ashwin Bhatkal
749943abe4 feat(dashboard-v2): runtime variable selection (#11646)
* feat(dashboard-v2): variable-selection store, dependency graph & sort helpers

* feat(dashboard-v2): runtime variables bar & per-type selectors

* feat(dashboard-v2): mount variables bar in dashboard toolbar
2026-06-22 11:36:43 +00:00
Tushar Vats
4f51ee37ba fix: modularize query range function (#11774) 2026-06-22 11:35:33 +00:00
Abhi kumar
d5617657b5 fix(dashboard): clickhouse table panel collapses value columns onto query name (#11794)
* fix(dashboard): clickhouse table panel collapses value columns onto query name

A table/scalar panel backed by a ClickHouse SQL query rendered every
aggregation column with the header "A" (the query name) and the same value in
each, while only the group columns (e.g. service.name) showed correctly.

Root cause: the scalar-response column-naming utils derive a value column's
display name and row-data key from request-side aggregation metadata, which
only exists for builder_query envelopes. A clickhouse_sql query has none, so
getColName/getColId fell through to the query name for every value column.
Sharing one id ("A") collapsed all value columns onto a single row key, so the
last column written (total_requests) overwrote the rest.

The backend already returns correct data: readAsScalar names each ClickHouse
SELECT column with its real SQL alias and a unique aggregationIndex. This is a
frontend-only consumption fix.

Fix: when a column belongs to a clickhouse_sql query (determined from the
request's query type, not a name heuristic), name and key it by the response
column's real SQL alias. Builder queries are unchanged; formulas/promql keep
the legend || queryName fallback. Applied to both the V1 converter
(convertV5Response.ts, the live table-panel path) and the V2 path
(prepareScalarTables.ts).

* chore: minor type fix
2026-06-22 08:31:06 +00:00
Nityananda Gohain
5600576722 chore: add search and override filters in pricing model list api (#11735) 2026-06-22 08:23:12 +00:00
Vikrant Gupta
f84b818552 feat(authz): add unified role APIs (#11798)
* feat(authz): add unified role APIs

* feat(authz): update openapi spec

* feat(authz): restructure the chunked write to the openfga server

* feat(authz): fix the order for minimal gitdiff

* feat(authz): update openapi spec

* feat(authz): fix the create API

* feat(authz): better error messages
2026-06-22 07:31:40 +00:00
Vinicius Lourenço
4147c5c4bd refactor(alerts): move channels to alerts (#11641)
Some checks failed
build-staging / prepare (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
* refactor(sidenav): add support for routes with ?key=value

* refactor(channels): move to be under alerts

* test(private): add test to cover redirects of channels

* chore(codeowners): move channels to pulse frontend

* chore(sidenav): add todo to remove the menu from sidebar

* test(jest): add transform ignore due to import of react-markdown

* test(ai-assistant): fix redirect link of notification channels
2026-06-20 17:28:53 +00:00
Gaurav Tewari
e1cb822091 chore(deps): bump @grafana/data and pin transitive deps to patched versions (#11796)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
- @grafana/data ^11.6.14 -> ^11.6.15
- http-proxy-middleware 4.0.0 -> 4.1.1 (dep + resolution)
- form-data 4.0.4 -> 4.0.6
- tmp 0.2.4 -> 0.2.7
- add js-cookie ^3.0.7 resolution pin (forces react-use's transitive copy to a patched range)

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-06-20 10:26:40 +00:00
Vinicius Lourenço
b8567664da refactor(quick-filters): split checkbox into multiple files (#11768)
Some checks failed
build-staging / prepare (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
* refactor(quick-filters): extract checkbox value fetching into hook

* refactor(quick-filters): extract checkbox query algebra into pure module

* refactor(quick-filters): extract checkbox glue hooks

* refactor(quick-filters): split checkbox renderers into components
2026-06-18 15:20:16 +00:00
Vinicius Lourenço
643aac4424 fix(alerts): missing fields when duplicating via edit alert (#11767) 2026-06-18 13:30:08 +00:00
Srikanth Chekuri
2cf7ef93ea chore: send warning instead of error for unseen metrics and missing (… (#11754)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: send warning instead of error for unseen metrics and missing (metric, key)

* chore: update integration test

* chore: fix integration test

* chore: fix test

* chore: add unit test for missing key
2026-06-18 10:42:09 +00:00
Nikhil Mantri
dba827ee33 feat(infra-monitoring): namespace+cluster group by for PVC monitoring, cluster group by for namespace monitoring (#11739)
* chore: deployments -> add default namespace group by

* chore: added integration tests for statefulsets

* chore: namespace group by for jobs

* chore: namespace group by for daemonsets

* chore: added group by clustername for all workloads and integration tests for the same

* chore: fix py fmt for integration tests

* chore: added group by namespace, cluster for pvcs

* chore: added cluster name default group by for namespaces monitoring
2026-06-18 09:14:42 +00:00
Ashwin Bhatkal
467a556062 feat(dashboard-v2): redesign public dashboard publish drawer (#11748)
* feat(dashboard-v2): redesign public dashboard publish drawer

Rework the Publish tab to the status-strip design (Claude Design handoff):
- a status strip with a lock/globe medallion, plain-language line and a
  Private/Public badge
- a public-link field shown in both states — a dashed placeholder while
  private, the live URL with copy / open actions once published
- an "Enable time range" switch + default-range select, and a quiet inline
  variables caveat
- actions grouped in a footer (Publish / Unpublish + Update)

Split each piece into its own folder with a co-located *.module.scss, drop the
dead time-range constants in favour of the shared RelativeDurationOptions, and
render the range dropdown without a portal (z-index + trigger width) so it shows
correctly inside the settings drawer.

* feat(dashboard-v2): fetch public dashboard meta once, globally

Move the public-sharing GET out of the publish drawer: a shared
usePublicDashboardMeta hook (keyed by dashboard id, license-gated, kept warm via
staleTime) owns the request, the toolbar mounts it with the dashboard to drive the
public-access badge, and the drawer's usePublicDashboard reads the same cache
instead of issuing its own call. Mutations invalidate the key so all consumers
refresh together.

Also rename the variables Callout to Hint, and drop redundant font-family: Inter /
font-weight: 400 from the publish-drawer styles (Inter is the inherited default).
2026-06-18 07:10:26 +00:00
primus-bot[bot]
a8f6b8187e chore(release): bump to v0.129.0 (#11773)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2026-06-18 07:02:37 +00:00
263 changed files with 11908 additions and 13768 deletions

7
.github/CODEOWNERS vendored
View File

@@ -189,6 +189,13 @@ go.mod @therealpandey
/frontend/src/container/TriggeredAlerts/ @SigNoz/pulse-frontend
/frontend/src/container/AnomalyAlertEvaluationView/ @SigNoz/pulse-frontend
## Notification Channels
/frontend/src/pages/ChannelsEdit/ @SigNoz/pulse-frontend
/frontend/src/pages/ChannelsNew/ @SigNoz/pulse-frontend
/frontend/src/container/AllAlertChannels/ @SigNoz/pulse-frontend
/frontend/src/container/CreateAlertChannels/ @SigNoz/pulse-frontend
/frontend/src/container/EditAlertChannels/ @SigNoz/pulse-frontend
## OpenAPI Schema - Generated
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv
/docs/api/openapi.yml @therealpandey @vikrantgupta25 @srikanthccv

View File

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

View File

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

View File

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

View File

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

View File

@@ -647,8 +647,12 @@ components:
type: string
name:
type: string
transactionGroups:
$ref: '#/components/schemas/AuthtypesTransactionGroups'
required:
- name
- description
- transactionGroups
type: object
AuthtypesPostableRotateToken:
properties:
@@ -703,6 +707,34 @@ components:
useRoleAttribute:
type: boolean
type: object
AuthtypesRoleWithTransactionGroups:
properties:
createdAt:
format: date-time
type: string
description:
type: string
id:
type: string
name:
type: string
orgId:
type: string
transactionGroups:
$ref: '#/components/schemas/AuthtypesTransactionGroups'
type:
type: string
updatedAt:
format: date-time
type: string
required:
- id
- name
- description
- type
- orgId
- transactionGroups
type: object
AuthtypesSamlConfig:
properties:
attributeMapping:
@@ -736,11 +768,35 @@ components:
- relation
- object
type: object
AuthtypesTransactionGroup:
properties:
objectGroup:
$ref: '#/components/schemas/CoretypesObjectGroup'
relation:
$ref: '#/components/schemas/AuthtypesRelation'
required:
- relation
- objectGroup
type: object
AuthtypesTransactionGroups:
items:
$ref: '#/components/schemas/AuthtypesTransactionGroup'
type: array
AuthtypesUpdatableAuthDomain:
properties:
config:
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
type: object
AuthtypesUpdatableRole:
properties:
description:
type: string
transactionGroups:
$ref: '#/components/schemas/AuthtypesTransactionGroups'
required:
- description
- transactionGroups
type: object
AuthtypesUserRole:
properties:
createdAt:
@@ -10253,6 +10309,15 @@ paths:
name: limit
schema:
type: integer
- in: query
name: q
schema:
type: string
- in: query
name: isOverride
schema:
nullable: true
type: boolean
responses:
"200":
content:
@@ -11058,7 +11123,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/AuthtypesRole'
$ref: '#/components/schemas/AuthtypesRoleWithTransactionGroups'
status:
type: string
required:
@@ -11093,7 +11158,7 @@ paths:
tags:
- role
patch:
deprecated: false
deprecated: true
description: This endpoint patches a role
operationId: PatchRole
parameters:
@@ -11154,6 +11219,68 @@ paths:
summary: Patch role
tags:
- role
put:
deprecated: false
description: This endpoint updates a role
operationId: UpdateRole
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesUpdatableRole'
responses:
"204":
description: No Content
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"451":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unavailable For Legal Reasons
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Implemented
security:
- api_key:
- role:update
- tokenizer:
- role:update
summary: Update role
tags:
- role
/api/v1/roles/{id}/relations/{relation}/objects:
get:
deprecated: false
@@ -11233,7 +11360,7 @@ paths:
tags:
- role
patch:
deprecated: false
deprecated: true
description: Patches the objects connected to the specified role via a given
relation type
operationId: PatchObjects

View File

@@ -179,13 +179,36 @@ func (provider *provider) CreateManagedUserRoleTransactions(ctx context.Context,
return provider.Write(ctx, tuples, nil)
}
func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) error {
func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *authtypes.RoleWithTransactionGroups) error {
_, err := provider.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 provider.store.Create(ctx, role)
existingRole, err := provider.GetByOrgIDAndName(ctx, orgID, role.Name)
if err != nil && !errors.Asc(err, authtypes.ErrCodeRoleNotFound) {
return err
}
if existingRole != nil {
return errors.Newf(errors.TypeAlreadyExists, authtypes.ErrCodeRoleAlreadyExists, "role with name: %s already exists", existingRole.Name)
}
tuples, err := authtypes.NewTuplesFromTransactionGroups(role.Name, orgID, role.TransactionGroups)
if err != nil {
return err
}
err = provider.Write(ctx, tuples, nil)
if err != nil {
return err
}
if err := provider.store.Create(ctx, role.Role); err != nil {
return err
}
return nil
}
func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) (*authtypes.Role, error) {
@@ -213,6 +236,26 @@ func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, ro
return role, nil
}
func (provider *provider) GetWithTransactionGroups(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.RoleWithTransactionGroups, error) {
_, err := provider.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())
}
role, err := provider.store.Get(ctx, orgID, id)
if err != nil {
return nil, err
}
tuples, err := provider.readAllTuplesForRole(ctx, role.Name, orgID)
if err != nil {
return nil, err
}
transactionGroups := authtypes.MustNewTransactionGroupsFromTuples(tuples)
return authtypes.MakeRoleWithTransactionGroups(role, transactionGroups), nil
}
func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation) ([]*coretypes.Object, error) {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
@@ -247,6 +290,36 @@ func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id
return objects, nil
}
func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updatedRole *authtypes.RoleWithTransactionGroups) error {
_, err := provider.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())
}
existingRole, err := provider.GetWithTransactionGroups(ctx, orgID, updatedRole.ID)
if err != nil {
return err
}
additions, deletions := existingRole.TransactionGroups.Diff(updatedRole.TransactionGroups)
additionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, additions)
if err != nil {
return err
}
deletionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, deletions)
if err != nil {
return err
}
err = provider.Write(ctx, additionTuples, deletionTuples)
if err != nil {
return err
}
return provider.store.Update(ctx, orgID, updatedRole.Role)
}
func (provider *provider) Patch(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
@@ -286,7 +359,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
role, err := provider.store.Get(ctx, orgID, id)
role, err := provider.GetWithTransactionGroups(ctx, orgID, id)
if err != nil {
return err
}
@@ -302,7 +375,12 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
}
}
if err := provider.deleteTuples(ctx, role.Name, orgID); err != nil {
tuples, err := authtypes.NewTuplesFromTransactionGroups(role.Name, orgID, role.TransactionGroups)
if err != nil {
return err
}
if err := provider.Write(ctx, nil, tuples); err != nil {
return errors.WithAdditionalf(err, "failed to delete tuples for the role: %s", role.Name)
}
@@ -361,7 +439,7 @@ func (provider *provider) getManagedRoleTransactionTuples(orgID valuer.UUID) []*
return tuples
}
func (provider *provider) deleteTuples(ctx context.Context, roleName string, orgID valuer.UUID) error {
func (provider *provider) readAllTuplesForRole(ctx context.Context, roleName string, orgID valuer.UUID) ([]*openfgav1.TupleKey, error) {
subject := authtypes.MustNewSubject(coretypes.NewResourceRole(), roleName, orgID, &coretypes.VerbAssignee)
tuples := make([]*openfgav1.TupleKey, 0)
@@ -371,26 +449,10 @@ func (provider *provider) deleteTuples(ctx context.Context, roleName string, org
Object: objectType.StringValue() + ":",
})
if err != nil {
return err
return nil, err
}
tuples = append(tuples, typeTuples...)
}
if len(tuples) == 0 {
return nil
}
for idx := 0; idx < len(tuples); idx += provider.config.OpenFGA.MaxTuplesPerWrite {
end := idx + provider.config.OpenFGA.MaxTuplesPerWrite
if end > len(tuples) {
end = len(tuples)
}
err := provider.Write(ctx, nil, tuples[idx:end])
if err != nil {
return err
}
}
return nil
return tuples, nil
}

View File

@@ -48,13 +48,14 @@ const config: Config.InitialOptions = {
],
'^.+\\.(js|jsx)$': 'babel-jest',
},
// TODO: https://github.com/SigNoz/engineering-pod/issues/5334
transformIgnorePatterns: [
// @chenglou/pretext is ESM-only; @signozhq/ui pulls it in via text-ellipsis.
// Pattern 1: allow .pnpm virtual store through (handled by pattern 2), plus root-level ESM packages.
'node_modules/(?!(\\.pnpm|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard)/)',
'node_modules/(?!(\\.pnpm|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
// Pattern 2: pnpm virtual store — ignore everything except ESM-only packages.
// pnpm encodes scoped packages as @scope+name@version, so match on scope prefix.
'node_modules/\\.pnpm/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard)[^/]*/node_modules)',
'node_modules/\\.pnpm/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
],
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testPathIgnorePatterns: ['/node_modules/', '/public/'],

View File

@@ -43,7 +43,7 @@
"@dnd-kit/modifiers": "7.0.0",
"@dnd-kit/sortable": "8.0.0",
"@dnd-kit/utilities": "3.2.2",
"@grafana/data": "^11.6.14",
"@grafana/data": "^11.6.15",
"@monaco-editor/react": "^4.7.0",
"@sentry/react": "10.57.0",
"@sentry/vite-plugin": "5.3.0",
@@ -79,7 +79,7 @@
"event-source-polyfill": "1.0.31",
"eventemitter3": "5.0.1",
"history": "4.10.1",
"http-proxy-middleware": "4.0.0",
"http-proxy-middleware": "4.1.1",
"http-status-codes": "2.3.0",
"i18next": "^21.6.12",
"i18next-browser-languagedetector": "^6.1.3",
@@ -231,16 +231,17 @@
"xml2js": "0.5.0",
"phin": "^3.7.1",
"body-parser": "1.20.3",
"http-proxy-middleware": "4.0.0",
"http-proxy-middleware": "4.1.1",
"cross-spawn": "7.0.5",
"cookie": "^0.7.1",
"serialize-javascript": "6.0.2",
"prismjs": "1.30.0",
"got": "11.8.5",
"form-data": "4.0.4",
"form-data": "4.0.6",
"brace-expansion": "^2.0.2",
"on-headers": "^1.1.0",
"tmp": "0.2.4",
"js-cookie": "^3.0.7",
"tmp": "0.2.7",
"vite": "npm:rolldown-vite@7.3.1"
}
}

View File

@@ -12,16 +12,17 @@ overrides:
xml2js: 0.5.0
phin: ^3.7.1
body-parser: 1.20.3
http-proxy-middleware: 4.0.0
http-proxy-middleware: 4.1.1
cross-spawn: 7.0.5
cookie: ^0.7.1
serialize-javascript: 6.0.2
prismjs: 1.30.0
got: 11.8.5
form-data: 4.0.4
form-data: 4.0.6
brace-expansion: ^2.0.2
on-headers: ^1.1.0
tmp: 0.2.4
js-cookie: ^3.0.7
tmp: 0.2.7
vite: npm:rolldown-vite@7.3.1
importers:
@@ -56,8 +57,8 @@ importers:
specifier: 3.2.2
version: 3.2.2(react@18.2.0)
'@grafana/data':
specifier: ^11.6.14
version: 11.6.14(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
specifier: ^11.6.15
version: 11.6.15(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@monaco-editor/react':
specifier: ^4.7.0
version: 4.7.0(monaco-editor@0.55.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -164,8 +165,8 @@ importers:
specifier: 4.10.1
version: 4.10.1
http-proxy-middleware:
specifier: 4.0.0
version: 4.0.0
specifier: 4.1.1
version: 4.1.1
http-status-codes:
specifier: 2.3.0
version: 2.3.0
@@ -1636,14 +1637,14 @@ packages:
'@gerrit0/mini-shiki@3.23.0':
resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==}
'@grafana/data@11.6.14':
resolution: {integrity: sha512-Nsjq1A9m6LbsKsKvOgvAk9Wq7RGjy0V4N9d5YsSnzMwCiw/ov2wblR2bcDpy95uF8KaDTIR2Gf40nJaOYksPMA==}
'@grafana/data@11.6.15':
resolution: {integrity: sha512-q2Zbjr0N9iEGY/zKHm4Z4X5x64806E17W58y7mnvwc0MlbyGPPVulcp/rWA2Nd190mZeafZQPer9u+MaO+0HUQ==}
peerDependencies:
react: ^18.0.0
react-dom: ^18.0.0
'@grafana/schema@11.6.14':
resolution: {integrity: sha512-YTqgYekb7kiu5NEoQxKF8czJ6QIARmMkCi9cNcynHqYpcDLOv5pg5Q0QtKgiiqHjlYoEeCV6iejdB4hXxzB+VA==}
'@grafana/schema@11.6.15':
resolution: {integrity: sha512-MPIvGAp9uzkswnH6e+Fmzu+WBTqWMgbv93/8iu56gb+sjCB2LciZLz4KvrPFdw32bWCGSMAGqsML9mgmeJZtGQ==}
'@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
@@ -5167,8 +5168,8 @@ packages:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
form-data@4.0.4:
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
form-data@4.0.6:
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
engines: {node: '>= 6'}
format@0.2.2:
@@ -5381,6 +5382,10 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
hasown@2.0.4:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
hast-util-from-parse5@8.0.1:
resolution: {integrity: sha512-Er/Iixbc7IEa7r/XLtuG52zoqn/b3Xng/w6aZQ0xGVxzhw5xUFxcRqdPzP6yFi/4HBYRaifaI5fQ1RH8n0ZeOQ==}
@@ -5456,8 +5461,8 @@ packages:
resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==}
engines: {node: '>= 6'}
http-proxy-middleware@4.0.0:
resolution: {integrity: sha512-wuHwaUtmC0XzJNHqRp41zXtt5ojpHbusXGhq6781VvnjWUYPu7opmOF3eomGNujT07kEOnHWZyV9UZzKimVCKA==}
http-proxy-middleware@4.1.1:
resolution: {integrity: sha512-KX5ZofGXLFXqFAkQoOWZ+rTtaLTut7m0gyL+QzJrdejtIZ+F4bPPDoe7reISg2+v0CAz5OfVwEJEhty7X+e57g==}
engines: {node: ^22.15.0 || ^24.0.0 || >=26.0.0}
http-status-codes@2.3.0:
@@ -5467,8 +5472,8 @@ packages:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
httpxy@0.5.1:
resolution: {integrity: sha512-JPhqYiixe1A1I+MXDewWDZqeudBGU8Q9jCHYN8ML+779RQzLjTi78HBvWz4jMxUD6h2/vUL12g4q/mFM0OUw1A==}
httpxy@0.5.3:
resolution: {integrity: sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw==}
human-signals@2.1.0:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
@@ -6041,8 +6046,8 @@ packages:
js-base64@3.7.5:
resolution: {integrity: sha512-3MEt5DTINKqfScXKfJFrRbxkrnk2AxPWGBL/ycjz4dK8iqiSJ06UxD8jh8xuh6p10TX4t2+7FsBYVxxQbMg+qA==}
js-cookie@2.2.1:
resolution: {integrity: sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==}
js-cookie@3.0.8:
resolution: {integrity: sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==}
js-levenshtein@1.1.6:
resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==}
@@ -8394,8 +8399,8 @@ packages:
resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==}
engines: {node: ^20.0.0 || >=22.0.0}
tmp@0.2.4:
resolution: {integrity: sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ==}
tmp@0.2.7:
resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==}
engines: {node: '>=14.14'}
tmpl@1.0.5:
@@ -10318,10 +10323,10 @@ snapshots:
'@shikijs/types': 3.23.0
'@shikijs/vscode-textmate': 10.0.2
'@grafana/data@11.6.14(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
'@grafana/data@11.6.15(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@braintree/sanitize-url': 7.0.1
'@grafana/schema': 11.6.14
'@grafana/schema': 11.6.15
'@types/d3-interpolate': 3.0.1
'@types/string-hash': 1.1.3
d3-interpolate: 3.0.1
@@ -10347,7 +10352,7 @@ snapshots:
uplot: 1.6.31
xss: 1.0.14
'@grafana/schema@11.6.14':
'@grafana/schema@11.6.15':
dependencies:
tslib: 2.8.1
@@ -12886,7 +12891,7 @@ snapshots:
axios@1.16.0:
dependencies:
follow-redirects: 1.16.0
form-data: 4.0.4
form-data: 4.0.6
proxy-from-env: 2.1.0
transitivePeerDependencies:
- debug
@@ -13833,7 +13838,7 @@ snapshots:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.2
hasown: 2.0.4
es-toolkit@1.46.1: {}
@@ -14031,7 +14036,7 @@ snapshots:
dependencies:
chardet: 0.7.0
iconv-lite: 0.4.24
tmp: 0.2.4
tmp: 0.2.7
fast-deep-equal@3.1.3: {}
@@ -14164,12 +14169,12 @@ snapshots:
cross-spawn: 7.0.5
signal-exit: 4.1.0
form-data@4.0.4:
form-data@4.0.6:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.2
hasown: 2.0.4
mime-types: 2.1.35
format@0.2.2: {}
@@ -14248,7 +14253,7 @@ snapshots:
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.2
hasown: 2.0.4
math-intrinsics: 1.1.0
get-nonce@1.0.1: {}
@@ -14386,6 +14391,10 @@ snapshots:
dependencies:
function-bind: 1.1.2
hasown@2.0.4:
dependencies:
function-bind: 1.1.2
hast-util-from-parse5@8.0.1:
dependencies:
'@types/hast': 3.0.4
@@ -14506,10 +14515,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
http-proxy-middleware@4.0.0:
http-proxy-middleware@4.1.1:
dependencies:
debug: 4.3.4(supports-color@5.5.0)
httpxy: 0.5.1
httpxy: 0.5.3
is-glob: 4.0.3
is-plain-obj: 4.1.0
micromatch: 4.0.8
@@ -14525,7 +14534,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
httpxy@0.5.1: {}
httpxy@0.5.3: {}
human-signals@2.1.0: {}
@@ -15339,7 +15348,7 @@ snapshots:
js-base64@3.7.5: {}
js-cookie@2.2.1: {}
js-cookie@3.0.8: {}
js-levenshtein@1.1.6: {}
@@ -15367,7 +15376,7 @@ snapshots:
decimal.js: 10.6.0
domexception: 4.0.0
escodegen: 2.1.0
form-data: 4.0.4
form-data: 4.0.6
html-encoding-sniffer: 3.0.0
http-proxy-agent: 5.0.0
https-proxy-agent: 5.0.1
@@ -17336,7 +17345,7 @@ snapshots:
copy-to-clipboard: 3.3.3
fast-deep-equal: 3.1.3
fast-shallow-equal: 1.0.0
js-cookie: 2.2.1
js-cookie: 3.0.8
nano-css: 5.6.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
@@ -17355,7 +17364,7 @@ snapshots:
copy-to-clipboard: 3.3.3
fast-deep-equal: 3.1.3
fast-shallow-equal: 1.0.0
js-cookie: 2.2.1
js-cookie: 3.0.8
nano-css: 5.6.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
@@ -18103,7 +18112,7 @@ snapshots:
tinypool@2.1.0: {}
tmp@0.2.4: {}
tmp@0.2.7: {}
tmpl@1.0.5: {}

View File

@@ -55,7 +55,6 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
),
[pathname],
);
const isOldRoute = oldRoutes.indexOf(pathname) > -1;
const currentRoute = mapRoutes.get('current');
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
@@ -83,12 +82,36 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
}, [usersData?.data]);
// Handle old routes - redirect to new routes
const isOldRoute = oldRoutes.indexOf(pathname) > -1;
if (isOldRoute) {
const redirectUrl = oldNewRoutesMapping[pathname];
// TODO(H4ad): Remove this after https://github.com/SigNoz/engineering-pod/issues/5322
// A mapped target may itself carry a query string (e.g. `/alerts?tab=Channels`).
// react-router does not re-parse a `?` embedded in the `pathname` field, so split
// it out and merge with the incoming search params.
const [redirectPath, redirectSearch = ''] = redirectUrl.split('?');
const mergedParams = new URLSearchParams(location.search);
new URLSearchParams(redirectSearch).forEach((value, name) => {
mergedParams.set(name, value);
});
const search = mergedParams.toString();
return (
<Redirect
to={{
pathname: redirectUrl,
pathname: redirectPath,
search: search ? `?${search}` : '',
hash: location.hash,
}}
/>
);
}
if (pathname.startsWith('/settings/channels/edit/')) {
const channelId = pathname.replace('/settings/channels/edit/', '');
return (
<Redirect
to={{
pathname: `/alerts/channels/edit/${channelId}`,
search: location.search,
hash: location.hash,
}}

View File

@@ -73,7 +73,13 @@ const queryClient = new QueryClient({
// Component to capture current location for assertions
function LocationDisplay(): ReactElement {
const location = useLocation();
return <div data-testid="location-display">{location.pathname}</div>;
return (
<>
<div data-testid="location-display">{location.pathname}</div>
<div data-testid="location-search">{location.search}</div>
<div data-testid="location-hash">{location.hash}</div>
</>
);
}
// Helper to create mock user
@@ -1475,12 +1481,10 @@ describe('PrivateRoute', () => {
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
});
it('should not redirect VIEWER from /settings/channels/new due to route matching order (ALL_CHANNELS matches last)', () => {
// Note: This tests the ACTUAL behavior of Private.tsx route matching
// CHANNELS_NEW has path '/settings/channels/new' with permission ['ADMIN']
// ALL_CHANNELS has path '/settings/channels' with permission ['ADMIN', 'EDITOR', 'VIEWER']
// Due to non-exact matching and array order, ALL_CHANNELS matches LAST for '/settings/channels/new'
// This is a known limitation - actual permission enforcement happens in the page component
it('should redirect VIEWER from /alerts/channels/new (ADMIN only)', async () => {
// After moving channels under /alerts, CHANNELS_NEW ('/alerts/channels/new')
// is an exact, ADMIN-only route with no overlapping non-exact ALL_CHANNELS
// route to match last, so a VIEWER is now correctly redirected.
renderPrivateRoute({
initialRoute: ROUTES.CHANNELS_NEW,
appContext: {
@@ -1489,8 +1493,7 @@ describe('PrivateRoute', () => {
},
});
assertRendersChildren();
assertStaysOnRoute(ROUTES.CHANNELS_NEW);
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
});
it('should allow EDITOR to access /get-started route', () => {
@@ -1548,4 +1551,60 @@ describe('PrivateRoute', () => {
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
});
});
describe('Old channel route redirects', () => {
it.each([
['/settings/channels', '/alerts', 'tab=Channels'],
['/settings/channels/new', '/alerts/channels/new', ''],
])(
'should redirect %s to %s',
async (oldRoute, expectedPath, expectedSearch) => {
renderPrivateRoute({
initialRoute: oldRoute,
appContext: { isLoggedIn: true },
});
await waitFor(() => {
expect(screen.getByTestId('location-display')).toHaveTextContent(
expectedPath,
);
});
if (expectedSearch) {
const search = screen.getByTestId('location-search').textContent ?? '';
const params = new URLSearchParams(search);
new URLSearchParams(expectedSearch).forEach((value, name) => {
expect(params.get(name)).toBe(value);
});
} else {
expect(screen.getByTestId('location-search')).toHaveTextContent('');
}
},
);
it('should redirect dynamic channel edit route preserving the channel id', async () => {
renderPrivateRoute({
initialRoute: '/settings/channels/edit/abc123',
appContext: { isLoggedIn: true },
});
await assertRedirectsTo('/alerts/channels/edit/abc123');
});
it('should merge incoming query params with the embedded query of the target', async () => {
renderPrivateRoute({
initialRoute: '/settings/channels?foo=bar',
appContext: { isLoggedIn: true },
});
await waitFor(() => {
expect(screen.getByTestId('location-display')).toHaveTextContent('/alerts');
});
const search = screen.getByTestId('location-search').textContent ?? '';
const params = new URLSearchParams(search);
expect(params.get('tab')).toBe('Channels');
expect(params.get('foo')).toBe('bar');
});
});
});

View File

@@ -57,13 +57,6 @@ export const TraceFilter = Loadable(
() => import(/* webpackChunkName: "Trace Filter Page" */ 'pages/Trace'),
);
export const TraceDetail = Loadable(
() =>
import(
/* webpackChunkName: "TraceDetail Page" */ 'pages/TraceDetailV2/index'
),
);
export const TraceDetailOldRedirect = Loadable(
() =>
import(
@@ -142,12 +135,12 @@ export const AlertOverview = Loadable(
() => import(/* webpackChunkName: "Alert Overview" */ 'pages/AlertList'),
);
export const CreateAlertChannelAlerts = Loadable(
() => import(/* webpackChunkName: "Create Channels" */ 'pages/Settings'),
export const ChannelsNew = Loadable(
() => import(/* webpackChunkName: "Create Channels" */ 'pages/AlertList'),
);
export const AllAlertChannels = Loadable(
() => import(/* webpackChunkName: "All Channels" */ 'pages/Settings'),
export const ChannelsEdit = Loadable(
() => import(/* webpackChunkName: "Edit Channels" */ 'pages/AlertList'),
);
export const AllErrors = Loadable(

View File

@@ -5,10 +5,10 @@ import {
AIAssistantPage,
AlertHistory,
AlertOverview,
AllAlertChannels,
AllErrors,
ApiMonitoring,
CreateAlertChannelAlerts,
ChannelsEdit,
ChannelsNew,
CreateNewAlerts,
DashboardPage,
DashboardsListPage,
@@ -269,16 +269,16 @@ const routes: AppRoutes[] = [
{
path: ROUTES.CHANNELS_NEW,
exact: true,
component: CreateAlertChannelAlerts,
component: ChannelsNew,
isPrivate: true,
key: 'CHANNELS_NEW',
},
{
path: ROUTES.ALL_CHANNELS,
path: ROUTES.CHANNELS_EDIT,
exact: true,
component: AllAlertChannels,
component: ChannelsEdit,
isPrivate: true,
key: 'ALL_CHANNELS',
key: 'CHANNELS_EDIT',
},
{
path: ROUTES.ALL_ERROR,
@@ -534,6 +534,9 @@ export const oldNewRoutesMapping: Record<string, string> = {
'/messaging-queues': '/messaging-queues/overview',
'/alerts/edit': '/alerts/overview',
'/alerts/type-selection': '/alerts/new',
// TODO(H4ad): Update this after https://github.com/SigNoz/engineering-pod/issues/5322
'/settings/channels': '/alerts?tab=Channels',
'/settings/channels/new': '/alerts/channels/new',
};
export const oldRoutes = Object.keys(oldNewRoutesMapping);

View File

@@ -20,6 +20,7 @@ import type {
import type {
AuthtypesPatchableRoleDTO,
AuthtypesPostableRoleDTO,
AuthtypesUpdatableRoleDTO,
CoretypesPatchableObjectsDTO,
CreateRole201,
DeleteRolePathParameters,
@@ -31,6 +32,7 @@ import type {
PatchObjectsPathParameters,
PatchRolePathParameters,
RenderErrorResponseDTO,
UpdateRolePathParameters,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
@@ -365,6 +367,7 @@ export const invalidateGetRole = async (
/**
* This endpoint patches a role
* @deprecated
* @summary Patch role
*/
export const patchRole = (
@@ -436,6 +439,7 @@ export type PatchRoleMutationBody =
export type PatchRoleMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Patch role
*/
export const usePatchRole = <
@@ -462,6 +466,105 @@ export const usePatchRole = <
> => {
return useMutation(getPatchRoleMutationOptions(options));
};
/**
* This endpoint updates a role
* @summary Update role
*/
export const updateRole = (
{ id }: UpdateRolePathParameters,
authtypesUpdatableRoleDTO?: BodyType<AuthtypesUpdatableRoleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/roles/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: authtypesUpdatableRoleDTO,
signal,
});
};
export const getUpdateRoleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateRole>>,
TError,
{
pathParams: UpdateRolePathParameters;
data?: BodyType<AuthtypesUpdatableRoleDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateRole>>,
TError,
{
pathParams: UpdateRolePathParameters;
data?: BodyType<AuthtypesUpdatableRoleDTO>;
},
TContext
> => {
const mutationKey = ['updateRole'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateRole>>,
{
pathParams: UpdateRolePathParameters;
data?: BodyType<AuthtypesUpdatableRoleDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateRole(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof updateRole>>
>;
export type UpdateRoleMutationBody =
| BodyType<AuthtypesUpdatableRoleDTO>
| undefined;
export type UpdateRoleMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update role
*/
export const useUpdateRole = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateRole>>,
TError,
{
pathParams: UpdateRolePathParameters;
data?: BodyType<AuthtypesUpdatableRoleDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateRole>>,
TError,
{
pathParams: UpdateRolePathParameters;
data?: BodyType<AuthtypesUpdatableRoleDTO>;
},
TContext
> => {
return useMutation(getUpdateRoleMutationOptions(options));
};
/**
* Gets all objects connected to the specified role via a given relation type
* @summary Get objects for a role by relation
@@ -565,6 +668,7 @@ export const invalidateGetObjects = async (
/**
* Patches the objects connected to the specified role via a given relation type
* @deprecated
* @summary Patch objects for a role by relation
*/
export const patchObjects = (
@@ -636,6 +740,7 @@ export type PatchObjectsMutationBody =
export type PatchObjectsMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Patch objects for a role by relation
*/
export const usePatchObjects = <

View File

@@ -2224,15 +2224,31 @@ export interface AuthtypesPostableEmailPasswordSessionDTO {
password?: string;
}
export interface CoretypesObjectGroupDTO {
resource: CoretypesResourceRefDTO;
/**
* @type array
*/
selectors: string[];
}
export interface AuthtypesTransactionGroupDTO {
objectGroup: CoretypesObjectGroupDTO;
relation: AuthtypesRelationDTO;
}
export type AuthtypesTransactionGroupsDTO = AuthtypesTransactionGroupDTO[];
export interface AuthtypesPostableRoleDTO {
/**
* @type string
*/
description?: string;
description: string;
/**
* @type string
*/
name: string;
transactionGroups: AuthtypesTransactionGroupsDTO;
}
export interface AuthtypesPostableRotateTokenDTO {
@@ -2275,6 +2291,40 @@ export interface AuthtypesRoleDTO {
updatedAt?: string;
}
export interface AuthtypesRoleWithTransactionGroupsDTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
description: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
transactionGroups: AuthtypesTransactionGroupsDTO;
/**
* @type string
*/
type: string;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
}
export interface AuthtypesSessionContextDTO {
/**
* @type boolean
@@ -2295,6 +2345,14 @@ export interface AuthtypesUpdatableAuthDomainDTO {
config?: AuthtypesAuthDomainConfigDTO;
}
export interface AuthtypesUpdatableRoleDTO {
/**
* @type string
*/
description: string;
transactionGroups: AuthtypesTransactionGroupsDTO;
}
export interface AuthtypesUserRoleDTO {
/**
* @type string
@@ -3065,14 +3123,6 @@ export interface CommonJSONRefDTO {
$ref?: string;
}
export interface CoretypesObjectGroupDTO {
resource: CoretypesResourceRefDTO;
/**
* @type array
*/
selectors: string[];
}
export interface CoretypesPatchableObjectsDTO {
/**
* @type array,null
@@ -9450,6 +9500,16 @@ export type ListLLMPricingRulesParams = {
* @description undefined
*/
limit?: number;
/**
* @type string
* @description undefined
*/
q?: string;
/**
* @type boolean,null
* @description undefined
*/
isOverride?: boolean | null;
};
export type ListLLMPricingRules200 = {
@@ -9559,7 +9619,7 @@ export type GetRolePathParameters = {
id: string;
};
export type GetRole200 = {
data: AuthtypesRoleDTO;
data: AuthtypesRoleWithTransactionGroupsDTO;
/**
* @type string
*/
@@ -9569,6 +9629,9 @@ export type GetRole200 = {
export type PatchRolePathParameters = {
id: string;
};
export type UpdateRolePathParameters = {
id: string;
};
export type GetObjectsPathParameters = {
id: string;
relation: string;

View File

@@ -1,35 +0,0 @@
import { ApiV2Instance as axios } from 'api';
import { omit } from 'lodash-es';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
GetTraceV2PayloadProps,
GetTraceV2SuccessResponse,
} from 'types/api/trace/getTraceV2';
const getTraceV2 = async (
props: GetTraceV2PayloadProps,
): Promise<SuccessResponse<GetTraceV2SuccessResponse> | ErrorResponse> => {
let uncollapsedSpans = [...props.uncollapsedSpans];
if (!props.isSelectedSpanIDUnCollapsed) {
uncollapsedSpans = uncollapsedSpans.filter(
(node) => node !== props.selectedSpanId,
);
}
const postData: GetTraceV2PayloadProps = {
...props,
uncollapsedSpans,
};
const response = await axios.post<GetTraceV2SuccessResponse>(
`/traces/waterfall/${props.traceId}`,
omit(postData, 'traceId'),
);
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data,
};
};
export default getTraceV2;

View File

@@ -41,6 +41,7 @@ const getTraceV4 = async (
> & { spans: WireSpan[] | null };
// Derive 'service.name' from resource for convenience — only derived field
// todo(tech-debt): to remove use of this and to directly use service.name from resources.
const spans: SpanV3[] = (rawPayload.spans || []).map((span) => ({
...span,
'service.name': span.resource?.['service.name'] || '',

View File

@@ -274,4 +274,110 @@ describe('convertV5ResponseToLegacy', () => {
},
});
});
it('clickhouse_sql scalar keeps each value column distinct (regression: all-"A" collapse)', () => {
const scalar: ScalarData = {
columns: [
{
name: 'service.name',
queryName: 'A',
aggregationIndex: 0,
columnType: 'group',
} as unknown as ScalarData['columns'][number],
{
name: 'current_availability',
queryName: 'A',
aggregationIndex: 0,
columnType: 'aggregation',
} as unknown as ScalarData['columns'][number],
{
name: 'error_budget_remaining',
queryName: 'A',
aggregationIndex: 1,
columnType: 'aggregation',
} as unknown as ScalarData['columns'][number],
{
name: 'budget_status',
queryName: 'A',
aggregationIndex: 2,
columnType: 'group',
} as unknown as ScalarData['columns'][number],
{
name: 'total_requests',
queryName: 'A',
aggregationIndex: 4,
columnType: 'aggregation',
} as unknown as ScalarData['columns'][number],
],
data: [['kuja-api_gateway-service', 99.985, 0.985, 'Healthy ✅', 2181216]],
};
const v5Data: QueryRangeResponseV5 = {
type: 'scalar',
data: { results: [scalar] },
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0, stepIntervals: {} },
};
// A clickhouse_sql envelope contributes no aggregation metadata.
const params = makeBaseParams('scalar', [
{
type: 'clickhouse_sql',
spec: {
name: 'A',
query: 'SELECT ...',
disabled: false,
},
} as unknown as QueryRangeRequestV5['compositeQuery']['queries'][number],
]);
const input: SuccessResponse<MetricRangePayloadV5, QueryRangeRequestV5> =
makeBaseSuccess({ data: v5Data }, params);
// formatForWeb=true is the table-panel path.
const result = convertV5ResponseToLegacy(input, { A: '' }, true);
const [tableEntry] = result.payload.data.result;
// Headers keep their real names instead of collapsing to "A".
expect(tableEntry.table?.columns).toStrictEqual([
{
name: 'service.name',
queryName: 'A',
isValueColumn: false,
id: 'service.name',
},
{
name: 'current_availability',
queryName: 'A',
isValueColumn: true,
id: 'current_availability',
},
{
name: 'error_budget_remaining',
queryName: 'A',
isValueColumn: true,
id: 'error_budget_remaining',
},
{
name: 'budget_status',
queryName: 'A',
isValueColumn: false,
id: 'budget_status',
},
{
name: 'total_requests',
queryName: 'A',
isValueColumn: true,
id: 'total_requests',
},
]);
// Ids are unique, so value columns don't overwrite each other in the row.
expect(tableEntry.table?.rows?.[0]).toStrictEqual({
data: {
'service.name': 'kuja-api_gateway-service',
current_availability: 99.985,
error_budget_remaining: 0.985,
budget_status: 'Healthy ✅',
total_requests: 2181216,
},
});
});
});

View File

@@ -15,6 +15,7 @@ function getColName(
col: ScalarData['columns'][number],
legendMap: Record<string, string>,
aggregationPerQuery: Record<string, any>,
clickhouseQueryNames: Set<string>,
): string {
if (col.columnType === 'group') {
return col.name;
@@ -39,16 +40,32 @@ function getColName(
return alias || expression || col.queryName;
}
// clickhouse_sql value columns carry their real SQL alias in col.name — use
// it so each value column keeps its own header instead of collapsing onto
// the query name. Formulas/promql use placeholder names, so they fall back
// to legend || queryName.
if (clickhouseQueryNames.has(col.queryName)) {
return col.name;
}
return legend || col.queryName;
}
function getColId(
col: ScalarData['columns'][number],
aggregationPerQuery: Record<string, any>,
clickhouseQueryNames: Set<string>,
): string {
if (col.columnType === 'group') {
return col.name;
}
// clickhouse_sql value columns are keyed by their real SQL alias so multiple
// value columns stay unique instead of all collapsing onto the query name
// (which would overwrite every cell in the row with the last column's value).
if (clickhouseQueryNames.has(col.queryName)) {
return col.name;
}
const aggregation =
aggregationPerQuery?.[col.queryName]?.[col.aggregationIndex];
const expression = aggregation?.expression || '';
@@ -141,6 +158,7 @@ function convertScalarDataArrayToTable(
scalarDataArray: ScalarData[],
legendMap: Record<string, string>,
aggregationPerQuery: Record<string, any>,
clickhouseQueryNames: Set<string>,
): QueryDataV3[] {
// If no scalar data, return empty structure
@@ -166,10 +184,10 @@ function convertScalarDataArrayToTable(
// Collect columns for this specific query
const columns = scalarData?.columns?.map((col) => ({
name: getColName(col, legendMap, aggregationPerQuery),
name: getColName(col, legendMap, aggregationPerQuery, clickhouseQueryNames),
queryName: col.queryName,
isValueColumn: col.columnType === 'aggregation',
id: getColId(col, aggregationPerQuery),
id: getColId(col, aggregationPerQuery, clickhouseQueryNames),
}));
// Process rows for this specific query
@@ -177,8 +195,13 @@ function convertScalarDataArrayToTable(
const rowData: Record<string, any> = {};
scalarData?.columns?.forEach((col, colIndex) => {
const columnName = getColName(col, legendMap, aggregationPerQuery);
const columnId = getColId(col, aggregationPerQuery);
const columnName = getColName(
col,
legendMap,
aggregationPerQuery,
clickhouseQueryNames,
);
const columnId = getColId(col, aggregationPerQuery, clickhouseQueryNames);
rowData[columnId || columnName] = dataRow[colIndex];
});
@@ -202,6 +225,7 @@ function convertScalarWithFormatForWeb(
scalarDataArray: ScalarData[],
legendMap: Record<string, string>,
aggregationPerQuery: Record<string, any>,
clickhouseQueryNames: Set<string>,
): QueryDataV3[] {
if (!scalarDataArray || scalarDataArray.length === 0) {
return [];
@@ -210,13 +234,18 @@ function convertScalarWithFormatForWeb(
return scalarDataArray.map((scalarData) => {
const columns =
scalarData.columns?.map((col) => {
const colName = getColName(col, legendMap, aggregationPerQuery);
const colName = getColName(
col,
legendMap,
aggregationPerQuery,
clickhouseQueryNames,
);
return {
name: colName,
queryName: col.queryName,
isValueColumn: col.columnType === 'aggregation',
id: getColId(col, aggregationPerQuery),
id: getColId(col, aggregationPerQuery, clickhouseQueryNames),
};
}) || [];
@@ -289,6 +318,7 @@ function convertV5DataByType(
v5Data: any,
legendMap: Record<string, string>,
aggregationPerQuery: Record<string, any>,
clickhouseQueryNames: Set<string>,
): MetricRangePayloadV3['data'] {
switch (v5Data?.type) {
case 'time_series': {
@@ -307,6 +337,7 @@ function convertV5DataByType(
scalarData,
legendMap,
aggregationPerQuery,
clickhouseQueryNames,
);
return {
resultType: 'scalar',
@@ -373,6 +404,15 @@ export function convertV5ResponseToLegacy(
{} as Record<string, any>,
) || {};
// clickhouse_sql queries have no aggregation metadata; their value columns
// are named/keyed by the real SQL alias the response carries (see getColId).
const clickhouseQueryNames = new Set<string>(
(params?.compositeQuery?.queries ?? [])
.filter((query) => query.type === 'clickhouse_sql')
.map((query) => (query.spec as { name?: string })?.name)
.filter((name): name is string => !!name),
);
// If formatForWeb is true, return as-is (like existing logic)
if (formatForWeb && v5Data?.type === 'scalar') {
const scalarData = v5Data.data.results as ScalarData[];
@@ -380,6 +420,7 @@ export function convertV5ResponseToLegacy(
scalarData,
legendMap,
aggregationPerQuery,
clickhouseQueryNames,
);
return {
@@ -402,6 +443,7 @@ export function convertV5ResponseToLegacy(
v5Data,
legendMap,
aggregationPerQuery,
clickhouseQueryNames,
);
// Create legacy-compatible response structure

View File

@@ -1,25 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import { useIsDarkMode } from 'hooks/useDarkMode';
function FlamegraphImg(): JSX.Element {
const isDarkMode = useIsDarkMode();
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 3c1 3 2.5 3.5 3.5 4.5A5 5 0 0113 11a5 5 0 11-10 0c0-.3 0-.6.1-.9a2 2 0 103.3-2C4 5.5 7 3 8 3zM21 4h-8M20 14.5h-3M20 9.5h-3M21 20H4"
stroke={isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default FlamegraphImg;

View File

@@ -1,67 +1,29 @@
/* eslint-disable sonarjs/no-identical-functions */
import { Fragment, useMemo, useState } from 'react';
import { Input } from '@signozhq/ui/input';
import { Button, Skeleton } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Skeleton } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
import {
IQuickFiltersConfig,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { OPERATORS } from 'constants/antlrQueryConstants';
import {
DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQueryKeyValueSuggestions';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { cloneDeep, isArray, isFunction } from 'lodash-es';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { v4 as uuid } from 'uuid';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import CheckboxFilterHeader from './CheckboxFilterHeader';
import CheckboxValueRow from './CheckboxValueRow';
import LogsQuickFilterEmptyState from './LogsQuickFilterEmptyState';
import { isKeyMatch } from './utils';
import useActiveQueryIndex from './useActiveQueryIndex';
import useCheckboxDisclosure from './useCheckboxDisclosure';
import useCheckboxFilterActions from './useCheckboxFilterActions';
import useCheckboxFilterState from './useCheckboxFilterState';
import useCheckboxFilterValues from './useCheckboxFilterValues';
import './Checkbox.styles.scss';
const SELECTED_OPERATORS = [OPERATORS['='], 'in'];
const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'not in', 'nin'];
const SOURCES_WITH_EMPTY_STATE_ENABLED = [QuickFiltersSource.LOGS_EXPLORER];
// Sources that use backend APIs expecting short operator format (e.g., 'nin' instead of 'not in')
const SOURCES_WITH_SHORT_OPERATORS = [QuickFiltersSource.INFRA_MONITORING];
/**
* Returns the correct NOT_IN operator value based on source.
* InfraMonitoring backend expects 'nin', others expect 'not in'.
*/
function getNotInOperator(source: QuickFiltersSource): string {
if (SOURCES_WITH_SHORT_OPERATORS.includes(source)) {
return 'nin';
}
return getOperatorValue('NOT_IN');
}
function setDefaultValues(
values: string[],
trueOrFalse: boolean,
): Record<string, boolean> {
const defaultState: Record<string, boolean> = {};
values.forEach((val) => {
defaultState[val] = trueOrFalse;
});
return defaultState;
}
interface ICheckboxProps {
filter: IQuickFiltersConfig;
source: QuickFiltersSource;
@@ -72,194 +34,39 @@ interface ICheckboxProps {
export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
const { source, filter, onFilterChange } = props;
const [searchText, setSearchText] = useState<string>('');
// null = no user action, true = user opened, false = user closed
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
const [visibleItemsCount, setVisibleItemsCount] = useState<number>(10);
const activeQueryIndex = useActiveQueryIndex(source);
const {
lastUsedQuery,
currentQuery,
redirectWithQueryBuilderData,
panelType,
} = useQueryBuilder();
// Determine if we're in ListView mode
const isListView = panelType === PANEL_TYPES.LIST;
// In ListView mode, use index 0 for most sources; for TRACES_EXPLORER, use lastUsedQuery
// Otherwise use lastUsedQuery for non-ListView modes
const activeQueryIndex = useMemo(() => {
if (isListView) {
return source === QuickFiltersSource.TRACES_EXPLORER
? lastUsedQuery || 0
: 0;
}
return lastUsedQuery || 0;
}, [isListView, source, lastUsedQuery]);
// Check if this filter has active filters in the query
const isSomeFilterPresentForCurrentAttribute = useMemo(
() =>
currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items?.some(
(item) => isKeyMatch(item.key?.key, filter.attributeKey.key),
),
[currentQuery.builder.queryData, activeQueryIndex, filter.attributeKey.key],
);
// Derive isOpen from filter state + user action
const isOpen = useMemo(() => {
// If user explicitly toggled, respect that
if (userToggleState !== null) {
return userToggleState;
}
// Auto-open if this filter has active filters in the query
if (isSomeFilterPresentForCurrentAttribute) {
return true;
}
// Otherwise use default behavior (first 2 filters open)
return filter.defaultOpen;
}, [
userToggleState,
isOpen,
isSomeFilterPresentForCurrentAttribute,
filter.defaultOpen,
]);
visibleItemsCount,
onToggleOpen,
onShowMore,
} = useCheckboxDisclosure({ filter, activeQueryIndex });
const { data, isLoading } = useGetAggregateValues(
{
aggregateOperator: filter.aggregateOperator || 'noop',
dataSource: filter.dataSource || DataSource.LOGS,
aggregateAttribute: filter.aggregateAttribute || '',
attributeKey: filter.attributeKey.key,
filterAttributeKeyDataType: filter.attributeKey.dataType || DataTypes.EMPTY,
tagType: filter.attributeKey.type || '',
searchText: searchText ?? '',
},
{
enabled: isOpen && source !== QuickFiltersSource.METER_EXPLORER,
keepPreviousData: true,
},
);
const { attributeValues, isLoading } = useCheckboxFilterValues({
filter,
source,
searchText,
isOpen,
});
const { data: keyValueSuggestions, isLoading: isLoadingKeyValueSuggestions } =
useGetQueryKeyValueSuggestions({
key: filter.attributeKey.key,
signal: filter.dataSource || DataSource.LOGS,
signalSource: 'meter',
options: {
enabled: isOpen && source === QuickFiltersSource.METER_EXPLORER,
keepPreviousData: true,
},
});
const { currentFilterState, isFilterDisabled, isMultipleValuesTrueForTheKey } =
useCheckboxFilterState({ filter, attributeValues, activeQueryIndex });
const attributeValues: string[] = useMemo(() => {
const dataType = filter.attributeKey.dataType || DataTypes.String;
if (source === QuickFiltersSource.METER_EXPLORER && keyValueSuggestions) {
// Process the response data
const responseData = keyValueSuggestions?.data as any;
const values = responseData.data?.values || {};
const stringValues = values.stringValues || [];
const numberValues = values.numberValues || [];
// Generate options from string values - explicitly handle empty strings
const stringOptions = stringValues
// Strict filtering for empty string - we'll handle it as a special case if needed
.filter(
(value: string | null | undefined): value is string =>
value !== null && value !== undefined && value !== '',
);
// Generate options from number values
const numberOptions = numberValues
.filter(
(value: number | null | undefined): value is number =>
value !== null && value !== undefined,
)
.map((value: number) => value.toString());
// Combine all options and make sure we don't have duplicate labels
return [...stringOptions, ...numberOptions];
}
const key = DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY[dataType];
return (data?.payload?.[key] || []).filter(
(val) => val !== undefined && val !== null,
);
}, [data?.payload, filter.attributeKey.dataType, keyValueSuggestions, source]);
const { onChange, onClear } = useCheckboxFilterActions({
filter,
source,
attributeValues,
activeQueryIndex,
onFilterChange,
});
const setSearchTextDebounced = useDebouncedFn((...args) => {
setSearchText(args[0] as string);
}, DEBOUNCE_DELAY);
// derive the state of each filter key here in the renderer itself and keep it in sync with current query
// also we need to keep a note of last focussed query.
// eslint-disable-next-line sonarjs/cognitive-complexity
const currentFilterState = useMemo(() => {
let filterState: Record<string, boolean> = setDefaultValues(
attributeValues,
false,
);
const filterSync = currentQuery?.builder.queryData?.[
activeQueryIndex
]?.filters?.items.find((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
);
if (filterSync) {
if (SELECTED_OPERATORS.includes(filterSync.op)) {
if (isArray(filterSync.value)) {
filterSync.value.forEach((val) => {
filterState[String(val)] = true;
});
} else if (typeof filterSync.value === 'string') {
filterState[filterSync.value] = true;
} else if (typeof filterSync.value === 'boolean') {
filterState[String(filterSync.value)] = true;
} else if (typeof filterSync.value === 'number') {
filterState[String(filterSync.value)] = true;
}
} else if (NON_SELECTED_OPERATORS.includes(filterSync.op)) {
filterState = setDefaultValues(attributeValues, true);
if (isArray(filterSync.value)) {
filterSync.value.forEach((val) => {
filterState[String(val)] = false;
});
} else if (typeof filterSync.value === 'string') {
filterState[filterSync.value] = false;
} else if (typeof filterSync.value === 'boolean') {
filterState[String(filterSync.value)] = false;
} else if (typeof filterSync.value === 'number') {
filterState[String(filterSync.value)] = false;
}
}
} else {
filterState = setDefaultValues(attributeValues, true);
}
return filterState;
}, [
attributeValues,
currentQuery?.builder.queryData,
filter.attributeKey,
activeQueryIndex,
]);
// disable the filter when there are multiple entries of the same attribute key present in the filter bar
const isFilterDisabled = useMemo(
() =>
(currentQuery?.builder?.queryData?.[
activeQueryIndex
]?.filters?.items?.filter((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
)?.length || 0) > 1,
[currentQuery?.builder?.queryData, activeQueryIndex, filter.attributeKey],
);
// variable to check if the current filter has multiple values to its name in the key op value section
const isMultipleValuesTrueForTheKey =
Object.values(currentFilterState).filter((val) => val).length > 1;
// Sort checked items to the top, then unchecked items
const currentAttributeKeys = useMemo(() => {
const checkedValues = attributeValues.filter(
@@ -277,293 +84,6 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
[currentAttributeKeys, currentFilterState],
);
const handleClearFilterAttribute = (): void => {
const preparedQuery: Query = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item, idx) => ({
...item,
filter: {
expression: removeKeysFromExpression(item.filter?.expression ?? '', [
filter.attributeKey.key,
]),
},
filters: {
...item.filters,
items:
idx === activeQueryIndex
? item.filters?.items?.filter(
(fil) => !isKeyMatch(fil.key?.key, filter.attributeKey.key),
) || []
: [...(item.filters?.items || [])],
op: item.filters?.op || 'AND',
},
})),
},
};
if (onFilterChange && isFunction(onFilterChange)) {
onFilterChange(preparedQuery);
} else {
redirectWithQueryBuilderData(preparedQuery);
}
};
const onChange = (
value: string,
checked: boolean,
isOnlyOrAllClicked: boolean,
// eslint-disable-next-line sonarjs/cognitive-complexity
): void => {
const query = cloneDeep(currentQuery.builder.queryData?.[activeQueryIndex]);
// if only or all are clicked we do not need to worry about anything just override whatever we have
// by either adding a new IN operator value clause in case of ONLY or remove everything we have for ALL.
if (isOnlyOrAllClicked && query?.filters?.items) {
const isOnlyOrAll = isSomeFilterPresentForCurrentAttribute
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
? 'All'
: 'Only'
: 'Only';
query.filters.items = query.filters.items.filter(
(q) => !isKeyMatch(q.key?.key, filter.attributeKey.key),
);
if (query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(
query.filter.expression,
[filter.attributeKey.key],
);
}
if (isOnlyOrAll === 'Only') {
const newFilterItem: TagFilterItem = {
id: uuid(),
op: getOperatorValue(OPERATORS.IN),
key: filter.attributeKey,
value,
};
query.filters.items = [...query.filters.items, newFilterItem];
}
} else if (query?.filters?.items) {
if (
query.filters?.items?.some((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
)
) {
// if there is already a running filter for the current attribute key then
// we split the cases by which particular operator is present right now!
const currentFilter = query.filters?.items?.find((q) =>
isKeyMatch(q.key?.key, filter.attributeKey.key),
);
if (currentFilter) {
const runningOperator = currentFilter?.op;
switch (runningOperator) {
case 'in':
if (checked) {
// if it's an IN operator then if we are checking another value it get's added to the
// filter clause. example - key IN [value1, currentSelectedValue]
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: [...currentFilter.value, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else {
// if the current state wasn't an array we make it one and add our value
const newFilter = {
...currentFilter,
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else if (!checked) {
// if we are removing some value when the running operator is IN we filter.
// example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: currentFilter.value.filter((val) => val !== value),
};
if (newFilter.value.length === 0) {
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
} else {
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else {
// if not an array remove the whole thing altogether!
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
}
}
break;
case 'nin':
case 'not in':
// if the current running operator is NIN then when unchecking the value it gets
// added to the clause like key NIN [value1 , currentUnselectedValue]
if (!checked) {
// in case of array add the currentUnselectedValue to the list.
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: [...currentFilter.value, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else {
// in case of not an array make it one!
const newFilter = {
...currentFilter,
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else if (checked) {
// opposite of above!
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: currentFilter.value.filter((val) => val !== value),
};
if (newFilter.value.length === 0) {
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(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 (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} 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) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
}
}
break;
case '=':
if (checked) {
const newFilter = {
...currentFilter,
op: getOperatorValue(OPERATORS.IN),
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else if (!checked) {
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
}
break;
case '!=':
if (!checked) {
const newFilter = {
...currentFilter,
op: getNotInOperator(source),
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else if (checked) {
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
}
break;
default:
break;
}
}
} else {
// case - when there is no filter for the current key that means all are selected right now.
const newFilterItem: TagFilterItem = {
id: uuid(),
op: getNotInOperator(source),
key: filter.attributeKey,
value,
};
query.filters.items = [...query.filters.items, newFilterItem];
}
}
const finalQuery = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
...currentQuery.builder.queryData.map((q, idx) => {
if (idx === activeQueryIndex) {
return query;
}
return q;
}),
],
},
};
if (onFilterChange && isFunction(onFilterChange)) {
onFilterChange(finalQuery);
} else {
redirectWithQueryBuilderData(finalQuery);
}
};
const isEmptyStateWithDocsEnabled =
SOURCES_WITH_EMPTY_STATE_ENABLED.includes(source) &&
!searchText &&
@@ -571,48 +91,19 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
return (
<div className="checkbox-filter">
<section
className="filter-header-checkbox"
onClick={(): void => {
if (isOpen) {
setUserToggleState(false);
setVisibleItemsCount(10);
} else {
setUserToggleState(true);
}
}}
>
<section className="left-action">
{isOpen ? (
<ChevronDown size={13} cursor="pointer" />
) : (
<ChevronRight size={13} cursor="pointer" />
)}
<Typography.Text className="title">{filter.title}</Typography.Text>
<CheckboxFilterHeader
title={filter.title}
isOpen={isOpen}
showClearAll={!!attributeValues.length}
onToggleOpen={onToggleOpen}
onClear={onClear}
/>
{isOpen && isLoading && !attributeValues.length && (
<section className="loading">
<Skeleton paragraph={{ rows: 4 }} />
</section>
<section className="right-action">
{isOpen && !!attributeValues.length && (
<Typography.Text
className="clear-all"
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
handleClearFilterAttribute();
}}
>
Clear All
</Typography.Text>
)}
</section>
</section>
{isOpen &&
(isLoading || isLoadingKeyValueSuggestions) &&
!attributeValues.length && (
<section className="loading">
<Skeleton paragraph={{ rows: 4 }} />
</section>
)}
{isOpen && !isLoading && !isLoadingKeyValueSuggestions && (
)}
{isOpen && !isLoading && (
<>
{!isEmptyStateWithDocsEnabled && (
<section className="search">
@@ -634,48 +125,24 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
data-testid="filter-separator"
/>
)}
<div className="value">
<Checkbox
onChange={(checked): void =>
onChange(value, checked === true, false)
}
value={currentFilterState[value]}
disabled={isFilterDisabled}
className="check-box"
/>
<div
className={cx(
'checkbox-value-section',
isFilterDisabled ? 'filter-disabled' : '',
)}
onClick={(): void => {
if (isFilterDisabled) {
return;
}
onChange(value, currentFilterState[value], true);
}}
>
<div className={`${filter.title} label-${value}`} />
{filter.customRendererForValue ? (
filter.customRendererForValue(value)
) : (
<Typography.Text className="value-string" truncate={1}>
{String(value)}
</Typography.Text>
)}
<Button type="text" className="only-btn">
{isSomeFilterPresentForCurrentAttribute
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
? 'All'
: 'Only'
: 'Only'}
</Button>
<Button type="text" className="toggle-btn">
Toggle
</Button>
</div>
</div>
<CheckboxValueRow
value={value}
checked={currentFilterState[value]}
disabled={isFilterDisabled}
title={filter.title}
onlyButtonLabel={
isSomeFilterPresentForCurrentAttribute
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
? 'All'
: 'Only'
: 'Only'
}
customRendererForValue={filter.customRendererForValue}
onCheckboxChange={(checked): void => onChange(value, checked, false)}
onOnlyOrAllClick={(): void =>
onChange(value, currentFilterState[value], true)
}
/>
</Fragment>
))}
</section>
@@ -688,10 +155,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
)}
{visibleItemsCount < attributeValues?.length && (
<section className="show-more">
<Typography.Text
className="show-more-text"
onClick={(): void => setVisibleItemsCount((prev) => prev + 10)}
>
<Typography.Text className="show-more-text" onClick={onShowMore}>
Show More...
</Typography.Text>
</section>

View File

@@ -0,0 +1,47 @@
import { Typography } from '@signozhq/ui/typography';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
interface CheckboxFilterHeaderProps {
title: string;
isOpen: boolean;
showClearAll: boolean;
onToggleOpen: () => void;
onClear: () => void;
}
function CheckboxFilterHeader({
title,
isOpen,
showClearAll,
onToggleOpen,
onClear,
}: CheckboxFilterHeaderProps): JSX.Element {
return (
<section className="filter-header-checkbox" onClick={onToggleOpen}>
<section className="left-action">
{isOpen ? (
<ChevronDown size={13} cursor="pointer" />
) : (
<ChevronRight size={13} cursor="pointer" />
)}
<Typography.Text className="title">{title}</Typography.Text>
</section>
<section className="right-action">
{isOpen && showClearAll && (
<Typography.Text
className="clear-all"
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
onClear();
}}
>
Clear All
</Typography.Text>
)}
</section>
</section>
);
}
export default CheckboxFilterHeader;

View File

@@ -0,0 +1,68 @@
import { Button } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
interface CheckboxValueRowProps {
value: string;
checked: boolean;
disabled: boolean;
title: string;
onlyButtonLabel: string;
customRendererForValue?: (value: string) => JSX.Element;
onCheckboxChange: (checked: boolean) => void;
onOnlyOrAllClick: () => void;
}
function CheckboxValueRow({
value,
checked,
disabled,
title,
onlyButtonLabel,
customRendererForValue,
onCheckboxChange,
onOnlyOrAllClick,
}: CheckboxValueRowProps): JSX.Element {
return (
<div className="value">
<Checkbox
onChange={(isChecked): void => onCheckboxChange(isChecked === true)}
value={checked}
disabled={disabled}
className="check-box"
/>
<div
className={cx('checkbox-value-section', disabled ? 'filter-disabled' : '')}
onClick={(): void => {
if (disabled) {
return;
}
onOnlyOrAllClick();
}}
>
<div className={`${title} label-${value}`} />
{customRendererForValue ? (
customRendererForValue(value)
) : (
<Typography.Text className="value-string" truncate={1}>
{String(value)}
</Typography.Text>
)}
<Button type="text" className="only-btn">
{onlyButtonLabel}
</Button>
<Button type="text" className="toggle-btn">
Toggle
</Button>
</div>
</div>
);
}
CheckboxValueRow.defaultProps = {
customRendererForValue: undefined,
};
export default CheckboxValueRow;

View File

@@ -0,0 +1,417 @@
/* eslint-disable sonarjs/no-identical-functions */
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
import {
IQuickFiltersConfig,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { OPERATORS } from 'constants/antlrQueryConstants';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { cloneDeep, isArray } from 'lodash-es';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuid } from 'uuid';
import { isKeyMatch } from './utils';
export const SELECTED_OPERATORS = [OPERATORS['='], 'in'];
export const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'not in', 'nin'];
// Sources that use backend APIs expecting short operator format (e.g., 'nin' instead of 'not in')
const SOURCES_WITH_SHORT_OPERATORS = [QuickFiltersSource.INFRA_MONITORING];
/**
* Returns the correct NOT_IN operator value based on source.
* InfraMonitoring backend expects 'nin', others expect 'not in'.
*/
export function getNotInOperator(source: QuickFiltersSource): string {
if (SOURCES_WITH_SHORT_OPERATORS.includes(source)) {
return 'nin';
}
return getOperatorValue('NOT_IN');
}
function setDefaultValues(
values: string[],
trueOrFalse: boolean,
): Record<string, boolean> {
const defaultState: Record<string, boolean> = {};
values.forEach((val) => {
defaultState[val] = trueOrFalse;
});
return defaultState;
}
/**
* Derives the checked/unchecked state for each attribute value by reading the
* active filter clause for this attribute key out of the query.
*
* - No matching clause -> every value is checked (all selected).
* - IN / `=` clause -> only the listed values are checked.
* - NOT IN / `!=` clause -> every value is checked except the excluded ones.
*/
// eslint-disable-next-line sonarjs/cognitive-complexity
export function deriveCheckboxState({
attributeValues,
filterItems,
filterKey,
}: {
attributeValues: string[];
filterItems: TagFilterItem[] | undefined;
filterKey: string;
}): Record<string, boolean> {
let filterState: Record<string, boolean> = setDefaultValues(
attributeValues,
false,
);
const filterSync = filterItems?.find((item) =>
isKeyMatch(item.key?.key, filterKey),
);
if (filterSync) {
if (SELECTED_OPERATORS.includes(filterSync.op)) {
if (isArray(filterSync.value)) {
filterSync.value.forEach((val) => {
filterState[String(val)] = true;
});
} else if (typeof filterSync.value === 'string') {
filterState[filterSync.value] = true;
} else if (typeof filterSync.value === 'boolean') {
filterState[String(filterSync.value)] = true;
} else if (typeof filterSync.value === 'number') {
filterState[String(filterSync.value)] = true;
}
} else if (NON_SELECTED_OPERATORS.includes(filterSync.op)) {
filterState = setDefaultValues(attributeValues, true);
if (isArray(filterSync.value)) {
filterSync.value.forEach((val) => {
filterState[String(val)] = false;
});
} else if (typeof filterSync.value === 'string') {
filterState[filterSync.value] = false;
} else if (typeof filterSync.value === 'boolean') {
filterState[String(filterSync.value)] = false;
} else if (typeof filterSync.value === 'number') {
filterState[String(filterSync.value)] = false;
}
}
} else {
filterState = setDefaultValues(attributeValues, true);
}
return filterState;
}
/**
* Returns a new query with every clause for this attribute key removed, both
* from the structured filter items and the raw filter expression.
*/
export function clearFilterFromQuery({
currentQuery,
filter,
activeQueryIndex,
}: {
currentQuery: Query;
filter: IQuickFiltersConfig;
activeQueryIndex: number;
}): Query {
return {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item, idx) => ({
...item,
filter: {
expression: removeKeysFromExpression(item.filter?.expression ?? '', [
filter.attributeKey.key,
]),
},
filters: {
...item.filters,
items:
idx === activeQueryIndex
? item.filters?.items?.filter(
(fil) => !isKeyMatch(fil.key?.key, filter.attributeKey.key),
) || []
: [...(item.filters?.items || [])],
op: item.filters?.op || 'AND',
},
})),
},
};
}
// eslint-disable-next-line sonarjs/cognitive-complexity
export function applyCheckboxToggle({
currentQuery,
activeQueryIndex,
filter,
source,
attributeValues,
value,
checked,
isOnlyOrAllClicked,
}: {
currentQuery: Query;
activeQueryIndex: number;
filter: IQuickFiltersConfig;
source: QuickFiltersSource;
attributeValues: string[];
value: string;
checked: boolean;
isOnlyOrAllClicked: boolean;
}): Query {
const activeItems =
currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items;
const isSomeFilterPresentForCurrentAttribute = !!activeItems?.some((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
);
const currentFilterState = deriveCheckboxState({
attributeValues,
filterItems: activeItems,
filterKey: filter.attributeKey.key,
});
const isMultipleValuesTrueForTheKey =
Object.values(currentFilterState).filter((val) => val).length > 1;
const query = cloneDeep(currentQuery.builder.queryData?.[activeQueryIndex]);
// if only or all are clicked we do not need to worry about anything just override whatever we have
// by either adding a new IN operator value clause in case of ONLY or remove everything we have for ALL.
if (isOnlyOrAllClicked && query?.filters?.items) {
const isOnlyOrAll = isSomeFilterPresentForCurrentAttribute
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
? 'All'
: 'Only'
: 'Only';
query.filters.items = query.filters.items.filter(
(q) => !isKeyMatch(q.key?.key, filter.attributeKey.key),
);
if (query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(query.filter.expression, [
filter.attributeKey.key,
]);
}
if (isOnlyOrAll === 'Only') {
const newFilterItem: TagFilterItem = {
id: uuid(),
op: getOperatorValue(OPERATORS.IN),
key: filter.attributeKey,
value,
};
query.filters.items = [...query.filters.items, newFilterItem];
}
} else if (query?.filters?.items) {
if (
query.filters?.items?.some((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
)
) {
// if there is already a running filter for the current attribute key then
// we split the cases by which particular operator is present right now!
const currentFilter = query.filters?.items?.find((q) =>
isKeyMatch(q.key?.key, filter.attributeKey.key),
);
if (currentFilter) {
const runningOperator = currentFilter?.op;
switch (runningOperator) {
case 'in':
if (checked) {
// if it's an IN operator then if we are checking another value it get's added to the
// filter clause. example - key IN [value1, currentSelectedValue]
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: [...currentFilter.value, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else {
// if the current state wasn't an array we make it one and add our value
const newFilter = {
...currentFilter,
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else if (!checked) {
// if we are removing some value when the running operator is IN we filter.
// example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: currentFilter.value.filter((val) => val !== value),
};
if (newFilter.value.length === 0) {
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
} else {
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else {
// if not an array remove the whole thing altogether!
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
}
}
break;
case 'nin':
case 'not in':
// if the current running operator is NIN then when unchecking the value it gets
// added to the clause like key NIN [value1 , currentUnselectedValue]
if (!checked) {
// in case of array add the currentUnselectedValue to the list.
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: [...currentFilter.value, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else {
// in case of not an array make it one!
const newFilter = {
...currentFilter,
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else if (checked) {
// opposite of above!
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: currentFilter.value.filter((val) => val !== value),
};
if (newFilter.value.length === 0) {
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(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 (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} 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) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
}
}
break;
case '=':
if (checked) {
const newFilter = {
...currentFilter,
op: getOperatorValue(OPERATORS.IN),
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else if (!checked) {
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
}
break;
case '!=':
if (!checked) {
const newFilter = {
...currentFilter,
op: getNotInOperator(source),
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else if (checked) {
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
}
break;
default:
break;
}
}
} else {
// case - when there is no filter for the current key that means all are selected right now.
const newFilterItem: TagFilterItem = {
id: uuid(),
op: getNotInOperator(source),
key: filter.attributeKey,
value,
};
query.filters.items = [...query.filters.items, newFilterItem];
}
}
return {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
...currentQuery.builder.queryData.map((q, idx) => {
if (idx === activeQueryIndex) {
return query;
}
return q;
}),
],
},
};
}

View File

@@ -0,0 +1,27 @@
import { useMemo } from 'react';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
/**
* Resolves which query-builder query index the checkbox filter reads from and
* writes to.
*
* In ListView most sources use index 0; TRACES_EXPLORER and every non-ListView
* mode track the last focused query.
*/
function useActiveQueryIndex(source: QuickFiltersSource): number {
const { lastUsedQuery, panelType } = useQueryBuilder();
const isListView = panelType === PANEL_TYPES.LIST;
return useMemo(() => {
if (isListView) {
return source === QuickFiltersSource.TRACES_EXPLORER
? lastUsedQuery || 0
: 0;
}
return lastUsedQuery || 0;
}, [isListView, source, lastUsedQuery]);
}
export default useActiveQueryIndex;

View File

@@ -0,0 +1,90 @@
import { useMemo, useState } from 'react';
import { IQuickFiltersConfig } from 'components/QuickFilters/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { isKeyMatch } from './utils';
const DEFAULT_VISIBLE_ITEMS_COUNT = 10;
interface UseCheckboxDisclosureProps {
filter: IQuickFiltersConfig;
activeQueryIndex: number;
}
interface UseCheckboxDisclosureReturn {
isOpen: boolean;
isSomeFilterPresentForCurrentAttribute: boolean;
visibleItemsCount: number;
onToggleOpen: () => void;
onShowMore: () => void;
}
/**
* Owns the open/collapsed state of a checkbox filter section and how many
* values are visible.
*
* Auto-opens when the query already has a clause for this attribute, otherwise
* falls back to `filter.defaultOpen`. An explicit user toggle always wins.
* Collapsing resets the visible count.
*/
function useCheckboxDisclosure({
filter,
activeQueryIndex,
}: UseCheckboxDisclosureProps): UseCheckboxDisclosureReturn {
const { currentQuery } = useQueryBuilder();
// null = no user action, true = user opened, false = user closed
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
const [visibleItemsCount, setVisibleItemsCount] = useState<number>(
DEFAULT_VISIBLE_ITEMS_COUNT,
);
const isSomeFilterPresentForCurrentAttribute = useMemo(
() =>
!!currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items?.some(
(item) => isKeyMatch(item.key?.key, filter.attributeKey.key),
),
[currentQuery.builder.queryData, activeQueryIndex, filter.attributeKey.key],
);
const isOpen = useMemo(() => {
// If user explicitly toggled, respect that
if (userToggleState !== null) {
return userToggleState;
}
// Auto-open if this filter has active filters in the query
if (isSomeFilterPresentForCurrentAttribute) {
return true;
}
// Otherwise use default behavior (first 2 filters open)
return filter.defaultOpen;
}, [
userToggleState,
isSomeFilterPresentForCurrentAttribute,
filter.defaultOpen,
]);
const onToggleOpen = (): void => {
if (isOpen) {
setUserToggleState(false);
setVisibleItemsCount(DEFAULT_VISIBLE_ITEMS_COUNT);
} else {
setUserToggleState(true);
}
};
const onShowMore = (): void => {
setVisibleItemsCount((prev) => prev + DEFAULT_VISIBLE_ITEMS_COUNT);
};
return {
isOpen,
isSomeFilterPresentForCurrentAttribute,
visibleItemsCount,
onToggleOpen,
onShowMore,
};
}
export default useCheckboxDisclosure;

View File

@@ -0,0 +1,78 @@
import {
IQuickFiltersConfig,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { isFunction } from 'lodash-es';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
applyCheckboxToggle,
clearFilterFromQuery,
} from './checkboxFilterQuery';
interface UseCheckboxFilterActionsProps {
filter: IQuickFiltersConfig;
source: QuickFiltersSource;
attributeValues: string[];
activeQueryIndex: number;
onFilterChange?: ((query: Query) => void) | null;
}
interface UseCheckboxFilterActionsReturn {
onChange: (
value: string,
checked: boolean,
isOnlyOrAllClicked: boolean,
) => void;
onClear: () => void;
}
/**
* Wires the pure checkbox query algebra to query-builder dispatch: the
* caller-provided `onFilterChange` when present, otherwise a URL redirect.
*/
function useCheckboxFilterActions({
filter,
source,
attributeValues,
activeQueryIndex,
onFilterChange,
}: UseCheckboxFilterActionsProps): UseCheckboxFilterActionsReturn {
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const dispatch = (query: Query): void => {
if (onFilterChange && isFunction(onFilterChange)) {
onFilterChange(query);
} else {
redirectWithQueryBuilderData(query);
}
};
const onChange = (
value: string,
checked: boolean,
isOnlyOrAllClicked: boolean,
): void => {
dispatch(
applyCheckboxToggle({
currentQuery,
activeQueryIndex,
filter,
source,
attributeValues,
value,
checked,
isOnlyOrAllClicked,
}),
);
};
const onClear = (): void => {
dispatch(clearFilterFromQuery({ currentQuery, filter, activeQueryIndex }));
};
return { onChange, onClear };
}
export default useCheckboxFilterActions;

View File

@@ -0,0 +1,71 @@
import { useMemo } from 'react';
import { IQuickFiltersConfig } from 'components/QuickFilters/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { deriveCheckboxState } from './checkboxFilterQuery';
import { isKeyMatch } from './utils';
interface UseCheckboxFilterStateProps {
filter: IQuickFiltersConfig;
attributeValues: string[];
activeQueryIndex: number;
}
interface UseCheckboxFilterStateReturn {
currentFilterState: Record<string, boolean>;
isFilterDisabled: boolean;
isMultipleValuesTrueForTheKey: boolean;
}
/**
* Reads the active query and derives the per-value checked state for this
* attribute, whether the filter is disabled (same key used more than once in
* the filter bar), and whether more than one value is currently selected.
*/
function useCheckboxFilterState({
filter,
attributeValues,
activeQueryIndex,
}: UseCheckboxFilterStateProps): UseCheckboxFilterStateReturn {
const { currentQuery } = useQueryBuilder();
// derive the state of each filter key here and keep it in sync with current query
const currentFilterState = useMemo(
() =>
deriveCheckboxState({
attributeValues,
filterItems:
currentQuery?.builder.queryData?.[activeQueryIndex]?.filters?.items,
filterKey: filter.attributeKey.key,
}),
[
attributeValues,
currentQuery?.builder.queryData,
filter.attributeKey,
activeQueryIndex,
],
);
// disable the filter when there are multiple entries of the same attribute key present in the filter bar
const isFilterDisabled = useMemo(
() =>
(currentQuery?.builder?.queryData?.[
activeQueryIndex
]?.filters?.items?.filter((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
)?.length || 0) > 1,
[currentQuery?.builder?.queryData, activeQueryIndex, filter.attributeKey],
);
// whether the current filter has multiple values to its name in the key op value section
const isMultipleValuesTrueForTheKey =
Object.values(currentFilterState).filter((val) => val).length > 1;
return {
currentFilterState,
isFilterDisabled,
isMultipleValuesTrueForTheKey,
};
}
export default useCheckboxFilterState;

View File

@@ -0,0 +1,99 @@
import { useMemo } from 'react';
import {
IQuickFiltersConfig,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY } from 'constants/queryBuilder';
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQueryKeyValueSuggestions';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
interface UseCheckboxFilterValuesProps {
filter: IQuickFiltersConfig;
source: QuickFiltersSource;
searchText: string;
isOpen: boolean;
}
interface UseCheckboxFilterValuesReturn {
attributeValues: string[];
isLoading: boolean;
}
function useCheckboxFilterValues({
filter,
source,
searchText,
isOpen,
}: UseCheckboxFilterValuesProps): UseCheckboxFilterValuesReturn {
const { data, isLoading } = useGetAggregateValues(
{
aggregateOperator: filter.aggregateOperator || 'noop',
dataSource: filter.dataSource || DataSource.LOGS,
aggregateAttribute: filter.aggregateAttribute || '',
attributeKey: filter.attributeKey.key,
filterAttributeKeyDataType: filter.attributeKey.dataType || DataTypes.EMPTY,
tagType: filter.attributeKey.type || '',
searchText: searchText ?? '',
},
{
enabled: isOpen && source !== QuickFiltersSource.METER_EXPLORER,
keepPreviousData: true,
},
);
const { data: keyValueSuggestions, isLoading: isLoadingKeyValueSuggestions } =
useGetQueryKeyValueSuggestions({
key: filter.attributeKey.key,
signal: filter.dataSource || DataSource.LOGS,
signalSource: 'meter',
options: {
enabled: isOpen && source === QuickFiltersSource.METER_EXPLORER,
keepPreviousData: true,
},
});
const attributeValues: string[] = useMemo(() => {
const dataType = filter.attributeKey.dataType || DataTypes.String;
if (source === QuickFiltersSource.METER_EXPLORER && keyValueSuggestions) {
// Process the response data
const responseData = keyValueSuggestions?.data as any;
const values = responseData.data?.values || {};
const stringValues = values.stringValues || [];
const numberValues = values.numberValues || [];
// Generate options from string values - explicitly handle empty strings
const stringOptions = stringValues
// Strict filtering for empty string - we'll handle it as a special case if needed
.filter(
(value: string | null | undefined): value is string =>
value !== null && value !== undefined && value !== '',
);
// Generate options from number values
const numberOptions = numberValues
.filter(
(value: number | null | undefined): value is number =>
value !== null && value !== undefined,
)
.map((value: number) => value.toString());
// Combine all options and make sure we don't have duplicate labels
return [...stringOptions, ...numberOptions];
}
const key = DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY[dataType];
return (data?.payload?.[key] || []).filter(
(val) => val !== undefined && val !== null,
);
}, [data?.payload, filter.attributeKey.dataType, keyValueSuggestions, source]);
return {
attributeValues,
isLoading: isLoading || isLoadingKeyValueSuggestions,
};
}
export default useCheckboxFilterValues;

View File

@@ -1,106 +0,0 @@
.span-hover-card {
.ant-popover-inner {
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 32%, transparent) 0%,
color-mix(in srgb, var(--card) 36%, transparent) 98.68%
);
padding: 12px 16px;
border: 1px solid var(--l1-border);
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 32%, transparent) 0%,
color-mix(in srgb, var(--card) 36%, transparent) 98.68%
);
backdrop-filter: blur(20px);
border-radius: 4px;
z-index: -1;
will-change: background-color, backdrop-filter;
}
}
&__title {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.5rem;
}
&__operation {
color: var(--l1-foreground);
font-size: 12px;
font-weight: 500;
line-height: 20px;
letter-spacing: 0.48px;
}
&__service {
font-size: 0.875rem;
color: var(--muted-foreground);
font-weight: 400;
}
&__error {
font-size: 0.75rem;
color: var(--danger-background);
font-weight: 500;
}
&__row {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
gap: 16px;
}
&__label {
color: var(--muted-foreground);
font-size: 12px;
font-weight: 500;
line-height: 20px;
}
&__value {
color: var(--l1-foreground);
font-size: 12px;
font-weight: 500;
line-height: 20px;
text-align: right;
}
&__relative-time {
display: flex;
align-items: center;
margin-top: 4px;
gap: 8px;
border-radius: 1px 0 0 1px;
background: linear-gradient(
90deg,
hsla(358, 75%, 59%, 0.2) 0%,
transparent 100%
);
&-icon {
width: 2px;
height: 20px;
flex-shrink: 0;
border-radius: 2px;
background: var(--danger-background);
}
}
&__relative-text {
color: var(--bg-cherry-300);
font-size: 12px;
line-height: 20px;
}
}

View File

@@ -1,103 +0,0 @@
import { ReactNode } from 'react';
import { Popover } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import dayjs from 'dayjs';
import { useTimezone } from 'providers/Timezone';
import { Span } from 'types/api/trace/getTraceV2';
import { toFixed } from 'utils/toFixed';
import './SpanHoverCard.styles.scss';
interface ITraceMetadata {
startTime: number;
endTime: number;
}
interface SpanHoverCardProps {
span: Span;
traceMetadata: ITraceMetadata;
children: ReactNode;
}
function SpanHoverCard({
span,
traceMetadata,
children,
}: SpanHoverCardProps): JSX.Element {
const duration = span.durationNano / 1e6; // Convert nanoseconds to milliseconds
const { time: formattedDuration, timeUnitName } =
convertTimeToRelevantUnit(duration);
const { timezone } = useTimezone();
// Calculate relative start time from trace start
const relativeStartTime = span.timestamp - traceMetadata.startTime;
const { time: relativeTime, timeUnitName: relativeTimeUnit } =
convertTimeToRelevantUnit(relativeStartTime);
// Format absolute start time
const startTimeFormatted = dayjs(span.timestamp)
.tz(timezone.value)
.format(DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS);
const getContent = (): JSX.Element => (
<div className="span-hover-card">
<div className="span-hover-card__row">
<Typography.Text className="span-hover-card__label">
Duration:
</Typography.Text>
<Typography.Text className="span-hover-card__value">
{toFixed(formattedDuration, 2)}
{timeUnitName}
</Typography.Text>
</div>
<div className="span-hover-card__row">
<Typography.Text className="span-hover-card__label">
Events:
</Typography.Text>
<Typography.Text className="span-hover-card__value">
{span.event?.length || 0}
</Typography.Text>
</div>
<div className="span-hover-card__row">
<Typography.Text className="span-hover-card__label">
Start time:
</Typography.Text>
<Typography.Text className="span-hover-card__value">
{startTimeFormatted}
</Typography.Text>
</div>
<div className="span-hover-card__relative-time">
<div className="span-hover-card__relative-time-icon" />
<Typography.Text className="span-hover-card__relative-text">
{toFixed(relativeTime, 2)}
{relativeTimeUnit} after trace start
</Typography.Text>
</div>
</div>
);
return (
<Popover
title={
<div className="span-hover-card__title">
<Typography.Text className="span-hover-card__operation">
{span.name}
</Typography.Text>
</div>
}
mouseEnterDelay={0.2}
content={getContent()}
trigger="hover"
rootClassName="span-hover-card"
autoAdjustOverflow
arrow={false}
>
{children}
</Popover>
);
}
export default SpanHoverCard;

View File

@@ -1,291 +0,0 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { TimezoneContextType } from 'providers/Timezone';
import { Span } from 'types/api/trace/getTraceV2';
import SpanHoverCard from '../SpanHoverCard';
// Mock timezone provider so SpanHoverCard can use useTimezone without a real context
jest.mock('providers/Timezone', () => ({
__esModule: true,
useTimezone: (): TimezoneContextType => ({
timezone: {
name: 'Coordinated Universal Time — UTC, GMT',
value: 'UTC',
offset: 'UTC',
searchIndex: 'UTC',
},
browserTimezone: {
name: 'Coordinated Universal Time — UTC, GMT',
value: 'UTC',
offset: 'UTC',
searchIndex: 'UTC',
},
updateTimezone: jest.fn(),
formatTimezoneAdjustedTimestamp: jest.fn(() => 'mock-date'),
isAdaptationEnabled: true,
setIsAdaptationEnabled: jest.fn(),
}),
}));
// Mock dayjs for testing, including timezone helpers used in timezoneUtils
jest.mock('dayjs', () => {
const mockDayjsInstance: any = {};
mockDayjsInstance.format = jest.fn((formatString: string) =>
// Match the DD_MMM_YYYY_HH_MM_SS format: 'DD MMM YYYY, HH:mm:ss'
formatString === 'DD MMM YYYY, HH:mm:ss'
? '15 Mar 2024, 14:23:45'
: 'mock-date',
);
// Support chaining: dayjs().tz(timezone).format(...) and dayjs().tz(timezone).utcOffset()
mockDayjsInstance.tz = jest.fn(() => mockDayjsInstance);
mockDayjsInstance.utcOffset = jest.fn(() => 0);
const mockDayjs = jest.fn(() => mockDayjsInstance);
Object.assign(mockDayjs, {
extend: jest.fn(),
// Support dayjs.tz.guess()
tz: { guess: jest.fn(() => 'UTC') },
});
return mockDayjs;
});
const HOVER_ELEMENT_ID = 'hover-element';
const mockSpan: Span = {
spanId: 'test-span-id',
traceId: 'test-trace-id',
rootSpanId: 'root-span-id',
parentSpanId: 'parent-span-id',
name: 'GET /api/users',
timestamp: 1679748225000000,
durationNano: 150000000,
serviceName: 'user-service',
kind: 1,
hasError: false,
level: 1,
references: [],
tagMap: {},
event: [
{
name: 'event1',
timeUnixNano: 1679748225100000,
attributeMap: {},
isError: false,
},
{
name: 'event2',
timeUnixNano: 1679748225200000,
attributeMap: {},
isError: false,
},
],
rootName: 'root-span',
statusMessage: '',
statusCodeString: 'OK',
spanKind: 'server',
hasChildren: false,
hasSibling: false,
subTreeNodeCount: 1,
};
const mockTraceMetadata = {
startTime: 1679748225000000,
endTime: 1679748226000000,
};
describe('SpanHoverCard', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('renders child element correctly', () => {
render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid="child-element">Hover me</div>
</SpanHoverCard>,
);
expect(screen.getByTestId('child-element')).toBeInTheDocument();
expect(screen.getByText('Hover me')).toBeInTheDocument();
});
it('shows popover after 0.2 second delay on hover', async () => {
render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Hover for details</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Hover over the element
fireEvent.mouseEnter(hoverElement);
// Popover should NOT appear immediately
expect(screen.queryByText('Duration:')).not.toBeInTheDocument();
// Advance time by 0.5 seconds
act(() => {
jest.advanceTimersByTime(200);
});
// Now popover should appear
expect(screen.getByText('Duration:')).toBeInTheDocument();
});
it('does not show popover if hover is too brief', async () => {
render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Quick hover test</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Quick hover and unhover (less than the 0.2s delay)
fireEvent.mouseEnter(hoverElement);
act(() => {
jest.advanceTimersByTime(100); // Only 0.1 seconds
});
fireEvent.mouseLeave(hoverElement);
// Advance past the full delay
act(() => {
jest.advanceTimersByTime(400);
});
// Popover should not appear
expect(screen.queryByText('Duration:')).not.toBeInTheDocument();
});
it('displays span information in popover content after delay', async () => {
render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Test span</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Hover and wait for popover
fireEvent.mouseEnter(hoverElement);
act(() => {
jest.advanceTimersByTime(500);
});
// Check that popover shows span operation name in title
expect(screen.getByText('GET /api/users')).toBeInTheDocument();
// Check duration information
expect(screen.getByText('Duration:')).toBeInTheDocument();
expect(screen.getByText('150ms')).toBeInTheDocument();
// Check events count
expect(screen.getByText('Events:')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
// Check start time label
expect(screen.getByText('Start time:')).toBeInTheDocument();
});
it('displays date in DD MMM YYYY, HH:mm:ss format with seconds', async () => {
render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Date format test</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Hover and wait for popover
fireEvent.mouseEnter(hoverElement);
act(() => {
jest.advanceTimersByTime(500);
});
// Verify the DD MMM YYYY, HH:mm:ss format is displayed
expect(screen.getByText('15 Mar 2024, 14:23:45')).toBeInTheDocument();
});
it('displays relative time information', async () => {
const spanWithRelativeTime: Span = {
...mockSpan,
timestamp: mockTraceMetadata.startTime + 1000000, // 1 second later
};
render(
<SpanHoverCard span={spanWithRelativeTime} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Relative time test</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Hover and wait for popover
fireEvent.mouseEnter(hoverElement);
act(() => {
jest.advanceTimersByTime(500);
});
// Check relative time display
expect(screen.getByText(/after trace start/)).toBeInTheDocument();
});
it('handles spans with no events correctly', async () => {
const spanWithoutEvents: Span = {
...mockSpan,
event: [],
};
render(
<SpanHoverCard span={spanWithoutEvents} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>No events test</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Hover and wait for popover
fireEvent.mouseEnter(hoverElement);
act(() => {
jest.advanceTimersByTime(500);
});
expect(screen.getByText('Events:')).toBeInTheDocument();
expect(screen.getByText('0')).toBeInTheDocument();
});
it('verifies mouseEnterDelay prop is set to 0.5', () => {
const { container } = render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Delay test</div>
</SpanHoverCard>,
);
// The mouseEnterDelay prop should be set on the Popover component
// This test verifies the implementation includes the delay
const popover = container.querySelector('.ant-popover');
expect(popover).not.toBeInTheDocument(); // Initially not visible
// Hover to trigger delay mechanism
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
fireEvent.mouseEnter(hoverElement);
// Should not appear before delay
expect(screen.queryByText('Duration:')).not.toBeInTheDocument();
// Should appear after delay
act(() => {
jest.advanceTimersByTime(500);
});
expect(screen.getByText('Duration:')).toBeInTheDocument();
});
});

View File

@@ -43,4 +43,5 @@ export enum LOCALSTORAGE {
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
ACTIVE_SIGNOZ_INSTANCE_URL = 'ACTIVE_SIGNOZ_INSTANCE_URL',
DASHBOARDS_LIST_VISIBLE_COLUMNS = 'DASHBOARDS_LIST_VISIBLE_COLUMNS',
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
}

View File

@@ -29,9 +29,10 @@ const ROUTES = {
ALERTS_NEW: '/alerts/new',
ALERT_HISTORY: '/alerts/history',
ALERT_OVERVIEW: '/alerts/overview',
ALL_CHANNELS: '/settings/channels',
CHANNELS_NEW: '/settings/channels/new',
CHANNELS_EDIT: '/settings/channels/edit/:channelId',
// TODO(H4ad): Add test to forbidden ? in this map after https://github.com/SigNoz/engineering-pod/issues/5322
ALL_CHANNELS: '/alerts?tab=Channels',
CHANNELS_NEW: '/alerts/channels/new',
CHANNELS_EDIT: '/alerts/channels/edit/:channelId',
ALL_ERROR: '/exceptions',
ERROR_DETAIL: '/error-detail',
VERSION: '/status',

View File

@@ -94,7 +94,7 @@ describe('resourceRoute', () => {
it('routes channels to the edit page', () => {
expect(resourceRoute(ResourceType.channel, 'channel-uuid-1')).toBe(
'/settings/channels/edit/channel-uuid-1',
'/alerts/channels/edit/channel-uuid-1',
);
});
});

View File

@@ -1,4 +1,4 @@
.alert-channels-container {
width: 90%;
margin: 12px auto;
width: 100%;
padding: 0 var(--spacing-8);
}

View File

@@ -1,7 +1,5 @@
.create-alert-channels-container {
width: 90%;
margin: 12px auto;
width: 100%;
border: 1px solid var(--l1-border);
background: var(--l2-background);
border-radius: 3px;

View File

@@ -1,108 +0,0 @@
.flamegraph {
display: flex;
height: 30vh;
border-bottom: 1px solid var(--l1-border);
.flamegraph-chart {
padding: 15px;
.loading-skeleton {
justify-content: center;
align-items: center;
}
}
.flamegraph-stats {
display: flex;
flex-direction: column;
border-right: 1px solid var(--l1-border);
overflow-y: auto;
overflow-x: hidden;
padding: 16px 20px;
.exec-time-service {
display: flex;
height: 30px;
flex-shrink: 0;
justify-content: center;
align-items: center;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border);
background: var(--l1-border);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
margin-bottom: 16px;
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
letter-spacing: -0.06px;
}
.stats {
display: flex;
flex-direction: column;
gap: 12px;
overflow-y: auto;
overflow-x: hidden;
&::-webkit-scrollbar {
width: 0rem;
}
.value-row {
display: flex;
justify-content: space-between;
.service-name {
display: flex;
align-items: center;
gap: 8px;
width: 80%;
.service-text {
color: var(--l2-foreground);
font-family: 'Inter';
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
width: 80%;
}
.square-box {
height: 8px;
width: 8px;
}
}
.progress-service {
display: flex;
align-items: center;
width: 100px;
gap: 8px;
justify-content: flex-start;
flex-shrink: 0;
.service-progress-indicator {
width: fit-content;
--progress-width: 30px;
}
.percent-value {
color: var(--l1-foreground);
text-align: right;
font-family: 'Inter';
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: normal;
letter-spacing: 0.48px;
font-variant-numeric: lining-nums tabular-nums slashed-zero;
}
}
}
}
}
}

View File

@@ -1,186 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Skeleton, Tooltip } from 'antd';
import { Progress } from '@signozhq/ui/progress';
import { Typography } from '@signozhq/ui/typography';
import { AxiosError } from 'axios';
import Spinner from 'components/Spinner';
import { themeColors } from 'constants/theme';
import useGetTraceFlamegraph from 'hooks/trace/useGetTraceFlamegraph';
import useUrlQuery from 'hooks/useUrlQuery';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { TraceDetailFlamegraphURLProps } from 'types/api/trace/getTraceFlamegraph';
import { Span } from 'types/api/trace/getTraceV2';
import { TraceFlamegraphStates } from './constants';
import Error from './TraceFlamegraphStates/Error/Error';
import NoData from './TraceFlamegraphStates/NoData/NoData';
import Success from './TraceFlamegraphStates/Success/Success';
import './PaginatedTraceFlamegraph.styles.scss';
interface ITraceFlamegraphProps {
serviceExecTime: Record<string, number>;
startTime: number;
endTime: number;
traceFlamegraphStatsWidth: number;
selectedSpan: Span | undefined;
}
function TraceFlamegraph(props: ITraceFlamegraphProps): JSX.Element {
const {
serviceExecTime,
startTime,
endTime,
traceFlamegraphStatsWidth,
selectedSpan,
} = props;
const { id: traceId } = useParams<TraceDetailFlamegraphURLProps>();
const urlQuery = useUrlQuery();
const [firstSpanAtFetchLevel, setFirstSpanAtFetchLevel] = useState<string>(
urlQuery.get('spanId') || '',
);
useEffect(() => {
setFirstSpanAtFetchLevel(urlQuery.get('spanId') || '');
}, [urlQuery]);
const { data, isFetching, error } = useGetTraceFlamegraph({
traceId,
selectedSpanId: firstSpanAtFetchLevel,
});
// get the current state of trace flamegraph based on the API lifecycle
const traceFlamegraphState = useMemo(() => {
if (isFetching) {
if (
data &&
data.payload &&
data.payload.spans &&
data.payload.spans.length > 0
) {
return TraceFlamegraphStates.FETCHING_WITH_OLD_DATA_PRESENT;
}
return TraceFlamegraphStates.LOADING;
}
if (error) {
return TraceFlamegraphStates.ERROR;
}
if (
data &&
data.payload &&
data.payload.spans &&
data.payload.spans.length === 0
) {
return TraceFlamegraphStates.NO_DATA;
}
return TraceFlamegraphStates.SUCCESS;
}, [error, isFetching, data]);
// capture the spans from the response, since we do not need to do any manipulation on the same we will keep this as a simple constant [ memoized ]
const spans = useMemo(
() => data?.payload?.spans || [],
[data?.payload?.spans],
);
// get the content based on the current state of the trace waterfall
const getContent = useMemo(() => {
switch (traceFlamegraphState) {
case TraceFlamegraphStates.LOADING:
return (
<div className="loading-skeleton">
<Skeleton active paragraph={{ rows: 3 }} />
</div>
);
case TraceFlamegraphStates.ERROR:
return <Error error={error as AxiosError} />;
case TraceFlamegraphStates.NO_DATA:
return <NoData id={traceId} />;
case TraceFlamegraphStates.SUCCESS:
case TraceFlamegraphStates.FETCHING_WITH_OLD_DATA_PRESENT:
return (
<Success
spans={spans}
firstSpanAtFetchLevel={firstSpanAtFetchLevel}
setFirstSpanAtFetchLevel={setFirstSpanAtFetchLevel}
traceMetadata={{
startTime: data?.payload?.startTimestampMillis || 0,
endTime: data?.payload?.endTimestampMillis || 0,
}}
selectedSpan={selectedSpan}
/>
);
default:
return <Spinner tip="Fetching the trace!" />;
}
}, [
data?.payload?.endTimestampMillis,
data?.payload?.startTimestampMillis,
error,
firstSpanAtFetchLevel,
selectedSpan,
spans,
traceFlamegraphState,
traceId,
]);
const spread = useMemo(() => endTime - startTime, [endTime, startTime]);
return (
<div className="flamegraph">
<div
className="flamegraph-stats"
style={{ width: `${traceFlamegraphStatsWidth + 22}px` }}
>
<div className="exec-time-service">% exec time</div>
<div className="stats">
{Object.keys(serviceExecTime)
.sort((a, b) => {
if (spread <= 0) {
return 0;
}
const aValue = (serviceExecTime[a] * 100) / spread;
const bValue = (serviceExecTime[b] * 100) / spread;
return bValue - aValue;
})
.map((service) => {
const value =
spread <= 0 ? 0 : (serviceExecTime[service] * 100) / spread;
const color = generateColor(service, themeColors.traceDetailColors);
return (
<div key={service} className="value-row">
<section className="service-name">
<div className="square-box" style={{ backgroundColor: color }} />
<Tooltip title={service}>
<Typography.Text className="service-text" truncate={1}>
{service}
</Typography.Text>
</Tooltip>
</section>
<section className="progress-service">
<Progress
percent={parseFloat(value.toFixed(2))}
className="service-progress-indicator"
showInfo={false}
/>
<Typography.Text className="percent-value">
{parseFloat(value.toFixed(2))}%
</Typography.Text>
</section>
</div>
);
})}
</div>
</div>
<div
className="flamegraph-chart"
style={{ width: `calc(100% - ${traceFlamegraphStatsWidth + 22}px)` }}
>
{getContent}
</div>
</div>
);
}
export default TraceFlamegraph;

View File

@@ -1,23 +0,0 @@
.error-flamegraph {
display: flex;
gap: 4px;
flex-direction: column;
justify-content: center;
align-items: center;
height: 15vh;
.error-flamegraph-img {
height: 32px;
width: 32px;
}
.no-data-text {
color: var(--muted-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
}
}

View File

@@ -1,32 +0,0 @@
import { Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { AxiosError } from 'axios';
import noDataUrl from '@/assets/Icons/no-data.svg';
import './Error.styles.scss';
interface IErrorProps {
error: AxiosError;
}
function Error(props: IErrorProps): JSX.Element {
const { error } = props;
return (
<div className="error-flamegraph">
<img
src={noDataUrl}
alt="error-flamegraph"
className="error-flamegraph-img"
/>
<Tooltip title={error?.message}>
<Typography.Text className="no-data-text">
{error?.message || 'Something went wrong!'}
</Typography.Text>
</Tooltip>
</div>
);
}
export default Error;

View File

@@ -1,12 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
interface INoDataProps {
id: string;
}
function NoData(props: INoDataProps): JSX.Element {
const { id } = props;
return <Typography.Text>No Trace found with the id: {id} </Typography.Text>;
}
export default NoData;

View File

@@ -1,49 +0,0 @@
.trace-flamegraph {
height: 90%;
overflow-x: hidden;
overflow-y: auto;
.trace-flamegraph-virtuoso {
overflow-x: hidden;
.flamegraph-row {
display: flex;
align-items: center;
height: 18px;
padding-bottom: 6px;
.span-item {
position: absolute;
height: 12px;
background-color: yellow;
border-radius: 6px;
cursor: pointer;
.event-dot {
position: absolute;
top: 50%;
transform: translate(-50%, -50%) rotate(45deg);
width: 6px;
height: 6px;
background-color: var(--primary-background);
border: 1px solid var(--bg-robin-600);
cursor: pointer;
z-index: 1;
&.error {
background-color: var(--danger-background);
border-color: var(--bg-cherry-600);
}
&:hover {
transform: translate(-50%, -50%) rotate(45deg) scale(1.5);
}
}
}
}
&::-webkit-scrollbar {
width: 0rem;
}
}
}

View File

@@ -1,178 +0,0 @@
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { ListRange, Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { Tooltip } from 'antd';
import Color from 'color';
import TimelineV2 from 'components/TimelineV2/TimelineV2';
import { themeColors } from 'constants/theme';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { Span } from 'types/api/trace/getTraceV2';
import { toFixed } from 'utils/toFixed';
import './Success.styles.scss';
interface ITraceMetadata {
startTime: number;
endTime: number;
}
interface ISuccessProps {
spans: FlamegraphSpan[][];
firstSpanAtFetchLevel: string;
setFirstSpanAtFetchLevel: Dispatch<SetStateAction<string>>;
traceMetadata: ITraceMetadata;
selectedSpan: Span | undefined;
}
function Success(props: ISuccessProps): JSX.Element {
const {
spans,
setFirstSpanAtFetchLevel,
traceMetadata,
firstSpanAtFetchLevel,
selectedSpan,
} = props;
const { search } = useLocation();
const history = useHistory();
const isDarkMode = useIsDarkMode();
const virtuosoRef = useRef<VirtuosoHandle>(null);
const [hoveredSpanId, setHoveredSpanId] = useState<string>('');
const renderSpanLevel = useCallback(
(_: number, spans: FlamegraphSpan[]): JSX.Element => (
<div className="flamegraph-row">
{spans.map((span) => {
const spread = traceMetadata.endTime - traceMetadata.startTime;
const leftOffset =
((span.timestamp - traceMetadata.startTime) * 100) / spread;
let width = ((span.durationNano / 1e6) * 100) / spread;
if (width > 100) {
width = 100;
}
const toolTipText = `${span.name}`;
const searchParams = new URLSearchParams(search);
let color = generateColor(span.serviceName, themeColors.traceDetailColors);
const selectedSpanColor = isDarkMode
? Color(color).lighten(0.7)
: Color(color).darken(0.7);
if (span.hasError) {
color = `var(--danger-background)`;
}
return (
<Tooltip title={toolTipText} key={span.spanId}>
<div
className="span-item"
style={{
left: `${leftOffset}%`,
width: `${width}%`,
backgroundColor:
selectedSpan?.spanId === span.spanId || hoveredSpanId === span.spanId
? `${selectedSpanColor}`
: color,
}}
onMouseEnter={(): void => setHoveredSpanId(span.spanId)}
onMouseLeave={(): void => setHoveredSpanId('')}
onClick={(event): void => {
event.stopPropagation();
event.preventDefault();
searchParams.set('spanId', span.spanId);
history.replace({ search: searchParams.toString() });
}}
>
{span.event?.map((event) => {
const eventTimeMs = event.timeUnixNano / 1e6;
const eventOffsetPercent =
((eventTimeMs - span.timestamp) / (span.durationNano / 1e6)) * 100;
const clampedOffset = Math.max(1, Math.min(eventOffsetPercent, 99));
const { isError } = event;
const { time, timeUnitName } = convertTimeToRelevantUnit(
eventTimeMs - span.timestamp,
);
return (
<Tooltip
key={`${span.spanId}-event-${event.name}-${event.timeUnixNano}`}
title={`${event.name} @ ${toFixed(time, 2)} ${timeUnitName}`}
>
<div
className={`event-dot ${isError ? 'error' : ''}`}
style={{
left: `${clampedOffset}%`,
}}
/>
</Tooltip>
);
})}
</div>
</Tooltip>
);
})}
</div>
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[traceMetadata.endTime, traceMetadata.startTime, selectedSpan, hoveredSpanId],
);
const handleRangeChanged = useCallback(
(range: ListRange) => {
// if there are less than 50 levels on any load that means a single API call is sufficient
if (spans.length < 50) {
return;
}
const { startIndex, endIndex } = range;
if (startIndex === 0 && spans[0][0].level !== 0) {
setFirstSpanAtFetchLevel(spans[0][0].spanId);
}
if (endIndex === spans.length - 1) {
setFirstSpanAtFetchLevel(spans[spans.length - 1][0].spanId);
}
},
[setFirstSpanAtFetchLevel, spans],
);
useEffect(() => {
const index = spans.findIndex(
(span) => span[0].spanId === firstSpanAtFetchLevel,
);
virtuosoRef.current?.scrollToIndex({
index,
behavior: 'auto',
});
}, [firstSpanAtFetchLevel, spans]);
return (
<>
<div className="trace-flamegraph">
<Virtuoso
ref={virtuosoRef}
className="trace-flamegraph-virtuoso"
data={spans}
itemContent={renderSpanLevel}
rangeChanged={handleRangeChanged}
/>
</div>
<TimelineV2
startTimestamp={traceMetadata.startTime}
endTimestamp={traceMetadata.endTime}
timelineHeight={22}
/>
</>
);
}
export default Success;

View File

@@ -1,7 +0,0 @@
export enum TraceFlamegraphStates {
LOADING = 'LOADING',
SUCCESS = 'SUCCESS',
NO_DATA = 'NO_DATA',
ERROR = 'ERROR',
FETCHING_WITH_OLD_DATA_PRESENT = 'FETCHING_WTIH_OLD_DATA_PRESENT',
}

View File

@@ -116,7 +116,8 @@ function CreateRoleModal({
} else {
const data: AuthtypesPostableRoleDTO = {
name: values.name,
...(values.description ? { description: values.description } : {}),
description: values.description || '',
transactionGroups: [],
};
createRole({ data });
}

View File

@@ -80,7 +80,7 @@ import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
import { useCmdK } from '../../providers/cmdKProvider';
import { routeConfig } from './config';
import { getQueryString } from './helper';
import { buildNavUrl, getQueryString } from './helper';
import {
defaultMoreMenuItems,
getUserSettingsDropdownMenuItems,
@@ -486,12 +486,13 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
const availableParams = routeConfig[key];
const queryString = getQueryString(availableParams || [], params);
const url = buildNavUrl(key, queryString);
if (pathname !== key) {
if (event && isModifierKeyPressed(event)) {
openInNewTab(`${key}?${queryString.join('&')}`);
openInNewTab(url);
} else {
history.push(`${key}?${queryString.join('&')}`, {
history.push(url, {
from: pathname,
});
}

View File

@@ -8,3 +8,14 @@ export const getQueryString = (
}
return '';
});
/**
* @deprecated This should be removed after https://github.com/SigNoz/engineering-pod/issues/5322 is done
*/
export const buildNavUrl = (key: string, queryString: string[]): string => {
if (key.includes('?')) {
const extra = queryString.filter(Boolean).join('&');
return extra ? `${key}&${extra}` : key;
}
return `${key}?${queryString.join('&')}`;
};

View File

@@ -337,6 +337,7 @@ export const settingsNavSections: SettingsNavSection[] = [
isEnabled: true,
itemKey: 'account',
},
// TODO(@SigNoz/pulse-frontend): https://github.com/SigNoz/engineering-pod/issues/5323
{
key: ROUTES.ALL_CHANNELS,
label: 'Notification Channels',

View File

@@ -1,202 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { Button, Popover, Spin, Tooltip } from 'antd';
import GroupByIcon from 'assets/CustomIcons/GroupByIcon';
import cx from 'classnames';
import { OPERATORS } from 'constants/antlrQueryConstants';
import { useTraceActions } from 'hooks/trace/useTraceActions';
import {
ArrowDownToDot,
ArrowUpFromDot,
Copy,
Ellipsis,
Pin,
} from '@signozhq/icons';
interface AttributeRecord {
field: string;
value: string;
}
interface AttributeActionsProps {
record: AttributeRecord;
isPinned?: boolean;
onTogglePin?: (fieldKey: string) => void;
showPinned?: boolean;
showCopyOptions?: boolean;
}
export default function AttributeActions({
record,
isPinned,
onTogglePin,
showPinned = true,
showCopyOptions = true,
}: AttributeActionsProps): JSX.Element {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [isFilterInLoading, setIsFilterInLoading] = useState<boolean>(false);
const [isFilterOutLoading, setIsFilterOutLoading] = useState<boolean>(false);
const { onAddToQuery, onGroupByAttribute, onCopyFieldName, onCopyFieldValue } =
useTraceActions();
const textToCopy = useMemo(() => {
const str = record.value == null ? '' : String(record.value);
// Remove surrounding double-quotes only (e.g., JSON-encoded string values)
return str.replace(/^"|"$/g, '');
}, [record.value]);
const handleFilterIn = useCallback(async (): Promise<void> => {
if (!onAddToQuery || isFilterInLoading) {
return;
}
setIsFilterInLoading(true);
try {
await Promise.resolve(
onAddToQuery(record.field, record.value, OPERATORS['=']),
);
} finally {
setIsFilterInLoading(false);
}
}, [onAddToQuery, record.field, record.value, isFilterInLoading]);
const handleFilterOut = useCallback(async (): Promise<void> => {
if (!onAddToQuery || isFilterOutLoading) {
return;
}
setIsFilterOutLoading(true);
try {
await Promise.resolve(
onAddToQuery(record.field, record.value, OPERATORS['!=']),
);
} finally {
setIsFilterOutLoading(false);
}
}, [onAddToQuery, record.field, record.value, isFilterOutLoading]);
const handleGroupBy = useCallback((): void => {
if (onGroupByAttribute) {
onGroupByAttribute(record.field);
}
setIsOpen(false);
}, [onGroupByAttribute, record.field]);
const handleCopyFieldName = useCallback((): void => {
if (onCopyFieldName) {
onCopyFieldName(record.field);
}
setIsOpen(false);
}, [onCopyFieldName, record.field]);
const handleCopyFieldValue = useCallback((): void => {
if (onCopyFieldValue) {
onCopyFieldValue(textToCopy);
}
setIsOpen(false);
}, [onCopyFieldValue, textToCopy]);
const handleTogglePin = useCallback((): void => {
onTogglePin?.(record.field);
}, [onTogglePin, record.field]);
const moreActionsContent = (
<div className="attribute-actions-menu">
<Button
className="group-by-clause"
type="text"
icon={<GroupByIcon />}
onClick={handleGroupBy}
block
>
Group By Attribute
</Button>
{showCopyOptions && (
<>
<Button
type="text"
icon={<Copy size={14} />}
onClick={handleCopyFieldName}
block
>
Copy Field Name
</Button>
<Button
type="text"
icon={<Copy size={14} />}
onClick={handleCopyFieldValue}
block
>
Copy Field Value
</Button>
</>
)}
</div>
);
return (
<div className={cx('action-btn', { 'action-btn--is-open': isOpen })}>
{showPinned && (
<Tooltip title={isPinned ? 'Unpin attribute' : 'Pin attribute'}>
<Button
className={`filter-btn periscope-btn ${isPinned ? 'pinned' : ''}`}
aria-label={isPinned ? 'Unpin attribute' : 'Pin attribute'}
icon={<Pin size={14} fill={isPinned ? 'currentColor' : 'none'} />}
onClick={handleTogglePin}
/>
</Tooltip>
)}
<Tooltip title="Filter for value">
<Button
className="filter-btn periscope-btn"
aria-label="Filter for value"
disabled={isFilterInLoading}
icon={
isFilterInLoading ? (
<Spin size="small" />
) : (
<ArrowDownToDot size={14} style={{ transform: 'rotate(90deg)' }} />
)
}
onClick={handleFilterIn}
/>
</Tooltip>
<Tooltip title="Filter out value">
<Button
className="filter-btn periscope-btn"
aria-label="Filter out value"
disabled={isFilterOutLoading}
icon={
isFilterOutLoading ? (
<Spin size="small" />
) : (
<ArrowUpFromDot size={14} style={{ transform: 'rotate(90deg)' }} />
)
}
onClick={handleFilterOut}
/>
</Tooltip>
<Popover
open={isOpen}
onOpenChange={setIsOpen}
arrow={false}
content={moreActionsContent}
rootClassName="attribute-actions-content"
trigger="hover"
placement="bottomLeft"
>
<Button
data-testid="attribute-actions-more"
aria-label="More attribute actions"
icon={<Ellipsis size={14} />}
className="filter-btn periscope-btn"
/>
</Popover>
</div>
);
}
AttributeActions.defaultProps = {
isPinned: false,
showPinned: true,
showCopyOptions: true,
onTogglePin: undefined,
};

View File

@@ -1,151 +0,0 @@
.attributes-corner {
display: flex;
flex-direction: column;
.no-data {
height: 400px;
justify-content: center;
align-items: center;
}
.search-input {
margin: 12px;
width: auto;
}
.attributes-container {
display: flex;
flex-direction: column;
gap: 12px;
padding-block: 12px;
.item {
display: flex;
flex-direction: column;
gap: 8px;
justify-content: flex-start;
position: relative;
padding: 2px 12px;
&:hover {
background-color: var(--l1-border);
.action-btn {
display: flex;
}
}
.item-key-wrapper {
display: flex;
align-items: center;
gap: 6px;
.pin-icon {
color: var(--bg-robin-400);
flex-shrink: 0;
}
}
.item-key {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.value-wrapper {
display: flex;
padding: 2px 8px;
align-items: center;
width: fit-content;
max-width: 100%;
gap: 8px;
border-radius: 50px;
border: 1px solid var(--l1-border);
background: var(--l1-border);
.copy-wrapper {
overflow: hidden;
}
.item-value {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: 0.56px;
}
}
.action-btn {
display: none;
&--is-open {
display: flex;
}
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
gap: 4px;
border-radius: 4px;
padding: 2px;
.filter-btn {
display: flex;
align-items: center;
border: none;
box-shadow: none;
border-radius: 2px;
background: var(--l1-border);
padding: 4px;
gap: 3px;
height: 24px;
width: 24px;
&:hover {
background: var(--l3-background);
}
}
}
}
}
.border-top {
border-top: 1px solid var(--l1-border);
}
}
.attribute-actions-menu {
display: flex;
flex-direction: column;
gap: 4px;
.ant-btn {
text-align: left;
height: auto;
padding: 6px 12px;
display: flex;
align-items: center;
gap: 8px;
&:hover {
background-color: var(--l1-border);
}
}
.group-by-clause {
color: var(--text-primary);
}
}
.attribute-actions-content {
.ant-popover-inner {
padding: 8px;
min-width: 160px;
}
}

View File

@@ -1,135 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import CopyClipboardHOC from 'components/Logs/CopyClipboardHOC';
import { flattenObject } from 'container/LogDetailedView/utils';
import { usePinnedAttributes } from 'hooks/spanDetails/usePinnedAttributes';
import { Pin } from '@signozhq/icons';
import { Span } from 'types/api/trace/getTraceV2';
import NoData from '../NoData/NoData';
import AttributeActions from './AttributeActions';
import './Attributes.styles.scss';
interface AttributeRecord {
field: string;
value: string;
}
interface IAttributesProps {
span: Span;
isSearchVisible: boolean;
shouldFocusOnToggle?: boolean;
}
function Attributes(props: IAttributesProps): JSX.Element {
const { span, isSearchVisible, shouldFocusOnToggle } = props;
const [fieldSearchInput, setFieldSearchInput] = useState<string>('');
const flattenSpanData: Record<string, string> = useMemo(
() => (span.tagMap ? flattenObject(span.tagMap) : {}),
[span],
);
const availableAttributes = useMemo(
() => Object.keys(flattenSpanData),
[flattenSpanData],
);
const { pinnedAttributes, togglePin } =
usePinnedAttributes(availableAttributes);
const sortPinnedAttributes = useCallback(
(data: AttributeRecord[]): AttributeRecord[] =>
data.sort((a, b) => {
const aIsPinned = pinnedAttributes[a.field];
const bIsPinned = pinnedAttributes[b.field];
if (aIsPinned && !bIsPinned) {
return -1;
}
if (!aIsPinned && bIsPinned) {
return 1;
}
// Within same pinning status, maintain alphabetical order
return a.field.localeCompare(b.field);
}),
[pinnedAttributes],
);
const datasource = useMemo(() => {
const filtered = Object.keys(flattenSpanData)
.filter((attribute) =>
attribute.toLowerCase().includes(fieldSearchInput.toLowerCase()),
)
.map((key) => ({ field: key, value: flattenSpanData[key] }));
return sortPinnedAttributes(filtered);
}, [flattenSpanData, fieldSearchInput, sortPinnedAttributes]);
return (
<div className="attributes-corner">
{isSearchVisible &&
(datasource.length > 0 || fieldSearchInput.length > 0) && (
<Input
autoFocus={shouldFocusOnToggle}
placeholder="Search for attribute..."
className="search-input"
value={fieldSearchInput}
onChange={(e): void => setFieldSearchInput(e.target.value)}
/>
)}
{datasource.length === 0 && fieldSearchInput.length === 0 && (
<NoData name="attributes" />
)}
<section
className={cx('attributes-container', isSearchVisible ? 'border-top' : '')}
>
{datasource
.filter((item) => !!item.value && item.value !== '-')
.map((item) => (
<div
className={cx('item', { pinned: pinnedAttributes[item.field] })}
key={`${item.field} + ${item.value}`}
>
<div className="item-key-wrapper">
<Typography.Text className="item-key" truncate={1}>
{item.field}
</Typography.Text>
{pinnedAttributes[item.field] && (
<Pin size={14} className="pin-icon" fill="currentColor" />
)}
</div>
<div className="value-wrapper">
<div className="copy-wrapper">
<CopyClipboardHOC
entityKey={item.value}
textToCopy={item.value}
tooltipText={item.value}
>
<Typography.Text className="item-value" truncate={1}>
{item.value}
</Typography.Text>
</CopyClipboardHOC>
</div>
<AttributeActions
record={item}
isPinned={pinnedAttributes[item.field]}
onTogglePin={togglePin}
/>
</div>
</div>
))}
</section>
</div>
);
}
Attributes.defaultProps = {
shouldFocusOnToggle: false,
};
export default Attributes;

View File

@@ -1,142 +0,0 @@
.events-table {
.no-events {
display: flex;
justify-content: center;
align-items: center;
height: 400px;
}
.events-container {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
.event {
.ant-collapse {
border: none;
}
.ant-collapse-content {
border-top: none;
}
.ant-collapse-item {
border-bottom: 0px;
}
.ant-collapse-content-box {
border: 1px solid var(--l1-border);
border-top: none;
}
.ant-collapse-header {
display: flex;
padding: 8px 6px;
align-items: center;
justify-content: space-between;
gap: 16px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
.ant-collapse-expand-icon {
padding-inline-start: 0px;
padding-inline-end: 0px;
}
.collapse-title {
display: flex;
align-items: center;
gap: 6px;
.diamond {
fill: var(--accent-primary);
}
}
}
.event-details {
display: flex;
flex-direction: column;
gap: 16px;
.attribute-container {
display: flex;
flex-direction: column;
gap: 8px;
.attribute-key {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.timestamp-container {
display: flex;
gap: 4px;
align-items: center;
.timestamp-text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.attribute-value {
display: flex;
padding: 2px 8px;
width: fit-content;
align-items: center;
gap: 8px;
border-radius: 50px;
border: 1px solid var(--l1-border);
background: var(--l1-border);
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
.wrapper {
display: flex;
padding: 2px 8px;
width: fit-content;
max-width: 100%;
align-items: center;
gap: 8px;
border-radius: 50px;
border: 1px solid var(--l1-border);
background: var(--l1-border);
.attribute-value {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
}
}
}
}
}

View File

@@ -1,31 +0,0 @@
.attribute-with-expandable-popover {
&__popover {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 50vw;
}
&__preview {
max-height: 40vh;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
padding: 8px;
border-radius: 4px;
}
&__expand-button {
align-self: flex-end;
display: flex;
align-items: center;
flex-grow: 0;
}
&__full-view {
max-height: 70vh;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
}

View File

@@ -1,52 +0,0 @@
.no-linked-spans {
display: flex;
justify-content: center;
align-items: center;
height: 400px;
}
.linked-spans-container {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
.item {
display: flex;
flex-direction: column;
gap: 8px;
justify-content: flex-start;
.item-key {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.value-wrapper {
display: flex;
padding: 2px 8px;
align-items: center;
width: fit-content;
max-width: 100%;
gap: 8px;
border-radius: 50px;
border: 1px solid var(--l1-border);
background: var(--l1-border);
.item-value {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: 0.56px;
}
}
}
}

View File

@@ -1,81 +0,0 @@
import { useCallback } from 'react';
import { Button, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import ROUTES from 'constants/routes';
import { formUrlParams } from 'container/TraceDetail/utils';
import { Span } from 'types/api/trace/getTraceV2';
import { withBasePath } from 'utils/basePath';
import NoData from '../NoData/NoData';
import './LinkedSpans.styles.scss';
interface LinkedSpansProps {
span: Span;
}
interface SpanReference {
traceId: string;
spanId: string;
refType: string;
}
function LinkedSpans(props: LinkedSpansProps): JSX.Element {
const { span } = props;
const getLink = useCallback((item: SpanReference): string | null => {
if (!item.traceId || !item.spanId) {
return null;
}
return withBasePath(
`${ROUTES.TRACE}/${item.traceId}${formUrlParams({
spanId: item.spanId,
levelUp: 0,
levelDown: 0,
})}`,
);
}, []);
// Filter out CHILD_OF references as they are parent-child relationships
const linkedSpans =
span.references?.filter((ref: SpanReference) => ref.refType !== 'CHILD_OF') ||
[];
if (linkedSpans.length === 0) {
return (
<div className="no-linked-spans">
<NoData name="linked spans" />
</div>
);
}
return (
<div className="linked-spans-container">
{linkedSpans.map((item: SpanReference) => {
const link = getLink(item);
return (
<div className="item" key={item.spanId}>
<Typography.Text className="item-key" truncate={1}>
Linked Span ID
</Typography.Text>
<div className="value-wrapper">
<Tooltip title={item.spanId}>
{link ? (
<Typography.Link href={link} className="item-value" truncate={1}>
{item.spanId}
</Typography.Link>
) : (
<Button type="link" className="item-value" disabled>
{item.spanId}
</Button>
)}
</Tooltip>
</div>
</div>
);
})}
</div>
);
}
export default LinkedSpans;

View File

@@ -1,20 +0,0 @@
.no-data {
display: flex;
gap: 4px;
flex-direction: column;
.no-data-img {
height: 32px;
width: 32px;
}
.no-data-text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
}
}

View File

@@ -1,687 +0,0 @@
.span-details-drawer {
display: flex;
flex-direction: column;
height: calc(100vh - 44px); //44px -> trace details top bar
border-left: 1px solid var(--l1-border);
overflow-y: auto !important;
&:not(&-docked) {
min-width: 450px;
}
&::-webkit-scrollbar {
width: 0.1rem;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
height: 48px;
padding: 12px;
border-bottom: 1px solid var(--l1-border);
.heading {
display: flex;
align-items: center;
gap: 8px;
.dot {
height: 8px;
width: 8px;
border-radius: 2px;
background: var(--danger-background);
}
.text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
}
.description {
display: flex;
flex-direction: column;
padding: 10px 0px;
.item {
padding: 8px 12px;
&,
.attribute-container {
display: flex;
flex-direction: column;
gap: 8px;
position: relative; // ensure absolutely-positioned children anchor to the row
}
// Show attribute actions on hover for hardcoded rows
.attribute-actions-wrapper {
display: none;
gap: 8px;
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
border-radius: 4px;
padding: 2px;
// style the action button group
.action-btn {
display: flex;
gap: 4px;
}
.filter-btn {
display: flex;
align-items: center;
border: none;
box-shadow: none;
border-radius: 2px;
background: var(--l1-border);
padding: 4px;
gap: 3px;
height: 24px;
width: 24px;
&:hover {
background: var(--l3-background);
}
}
}
&:hover {
background-color: var(--l1-border);
.attribute-actions-wrapper {
display: flex;
}
}
.span-name-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
.loading-spinner-container {
padding: 4px 8px;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
display: inline-flex;
}
.span-percentile-value-container {
.span-percentile-value {
color: var(--bg-sakura-400);
font-variant-numeric: lining-nums tabular-nums stacked-fractions
slashed-zero;
font-feature-settings:
'dlig' on,
'salt' on;
border-radius: 0 50px 50px 0;
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
min-width: 48px;
padding-left: 8px;
padding-right: 8px;
border-left: 1px solid var(--l1-border);
cursor: pointer;
display: inline-flex;
align-items: center;
word-break: normal;
gap: 6px;
}
&.span-percentile-value-container-open {
.span-percentile-value {
border: 1px solid var(--l1-border);
background: var(--l1-border);
}
}
}
}
.span-percentiles-container {
display: flex;
flex-direction: column;
position: relative;
fill: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 32%, transparent) 0%,
color-mix(in srgb, var(--card) 36%, transparent) 98.68%
);
stroke-width: 1px;
stroke: var(--l1-border);
filter: drop-shadow(2px 4px 16px rgba(0, 0, 0, 0.2));
backdrop-filter: blur(20px);
border: 1px solid var(--l1-border);
border-radius: 4px;
.span-percentiles-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px 8px 12px;
border-bottom: 1px solid var(--l1-border);
.span-percentiles-header-text {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
}
.span-percentile-content {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
.span-percentile-content-title {
.span-percentile-value {
color: var(--bg-sakura-400);
font-variant-numeric: lining-nums tabular-nums stacked-fractions
slashed-zero;
font-feature-settings:
'dlig' on,
'salt' on;
}
.span-percentile-value-loader {
display: inline-flex;
align-items: flex-end;
justify-content: flex-end;
margin-right: 4px;
margin-left: 4px;
line-height: 18px;
}
}
.span-percentile-timerange {
width: 100%;
.span-percentile-timerange-select {
width: 100%;
margin-top: 8px;
margin-bottom: 16px;
.ant-select-selector {
border-radius: 50px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: 0.28px;
height: 32px;
}
}
}
.span-percentile-values-table {
.span-percentile-values-table-header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
.span-percentile-values-table-header {
color: var(--l2-foreground);
text-align: right;
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 181.818% */
text-transform: uppercase;
}
}
.span-percentile-values-table-data-rows {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 4px;
.span-percentile-values-table-data-rows-skeleton {
display: flex;
flex-direction: column;
gap: 4px;
.ant-skeleton-title {
width: 100% !important;
margin-top: 0px !important;
}
.ant-skeleton-paragraph {
margin-top: 8px;
& > li + li {
margin-top: 10px;
width: 100% !important;
}
}
}
}
.span-percentile-values-table-data-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0px 4px;
.span-percentile-values-table-data-row-key {
flex: 0 0 auto;
color: var(--l1-foreground);
text-align: right;
font-variant-numeric: lining-nums tabular-nums slashed-zero;
font-feature-settings:
'dlig' on,
'salt' on;
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 166.667% */
}
.span-percentile-values-table-data-row-value {
color: var(--l2-foreground);
font-variant-numeric: lining-nums tabular-nums stacked-fractions
slashed-zero;
font-feature-settings:
'dlig' on,
'salt' on,
'ss02' on;
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 166.667% */
}
.dashed-line {
flex: 1;
height: 0; /* line only */
margin: 0 8px;
border-top: 1px dashed var(--l1-border);
/* Use border image to control dash length & spacing */
border-top-width: 1px;
border-top-style: solid; /* temporary solid for image */
border-image: repeating-linear-gradient(
to right,
#1d212d 0,
#1d212d 10px,
transparent 10px,
transparent 20px
)
1 stretch;
}
}
.current-span-percentile-row {
border-radius: 2px;
background: color-mix(
in srgb,
var(--primary-background) 20%,
transparent
);
.span-percentile-values-table-data-row-key {
color: var(--text-robin-300);
}
.dashed-line {
flex: 1;
height: 0; /* line only */
margin: 0 8px;
border-top: 1px dashed #abbdff;
/* Use border image to control dash length & spacing */
border-top-width: 1px;
border-top-style: solid; /* temporary solid for image */
border-image: repeating-linear-gradient(
to right,
#abbdff 0,
#abbdff 10px,
transparent 10px,
transparent 20px
)
1 stretch;
}
.span-percentile-values-table-data-row-value {
color: var(--text-robin-400);
}
}
}
}
.resource-attributes-select-container {
overflow: hidden;
width: calc(100% + 16px);
position: absolute;
top: 32px;
left: -8px;
z-index: 1000;
.resource-attributes-select-container-header {
.resource-attributes-select-container-input {
border-radius: 0px;
border: none !important;
box-shadow: none !important;
height: 36px;
border-bottom: 1px solid var(--l1-border) !important;
}
}
border-radius: 4px;
border: 1px solid var(--l1-border);
background: linear-gradient(139deg, var(--card) 0%, var(--card) 98.68%);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
.ant-select {
width: 100%;
}
.resource-attributes-items {
height: 200px;
overflow-y: auto;
&::-webkit-scrollbar {
width: 0.3rem;
height: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--l3-background);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--l3-background);
}
}
.resource-attributes-select-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px 8px 12px;
.resource-attributes-select-item-checkbox {
.ant-checkbox-disabled {
background-color: var(--primary-background);
color: var(--l1-foreground);
}
.resource-attributes-select-item-value {
color: var(--l1-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
}
}
}
.attribute-key {
color: var(--l2-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: 18px; /* 163.636% */
letter-spacing: 0.44px;
text-transform: uppercase;
}
.attribute-container .wrapper,
.value-wrapper {
display: flex;
align-items: center;
width: fit-content;
max-width: 100%;
border-radius: 50px;
border: 1px solid var(--l1-border);
background: var(--l1-border);
.attribute-value {
padding: 2px 8px;
color: var(--l2-foreground);
font-family: 'Inter';
font-size: 14px;
font-style: normal;
width: 100%;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: 0.28px;
}
}
.service {
display: flex;
padding: 2px 8px;
align-items: center;
gap: 8px;
border-radius: 50px;
border: 1px solid var(--l1-border);
background: var(--l1-border);
width: fit-content;
.dot {
height: 4px;
width: 4px;
}
.value-wrapper {
display: flex;
padding: 0px;
align-items: center;
width: fit-content;
max-width: 100%;
border-radius: 0px;
border: none;
background: var(--l1-border);
.service-value {
color: var(--l2-foreground);
font-family: 'Inter';
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: 0.28px;
}
}
}
.related-signals-section {
.view-title {
display: flex;
gap: 8px;
align-items: center;
font-size: 12px;
line-height: 30px;
}
.ant-btn.ant-btn-default {
padding: 0 15px;
&:not(:hover) {
border: 1px solid var(--l1-border);
}
}
}
}
}
.attributes-events {
.details-drawer-tabs {
.ant-tabs-extra-content {
display: flex;
align-items: center;
.search-icon {
width: 33px;
padding-right: 12px;
}
}
.ant-tabs-nav::before {
border-bottom: 1px solid var(--l1-border) !important;
}
.ant-tabs-tab {
margin: 0 !important;
padding: 0 2px !important;
min-width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
}
.attributes-tab-btn,
.events-tab-btn,
.linked-spans-tab-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 4px 8px;
margin-right: 8px;
gap: 4px;
.tab-label {
display: flex;
align-items: center;
}
.count-badge {
display: flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 10px;
background: color-mix(in srgb, var(--bg-robin-200) 10%, transparent);
color: var(--l2-foreground);
font-variant-numeric: lining-nums tabular-nums slashed-zero;
font-feature-settings:
'dlig' on,
'salt' on;
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.065px;
text-transform: uppercase;
}
}
.attributes-tab-btn:hover,
.events-tab-btn:hover,
.linked-spans-tab-btn:hover {
background: unset;
}
}
}
}
.span-percentile-tooltip {
.ant-tooltip-content {
width: 300px;
max-width: 300px;
}
.span-percentile-tooltip-text {
color: var(--l2-foreground);
font-variant-numeric: lining-nums tabular-nums stacked-fractions ordinal
slashed-zero;
font-feature-settings:
'dlig' on,
'salt' on;
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 166.667% */
letter-spacing: -0.06px;
.span-percentile-tooltip-text-percentile {
color: var(--text-sakura-500);
font-variant-numeric: lining-nums tabular-nums stacked-fractions slashed-zero;
font-feature-settings:
'dlig' on,
'salt' on;
font-family: Inter;
font-size: 12px;
}
.span-percentile-tooltip-text-link {
color: var(--l2-foreground);
text-align: right;
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 166.667% */
}
}
}
.span-details-drawer-docked {
width: 48px;
flex: 0 48px !important;
.header {
justify-content: center;
}
}
.resizable-handle {
box-sizing: border-box;
border: 2px solid transparent;
&:hover,
&[data-resize-handle-state='drag'],
&[data-resize-handle-state='hover'] {
border-color: color-mix(in srgb, var(--bg-aqua-500) 20%, transparent);
}
}
.linked-spans-tab-btn {
display: flex;
align-items: center;
gap: 0.5rem;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,76 +0,0 @@
.span-logs {
margin-inline: 16px;
height: 100%;
display: flex;
flex-direction: column;
&-virtuoso {
background: color-mix(in srgb, var(--bg-robin-200) 4%, transparent);
}
&-list-container {
flex: 1;
min-height: 0;
.logs-loading-skeleton {
height: 100%;
border: 1px solid var(--l1-border);
border-top: none;
color: var(--l2-foreground);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 8px 0;
}
}
&-empty-content {
height: 100%;
border-top: none;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 96px;
gap: 12px;
.description {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
width: 320px;
.no-data-img {
height: 2rem;
width: 2rem;
}
.no-data-text-1 {
color: var(--l2-foreground);
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
}
.no-data-text-2 {
font-weight: 500;
}
}
.action-section {
width: 320px;
.action-btn {
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l1-border);
color: var(--l2-foreground);
padding: 4px 8px;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
}
}

View File

@@ -1,99 +0,0 @@
.span-related-signals-drawer {
.ant-drawer-body {
padding: 0;
}
.ant-drawer-header {
border-bottom: 1px solid var(--l1-border);
padding: 16px 15px;
.title {
color: var(--l2-foreground);
font-size: 14px;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
}
}
&__divider {
--divider-vertical-margin: 10px;
}
.ant-drawer-close {
margin: 0 !important;
}
.span-related-signals-drawer__content {
height: 100%;
display: flex;
flex-direction: column;
}
.view-title {
display: flex;
align-items: center;
gap: 8px;
}
.views-tabs-container {
padding: 16px 15px;
display: flex;
align-items: center;
justify-content: space-between;
.open-in-explorer {
display: flex;
align-items: center;
height: 30px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
}
.ant-radio-button-wrapper {
width: 114px;
height: 32px;
.view-title {
gap: 6px;
color: var(--l1-foreground);
font-size: 12px;
font-weight: 400;
letter-spacing: -0.06px;
}
}
}
.span-related-signals-drawer__applied-filters {
padding: 11px;
margin-inline: 16px;
border: 1px solid var(--l1-border);
border-radius: 3px;
}
.span-related-signals-drawer__filters-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.span-related-signals-drawer__filter-tag {
padding: 2px 6px;
border-radius: 2px;
background: var(--l3-background);
cursor: default;
.ant-typography {
color: var(--l2-foreground);
font-size: 14px;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
}
}
.infra-metrics-container {
padding-inline: 16px;
.infra-metrics-card {
border: 1px solid var(--l1-border);
}
}
}

View File

@@ -1,257 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Button, Drawer } from 'antd';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import LogsIcon from 'assets/AlertHistory/LogsIcon';
import SignozRadioGroup from 'components/SignozRadioGroup/SignozRadioGroup';
import { QueryParams } from 'constants/query';
import {
initialQueryBuilderFormValuesMap,
initialQueryState,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { BarChart, Compass, X } from '@signozhq/icons';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Span } from 'types/api/trace/getTraceV2';
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
import { getAbsoluteUrl } from 'utils/basePath';
import { RelatedSignalsViews } from '../constants';
import SpanLogs from '../SpanLogs/SpanLogs';
import { useSpanContextLogs } from '../SpanLogs/useSpanContextLogs';
import { hasInfraMetadata } from '../utils';
import './SpanRelatedSignals.styles.scss';
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
interface SpanRelatedSignalsProps {
selectedSpan: Span;
traceStartTime: number;
traceEndTime: number;
isOpen: boolean;
onClose: () => void;
initialView: RelatedSignalsViews;
}
function SpanRelatedSignals({
selectedSpan,
traceStartTime,
traceEndTime,
isOpen,
onClose,
initialView,
}: SpanRelatedSignalsProps): JSX.Element {
const [selectedView, setSelectedView] =
useState<RelatedSignalsViews>(initialView);
const isDarkMode = useIsDarkMode();
// Extract infrastructure metadata from span attributes
const infraMetadata = useMemo(() => {
// Only return metadata if span has infrastructure metadata
if (!hasInfraMetadata(selectedSpan)) {
return null;
}
return {
clusterName: selectedSpan.tagMap['k8s.cluster.name'] || '',
podName: selectedSpan.tagMap['k8s.pod.name'] || '',
nodeName: selectedSpan.tagMap['k8s.node.name'] || '',
hostName: selectedSpan.tagMap['host.name'] || '',
spanTimestamp: dayjs(selectedSpan.timestamp).format(),
};
}, [selectedSpan]);
const {
logs,
isLoading,
isError,
isFetching,
isLogSpanRelated,
hasTraceIdLogs,
} = useSpanContextLogs({
traceId: selectedSpan.traceId,
spanId: selectedSpan.spanId,
timeRange: {
startTime: traceStartTime - FIVE_MINUTES_IN_MS,
endTime: traceEndTime + FIVE_MINUTES_IN_MS,
},
isDrawerOpen: isOpen,
});
const handleTabChange = useCallback((value: string): void => {
setSelectedView(value as RelatedSignalsViews);
}, []);
const tabOptions = useMemo(() => {
const baseOptions = [
{
label: (
<div className="view-title">
<LogsIcon width={14} height={14} />
Logs
</div>
),
value: RelatedSignalsViews.LOGS,
},
];
// Add Infra option if infrastructure metadata is available
if (infraMetadata) {
baseOptions.push({
label: (
<div className="view-title">
<BarChart size={14} />
Metrics
</div>
),
value: RelatedSignalsViews.INFRA,
});
}
return baseOptions;
}, [infraMetadata]);
const handleExplorerPageRedirect = useCallback((): void => {
const startTimeMs = traceStartTime - FIVE_MINUTES_IN_MS;
const endTimeMs = traceEndTime + FIVE_MINUTES_IN_MS;
const traceIdFilter = {
op: 'AND',
items: [
{
id: 'trace-id-filter',
key: {
key: 'trace_id',
id: 'trace-id-key',
dataType: 'string' as const,
isColumn: true,
type: '',
isJSON: false,
} as BaseAutocompleteData,
op: '=',
value: selectedSpan.traceId,
},
],
};
const compositeQuery = {
...initialQueryState,
queryType: 'builder',
builder: {
...initialQueryState.builder,
queryData: [
{
...initialQueryBuilderFormValuesMap.logs,
aggregateOperator: LogsAggregatorOperator.NOOP,
filters: traceIdFilter,
},
],
},
};
const searchParams = new URLSearchParams();
searchParams.set(QueryParams.compositeQuery, JSON.stringify(compositeQuery));
searchParams.set(QueryParams.startTime, startTimeMs.toString());
searchParams.set(QueryParams.endTime, endTimeMs.toString());
window.open(
getAbsoluteUrl(`${ROUTES.LOGS_EXPLORER}?${searchParams.toString()}`),
'_blank',
'noopener,noreferrer',
);
}, [selectedSpan.traceId, traceStartTime, traceEndTime]);
const emptyStateConfig = useMemo(
() => ({
...getEmptyLogsListConfig(() => {}),
showClearFiltersButton: false,
}),
[],
);
return (
<Drawer
width="50%"
title={
<>
<Divider
type="vertical"
className="span-related-signals-drawer__divider"
/>
<Typography.Text className="title">
Related Signals - {selectedSpan.name}
</Typography.Text>
</>
}
placement="right"
onClose={onClose}
open={isOpen}
style={{
overscrollBehavior: 'contain',
background: isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100,
}}
className="span-related-signals-drawer"
destroyOnClose
closeIcon={<X size={16} />}
>
{selectedSpan && (
<div className="span-related-signals-drawer__content">
<div className="views-tabs-container">
<SignozRadioGroup
value={selectedView}
options={tabOptions}
onChange={handleTabChange}
className="related-signals-radio"
/>
{selectedView === RelatedSignalsViews.LOGS && (
<Button
icon={<Compass size={18} />}
className="open-in-explorer"
onClick={handleExplorerPageRedirect}
data-testid="open-in-explorer-button"
>
Open in Logs Explorer
</Button>
)}
</div>
{selectedView === RelatedSignalsViews.LOGS && (
<SpanLogs
traceId={selectedSpan.traceId}
spanId={selectedSpan.spanId}
timeRange={{
startTime: traceStartTime - FIVE_MINUTES_IN_MS,
endTime: traceEndTime + FIVE_MINUTES_IN_MS,
}}
logs={logs}
isLoading={isLoading}
isError={isError}
isFetching={isFetching}
isLogSpanRelated={isLogSpanRelated}
handleExplorerPageRedirect={handleExplorerPageRedirect}
emptyStateConfig={!hasTraceIdLogs ? emptyStateConfig : undefined}
/>
)}
{selectedView === RelatedSignalsViews.INFRA && infraMetadata && (
<InfraMetrics
clusterName={infraMetadata.clusterName}
podName={infraMetadata.podName}
nodeName={infraMetadata.nodeName}
hostName={infraMetadata.hostName}
timestamp={infraMetadata.spanTimestamp}
dataSource={DataSource.TRACES}
/>
)}
</div>
)}
</Drawer>
);
}
export default SpanRelatedSignals;

View File

@@ -1,240 +0,0 @@
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import AttributeActions from '../Attributes/AttributeActions';
// Mock only Popover from antd to simplify hover/open behavior while keeping other components real
jest.mock('antd', () => {
const actual = jest.requireActual('antd');
const MockPopover = ({
content,
children,
open,
onOpenChange,
...rest
}: any): JSX.Element => (
<div
data-testid="mock-popover-wrapper"
onMouseEnter={(): void => onOpenChange?.(true)}
{...rest}
>
{children}
{open ? <div data-testid="mock-popover-content">{content}</div> : null}
</div>
);
return { ...actual, Popover: MockPopover };
});
// Mock getAggregateKeys API used inside useTraceActions to resolve autocomplete keys
jest.mock('api/queryBuilder/getAttributeKeys', () => ({
getAggregateKeys: jest.fn().mockResolvedValue({
payload: {
attributeKeys: [
{
key: 'http.method',
dataType: 'string',
type: 'tag',
isColumn: true,
},
],
},
}),
}));
const record = { field: 'http.method', value: 'GET' };
describe('AttributeActions (unit)', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders core action buttons (pin, filter in/out, more)', async () => {
render(<AttributeActions record={record} isPinned={false} />);
expect(
screen.getByRole('button', { name: 'Pin attribute' }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Filter for value' }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Filter out value' }),
).toBeInTheDocument();
// more actions (ellipsis) button
expect(screen.getByTestId('attribute-actions-more')).toBeInTheDocument();
});
it('applies "Filter for" and calls redirectWithQueryBuilderData with correct query', async () => {
const redirectWithQueryBuilderData = jest.fn();
const currentQuery = {
builder: {
queryData: [
{
aggregateOperator: 'count',
aggregateAttribute: { key: 'signoz_span_duration' },
filters: { items: [], op: 'AND' },
filter: { expression: '' },
groupBy: [],
},
],
},
} as any;
render(<AttributeActions record={record} />, undefined, {
queryBuilderOverrides: { currentQuery, redirectWithQueryBuilderData },
});
const filterForBtn = screen.getByRole('button', { name: 'Filter for value' });
await userEvent.click(filterForBtn);
await waitFor(() => {
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'http.method' }),
op: '=',
value: 'GET',
}),
]),
}),
}),
]),
}),
}),
{},
expect.any(String),
);
});
});
it('applies "Filter out" and calls redirectWithQueryBuilderData with correct query', async () => {
const redirectWithQueryBuilderData = jest.fn();
const currentQuery = {
builder: {
queryData: [
{
aggregateOperator: 'count',
aggregateAttribute: { key: 'signoz_span_duration' },
filters: { items: [], op: 'AND' },
filter: { expression: '' },
groupBy: [],
},
],
},
} as any;
render(<AttributeActions record={record} />, undefined, {
queryBuilderOverrides: { currentQuery, redirectWithQueryBuilderData },
});
const filterOutBtn = screen.getByRole('button', { name: 'Filter out value' });
await userEvent.click(filterOutBtn);
await waitFor(() => {
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'http.method' }),
op: '!=',
value: 'GET',
}),
]),
}),
}),
]),
}),
}),
{},
expect.any(String),
);
});
});
it('opens more actions on hover and calls Group By handler; closes after click', async () => {
const redirectWithQueryBuilderData = jest.fn();
const currentQuery = {
builder: {
queryData: [
{
aggregateOperator: 'count',
aggregateAttribute: { key: 'signoz_span_duration' },
filters: { items: [], op: 'AND' },
filter: { expression: '' },
groupBy: [],
},
],
},
} as any;
render(<AttributeActions record={record} />, undefined, {
queryBuilderOverrides: { currentQuery, redirectWithQueryBuilderData },
});
const ellipsisBtn = screen.getByTestId('attribute-actions-more');
expect(ellipsisBtn).toBeInTheDocument();
// hover to trigger Popover open via mock
fireEvent.mouseEnter(ellipsisBtn.parentElement as Element);
// content appears
await waitFor(() =>
expect(screen.getByText('Group By Attribute')).toBeInTheDocument(),
);
await userEvent.click(screen.getByText('Group By Attribute'));
await waitFor(() => {
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
groupBy: expect.arrayContaining([
expect.objectContaining({ key: 'http.method' }),
]),
}),
]),
}),
}),
{},
expect.any(String),
);
});
// After clicking group by, popover should close
await waitFor(() =>
expect(screen.queryByTestId('mock-popover-content')).not.toBeInTheDocument(),
);
});
it('hides pin button when showPinned=false', async () => {
render(<AttributeActions record={record} showPinned={false} />);
expect(
screen.queryByRole('button', { name: /pin attribute/i }),
).not.toBeInTheDocument();
});
it('hides copy options when showCopyOptions=false', async () => {
render(<AttributeActions record={record} showCopyOptions={false} />);
const ellipsisBtn = screen.getByTestId('attribute-actions-more');
fireEvent.mouseEnter(ellipsisBtn.parentElement as Element);
await waitFor(() =>
expect(screen.queryByText('Copy Field Name')).not.toBeInTheDocument(),
);
expect(screen.queryByText('Copy Field Value')).not.toBeInTheDocument();
});
});

View File

@@ -1,383 +0,0 @@
import { MemoryRouter, Route } from 'react-router-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ROUTES from 'constants/routes';
import { SPAN_ATTRIBUTES } from 'container/ApiMonitoring/Explorer/Domains/DomainDetails/constants';
import { AppProvider } from 'providers/App/App';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import { Span } from 'types/api/trace/getTraceV2';
import SpanDetailsDrawer from '../SpanDetailsDrawer';
// Mock external dependencies
const mockRedirectWithQueryBuilderData = jest.fn();
const mockNotifications = {
success: jest.fn(),
error: jest.fn(),
};
const mockSetCopy = jest.fn();
const mockQueryClient = {
fetchQuery: jest.fn(),
};
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
// Mock the hooks
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): any => ({
currentQuery: {
builder: {
queryData: [
{
aggregateOperator: 'count',
aggregateAttribute: { key: 'signoz_span_duration' },
filters: { items: [], op: 'AND' },
filter: { expression: '' },
groupBy: [],
},
],
},
},
redirectWithQueryBuilderData: mockRedirectWithQueryBuilderData,
}),
}));
jest.mock('hooks/useNotifications', () => ({
useNotifications: (): any => ({ notifications: mockNotifications }),
}));
jest.mock('react-use', () => ({
...jest.requireActual('react-use'),
useCopyToClipboard: (): any => [{ value: '' }, mockSetCopy],
}));
jest.mock('react-query', () => ({
...jest.requireActual('react-query'),
useQueryClient: (): any => mockQueryClient,
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: jest.fn(),
}));
// Mock the API response for getAggregateKeys
const mockAggregateKeysResponse = {
payload: {
attributeKeys: [
{
key: 'http.method',
dataType: 'string',
type: 'tag',
isColumn: true,
},
{
key: 'service.name',
dataType: 'string',
type: 'resource',
isColumn: true,
},
],
},
};
beforeEach(() => {
jest.clearAllMocks();
mockQueryClient.fetchQuery.mockResolvedValue(mockAggregateKeysResponse);
});
// Mock trace data with realistic span attributes
const createMockSpan = (): Span => ({
spanId: '28a8a67365d0bd8b',
traceId: '000000000000000071dc9b0a338729b4',
name: 'HTTP GET /api/users',
timestamp: 1699872000000000,
durationNano: 150000000,
serviceName: 'frontend-service',
spanKind: 'server',
statusCodeString: 'OK',
statusMessage: '',
tagMap: {
'http.method': 'GET',
[SPAN_ATTRIBUTES.HTTP_URL]: '/api/users?page=1',
'http.status_code': '200',
'service.name': 'frontend-service',
'span.kind': 'server',
'user.id': '12345',
'request.id': 'req-abc-123',
},
event: [],
references: [],
hasError: false,
rootSpanId: '',
parentSpanId: '',
kind: 0,
rootName: '',
hasChildren: false,
hasSibling: false,
subTreeNodeCount: 0,
level: 0,
});
const renderSpanDetailsDrawer = (span: Span = createMockSpan()): any => {
const user = userEvent.setup();
const component = render(
<MockQueryClientProvider>
<AppProvider>
<MemoryRouter>
<Route>
<SpanDetailsDrawer
isSpanDetailsDocked={false}
setIsSpanDetailsDocked={jest.fn()}
selectedSpan={span}
traceStartTime={span.timestamp}
traceEndTime={span.timestamp + span.durationNano}
/>
</Route>
</MemoryRouter>
</AppProvider>
</MockQueryClientProvider>,
);
return { ...component, user };
};
describe('AttributeActions User Flow Tests', () => {
// Todo: to fixed properly - failing with - due to timeout > 5000ms
describe.skip('Complete Attribute Actions User Flow', () => {
it('should allow user to interact with span attribute actions from trace detail page', async () => {
renderSpanDetailsDrawer();
// Verify Attributes tab is displayed with table view
expect(screen.getByText('Attributes')).toBeInTheDocument();
// Verify attributes are displayed
expect(screen.getByText('http.method')).toBeInTheDocument();
expect(screen.getByText('GET')).toBeInTheDocument();
expect(screen.getByText('service.name')).toBeInTheDocument();
expect(screen.getAllByText('frontend-service')[0]).toBeInTheDocument();
// Find an attribute row to test actions on
const httpMethodRow = screen.getByText('http.method').closest('.item');
expect(httpMethodRow).toBeInTheDocument();
// Action buttons are always mounted in the DOM (only CSS-hidden until :hover),
// so we can query them directly without simulating a pointer hover.
const actionButtons = httpMethodRow!.querySelector('.action-btn');
expect(actionButtons).toBeInTheDocument();
const filterForButton = httpMethodRow!.querySelector(
'[aria-label="Filter for value"]',
) as HTMLElement;
const filterOutButton = httpMethodRow!.querySelector(
'[aria-label="Filter out value"]',
) as HTMLElement;
expect(filterForButton).toBeInTheDocument();
expect(filterOutButton).toBeInTheDocument();
// Test "Filter for" action — use fireEvent to skip userEvent's pointer
// simulation and the Antd Tooltip mouseEnterDelay timers it triggers.
fireEvent.click(filterForButton);
// Verify navigation to traces explorer with inclusive filter
await waitFor(() => {
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
dataSource: 'traces',
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'http.method' }),
op: '=',
value: 'GET',
}),
]),
}),
}),
]),
}),
}),
{},
ROUTES.TRACES_EXPLORER,
);
});
// Reset mock for next test
mockRedirectWithQueryBuilderData.mockClear();
// Test "Filter out" action
fireEvent.click(filterOutButton);
// Verify navigation to traces explorer with exclusive filter
await waitFor(() => {
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
dataSource: 'traces',
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'http.method' }),
op: '!=',
value: 'GET',
}),
]),
}),
}),
]),
}),
}),
{},
ROUTES.TRACES_EXPLORER,
);
});
// Verify more actions button exists (popover functionality is tested in unit tests)
const moreActionsButton = httpMethodRow!
.querySelector('.lucide-ellipsis')
?.closest('button');
expect(moreActionsButton).toBeInTheDocument();
});
});
// Todo: to fixed properly - failing with - due to timeout > 5000ms
describe.skip('Filter Replacement Flow', () => {
it('should replace previous filter when applying multiple filters on same field', async () => {
renderSpanDetailsDrawer();
// Find the http.method attribute row
const httpMethodRow = screen.getByText('http.method').closest('.item');
expect(httpMethodRow).toBeInTheDocument();
// Action buttons are always mounted (CSS-hidden until :hover, which jsdom
// doesn't evaluate), so we can click them directly via fireEvent and skip
// userEvent's pointer simulation + Antd Tooltip mouseEnterDelay timers.
const filterForButton = httpMethodRow!.querySelector(
'[aria-label="Filter for value"]',
) as HTMLElement;
expect(filterForButton).toBeInTheDocument();
fireEvent.click(filterForButton);
// Verify first filter was applied
await waitFor(() => {
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'http.method' }),
op: '=',
value: 'GET',
}),
]),
}),
}),
]),
}),
}),
{},
ROUTES.TRACES_EXPLORER,
);
});
// Reset and simulate existing filter in current query
mockRedirectWithQueryBuilderData.mockClear();
// Apply second filter on same field (should replace, not accumulate)
const filterOutButton = httpMethodRow!.querySelector(
'[aria-label="Filter out value"]',
) as HTMLElement;
expect(filterOutButton).toBeInTheDocument();
fireEvent.click(filterOutButton);
// Verify the new call contains only the new filter (replacement behavior)
await waitFor(() => {
const lastCall =
mockRedirectWithQueryBuilderData.mock.calls[
mockRedirectWithQueryBuilderData.mock.calls.length - 1
];
const queryData = lastCall[0].builder.queryData[0];
const httpMethodFilters = queryData.filters.items.filter(
(item: any) => item.key.key === 'http.method',
);
// Should have only one filter for http.method (the new one)
expect(httpMethodFilters).toHaveLength(1);
expect(httpMethodFilters[0].op).toBe('!=');
expect(httpMethodFilters[0].value).toBe('GET');
});
});
});
describe('Edge Cases', () => {
it('should handle attributes with special characters and JSON values', async () => {
const spanWithSpecialAttrs = createMockSpan();
spanWithSpecialAttrs.tagMap = {
'request.headers.content-type': 'application/json',
'response.body': '{"status":"success","data":[]}',
'trace.annotation': '"quoted_string_value"',
};
const { user } = renderSpanDetailsDrawer(spanWithSpecialAttrs);
// Test attribute with dashes
expect(screen.getByText('request.headers.content-type')).toBeInTheDocument();
expect(screen.getByText('application/json')).toBeInTheDocument();
// Test JSON value
expect(screen.getByText('response.body')).toBeInTheDocument();
// Test quoted string value - should remove surrounding quotes when copying
const quotedAttrRow = screen.getByText('trace.annotation').closest('.item');
await user.hover(quotedAttrRow!);
const actionButtons = quotedAttrRow!.querySelectorAll('.action-btn button');
const moreActionsButton = actionButtons[actionButtons.length - 1];
await user.hover(moreActionsButton);
await waitFor(() => {
expect(screen.getByText('Copy Field Value')).toBeInTheDocument();
});
const copyFieldValueButton = screen.getByText('Copy Field Value');
fireEvent.click(copyFieldValueButton);
// Verify quotes are stripped from copied value
await waitFor(() => {
expect(mockSetCopy).toHaveBeenCalledWith('quoted_string_value');
});
});
it('should handle empty attributes gracefully', async () => {
const spanWithNoAttrs = createMockSpan();
spanWithNoAttrs.tagMap = {};
renderSpanDetailsDrawer(spanWithNoAttrs);
// Verify no attributes message is displayed
expect(
screen.getByText('No attributes found for selected span'),
).toBeInTheDocument();
});
});
});

View File

@@ -1,495 +0,0 @@
import ROUTES from 'constants/routes';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { server } from 'mocks-server/server';
import { QueryBuilderContext } from 'providers/QueryBuilder';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { DataSource } from 'types/common/queryBuilder';
import SpanDetailsDrawer from '../SpanDetailsDrawer';
import {
expectedHostOnlyMetadata,
expectedInfraMetadata,
expectedNodeOnlyMetadata,
expectedPodOnlyMetadata,
mockEmptyMetricsResponse,
mockNodeMetricsResponse,
mockPodMetricsResponse,
mockSpanWithHostOnly,
mockSpanWithInfraMetadata,
mockSpanWithNodeOnly,
mockSpanWithoutInfraMetadata,
mockSpanWithPodOnly,
} from './infraMetricsTestData';
// Mock external dependencies
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${ROUTES.TRACE_DETAIL}`,
}),
}));
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: mockSafeNavigate,
}),
}));
const mockUpdateAllQueriesOperators = jest.fn().mockReturnValue({
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
aggregateOperator: 'noop',
filters: { items: [], op: 'AND' },
expression: 'A',
disabled: false,
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
groupBy: [],
limit: null,
having: [],
},
],
queryFormulas: [],
},
queryType: 'builder',
});
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): any => ({
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
currentQuery: {
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
filters: { items: [], op: 'AND' },
},
],
},
},
}),
}));
const mockWindowOpen = jest.fn();
Object.defineProperty(window, 'open', {
writable: true,
value: mockWindowOpen,
});
// Mock uplot to avoid rendering issues
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
// Mock GetMetricQueryRange to track API calls
jest.mock('lib/dashboard/getQueryResults', () => ({
GetMetricQueryRange: jest.fn(),
}));
// Mock generateColor
jest.mock('lib/uPlotLib/utils/generateColor', () => ({
generateColor: jest.fn().mockReturnValue('#1f77b4'),
}));
// Mock OverlayScrollbar
jest.mock(
'components/OverlayScrollbar/OverlayScrollbar',
() =>
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
function OverlayScrollbar({ children }: any) {
return <div data-testid="overlay-scrollbar">{children}</div>;
},
);
// Mock Virtuoso
jest.mock('react-virtuoso', () => ({
Virtuoso: jest.fn(({ data, itemContent }) => (
<div data-testid="virtuoso">
{data?.map((item: any, index: number) => (
<div key={item.id || index} data-testid={`log-item-${item.id}`}>
{itemContent(index, item)}
</div>
))}
</div>
)),
}));
// Mock InfraMetrics component for focused testing
jest.mock(
'container/LogDetailedView/InfraMetrics/InfraMetrics',
() =>
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
function MockInfraMetrics({
podName,
nodeName,
hostName,
clusterName,
timestamp,
dataSource,
}: any) {
return (
<div data-testid="infra-metrics">
<div data-testid="infra-pod-name">{podName}</div>
<div data-testid="infra-node-name">{nodeName}</div>
<div data-testid="infra-host-name">{hostName}</div>
<div data-testid="infra-cluster-name">{clusterName}</div>
<div data-testid="infra-timestamp">{timestamp}</div>
<div data-testid="infra-data-source">{dataSource}</div>
</div>
);
},
);
// Mock PreferenceContextProvider
jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
PreferenceContextProvider: ({ children }: any): JSX.Element => (
<div>{children}</div>
),
}));
describe('SpanDetailsDrawer - Infra Metrics', () => {
// eslint-disable-next-line sonarjs/no-unused-collection
let apiCallHistory: any[] = [];
beforeEach(() => {
jest.clearAllMocks();
apiCallHistory = [];
mockSafeNavigate.mockClear();
mockWindowOpen.mockClear();
mockUpdateAllQueriesOperators.mockClear();
// Setup API call tracking for infra metrics
(GetMetricQueryRange as jest.Mock).mockImplementation((query) => {
apiCallHistory.push(query);
// Return mock responses for different query types
if (
query?.query?.builder?.queryData?.[0]?.filters?.items?.some(
(item: any) => item.key?.key === 'k8s_pod_name',
)
) {
return Promise.resolve(mockPodMetricsResponse);
}
if (
query?.query?.builder?.queryData?.[0]?.filters?.items?.some(
(item: any) => item.key?.key === 'k8s_node_name',
)
) {
return Promise.resolve(mockNodeMetricsResponse);
}
return Promise.resolve(mockEmptyMetricsResponse);
});
});
afterEach(() => {
server.resetHandlers();
});
// Mock QueryBuilder context value
const mockQueryBuilderContextValue = {
currentQuery: {
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
filters: { items: [], op: 'AND' },
},
],
},
},
stagedQuery: {
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
filters: { items: [], op: 'AND' },
},
],
},
},
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
panelType: 'list',
redirectWithQuery: jest.fn(),
handleRunQuery: jest.fn(),
handleStageQuery: jest.fn(),
resetQuery: jest.fn(),
};
const renderSpanDetailsDrawer = (props = {}): void => {
render(
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
<SpanDetailsDrawer
isSpanDetailsDocked={false}
setIsSpanDetailsDocked={jest.fn()}
selectedSpan={mockSpanWithInfraMetadata}
traceStartTime={1640995200000} // 2022-01-01 00:00:00
traceEndTime={1640995260000} // 2022-01-01 00:01:00
{...props}
/>
</QueryBuilderContext.Provider>,
);
};
it('should detect infra metadata from span attributes', async () => {
renderSpanDetailsDrawer();
// Click on metrics tab
const infraMetricsButton = screen.getByRole('button', { name: /metrics/i });
expect(infraMetricsButton).toBeInTheDocument();
fireEvent.click(infraMetricsButton);
// Wait for infra metrics to load
await waitFor(() => {
expect(screen.getByTestId('infra-metrics')).toBeInTheDocument();
});
// Verify metadata extraction
expect(screen.getByTestId('infra-pod-name')).toHaveTextContent(
expectedInfraMetadata.podName,
);
expect(screen.getByTestId('infra-node-name')).toHaveTextContent(
expectedInfraMetadata.nodeName,
);
expect(screen.getByTestId('infra-host-name')).toHaveTextContent(
expectedInfraMetadata.hostName,
);
expect(screen.getByTestId('infra-cluster-name')).toHaveTextContent(
expectedInfraMetadata.clusterName,
);
expect(screen.getByTestId('infra-data-source')).toHaveTextContent(
DataSource.TRACES,
);
});
it('should not show infra tab when span lacks infra metadata', async () => {
render(
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
<SpanDetailsDrawer
isSpanDetailsDocked={false}
setIsSpanDetailsDocked={jest.fn()}
selectedSpan={mockSpanWithoutInfraMetadata}
traceStartTime={1640995200000}
traceEndTime={1640995260000}
/>
</QueryBuilderContext.Provider>,
);
// Should NOT show infra tab, only logs tab
expect(
screen.queryByRole('button', { name: /metrics/i }),
).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /logs/i })).toBeInTheDocument();
});
it('should show infra tab when span has infra metadata', async () => {
renderSpanDetailsDrawer();
// Should show both logs and infra tabs
expect(screen.getByRole('button', { name: /metrics/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /logs/i })).toBeInTheDocument();
});
it('should handle pod-only metadata correctly', async () => {
render(
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
<SpanDetailsDrawer
isSpanDetailsDocked={false}
setIsSpanDetailsDocked={jest.fn()}
selectedSpan={mockSpanWithPodOnly}
traceStartTime={1640995200000}
traceEndTime={1640995260000}
/>
</QueryBuilderContext.Provider>,
);
// Click on infra tab
const infraMetricsButton = screen.getByRole('button', { name: /metrics/i });
fireEvent.click(infraMetricsButton);
await waitFor(() => {
expect(screen.getByTestId('infra-metrics')).toBeInTheDocument();
});
// Verify pod-only metadata
expect(screen.getByTestId('infra-pod-name')).toHaveTextContent(
expectedPodOnlyMetadata.podName,
);
expect(screen.getByTestId('infra-cluster-name')).toHaveTextContent(
expectedPodOnlyMetadata.clusterName,
);
expect(screen.getByTestId('infra-node-name')).toHaveTextContent(
expectedPodOnlyMetadata.nodeName,
);
expect(screen.getByTestId('infra-host-name')).toHaveTextContent(
expectedPodOnlyMetadata.hostName,
);
});
it('should handle node-only metadata correctly', async () => {
render(
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
<SpanDetailsDrawer
isSpanDetailsDocked={false}
setIsSpanDetailsDocked={jest.fn()}
selectedSpan={mockSpanWithNodeOnly}
traceStartTime={1640995200000}
traceEndTime={1640995260000}
/>
</QueryBuilderContext.Provider>,
);
// Click on infra tab
const infraMetricsButton = screen.getByRole('button', { name: /metrics/i });
fireEvent.click(infraMetricsButton);
await waitFor(() => {
expect(screen.getByTestId('infra-metrics')).toBeInTheDocument();
});
// Verify node-only metadata
expect(screen.getByTestId('infra-node-name')).toHaveTextContent(
expectedNodeOnlyMetadata.nodeName,
);
expect(screen.getByTestId('infra-pod-name')).toHaveTextContent(
expectedNodeOnlyMetadata.podName,
);
expect(screen.getByTestId('infra-cluster-name')).toHaveTextContent(
expectedNodeOnlyMetadata.clusterName,
);
expect(screen.getByTestId('infra-host-name')).toHaveTextContent(
expectedNodeOnlyMetadata.hostName,
);
});
it('should handle host-only metadata correctly', async () => {
render(
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
<SpanDetailsDrawer
isSpanDetailsDocked={false}
setIsSpanDetailsDocked={jest.fn()}
selectedSpan={mockSpanWithHostOnly}
traceStartTime={1640995200000}
traceEndTime={1640995260000}
/>
</QueryBuilderContext.Provider>,
);
// Click on infra tab
const infraMetricsButton = screen.getByRole('button', { name: /metrics/i });
fireEvent.click(infraMetricsButton);
await waitFor(() => {
expect(screen.getByTestId('infra-metrics')).toBeInTheDocument();
});
// Verify host-only metadata
expect(screen.getByTestId('infra-host-name')).toHaveTextContent(
expectedHostOnlyMetadata.hostName,
);
expect(screen.getByTestId('infra-pod-name')).toHaveTextContent(
expectedHostOnlyMetadata.podName,
);
expect(screen.getByTestId('infra-node-name')).toHaveTextContent(
expectedHostOnlyMetadata.nodeName,
);
expect(screen.getByTestId('infra-cluster-name')).toHaveTextContent(
expectedHostOnlyMetadata.clusterName,
);
});
it('should switch between logs and infra tabs correctly', async () => {
renderSpanDetailsDrawer();
// Initially should show logs tab content
const logsButton = screen.getByRole('button', { name: /logs/i });
const infraMetricsButton = screen.getByRole('button', { name: /metrics/i });
expect(logsButton).toBeInTheDocument();
expect(infraMetricsButton).toBeInTheDocument();
// Ensure logs tab is active and wait for content to load
fireEvent.click(logsButton);
await waitFor(() => {
expect(screen.getByTestId('open-in-explorer-button')).toBeInTheDocument();
});
// Click on infra tab
fireEvent.click(infraMetricsButton);
await waitFor(() => {
expect(screen.getByTestId('infra-metrics')).toBeInTheDocument();
});
// Should not show logs content anymore
expect(
screen.queryByTestId('open-in-explorer-button'),
).not.toBeInTheDocument();
// Switch back to logs tab
fireEvent.click(logsButton);
// Should not show infra metrics anymore
await waitFor(() => {
expect(screen.queryByTestId('infra-metrics')).not.toBeInTheDocument();
});
// Verify logs content is shown again
await waitFor(() => {
expect(screen.getByTestId('open-in-explorer-button')).toBeInTheDocument();
});
});
it('should pass correct data source and handle multiple infra identifiers', async () => {
renderSpanDetailsDrawer();
// Should show infra tab when span has any of: clusterName, podName, nodeName, hostName
expect(screen.getByRole('button', { name: /metrics/i })).toBeInTheDocument();
// Click on infra tab
const infraMetricsButton = screen.getByRole('button', { name: /metrics/i });
fireEvent.click(infraMetricsButton);
await waitFor(() => {
expect(screen.getByTestId('infra-metrics')).toBeInTheDocument();
});
// Verify TRACES data source is passed
expect(screen.getByTestId('infra-data-source')).toHaveTextContent(
DataSource.TRACES,
);
// All infra identifiers should be passed through
expect(screen.getByTestId('infra-pod-name')).toHaveTextContent(
'test-pod-abc123',
);
expect(screen.getByTestId('infra-node-name')).toHaveTextContent(
'test-node-456',
);
expect(screen.getByTestId('infra-host-name')).toHaveTextContent(
'test-host.example.com',
);
expect(screen.getByTestId('infra-cluster-name')).toHaveTextContent(
'test-cluster',
);
});
});

View File

@@ -1,167 +0,0 @@
import { Span } from 'types/api/trace/getTraceV2';
// Constants
const TEST_TRACE_ID = 'test-trace-id';
const TEST_CLUSTER_NAME = 'test-cluster';
const TEST_POD_NAME = 'test-pod-abc123';
const TEST_NODE_NAME = 'test-node-456';
const TEST_HOST_NAME = 'test-host.example.com';
// Mock span with infrastructure metadata (pod + node + host)
export const mockSpanWithInfraMetadata: Span = {
spanId: 'infra-span-id',
traceId: TEST_TRACE_ID,
name: 'api-service',
serviceName: 'api-service',
timestamp: 1640995200000000, // 2022-01-01 00:00:00 in microseconds
durationNano: 2000000000, // 2 seconds in nanoseconds
spanKind: 'server',
statusCodeString: 'STATUS_CODE_OK',
statusMessage: '',
parentSpanId: '',
references: [],
event: [],
tagMap: {
'k8s.cluster.name': TEST_CLUSTER_NAME,
'k8s.pod.name': TEST_POD_NAME,
'k8s.node.name': TEST_NODE_NAME,
'host.name': TEST_HOST_NAME,
'service.name': 'api-service',
'http.method': 'GET',
},
hasError: false,
rootSpanId: '',
kind: 0,
rootName: '',
hasChildren: false,
hasSibling: false,
subTreeNodeCount: 0,
level: 0,
};
// Mock span with only pod metadata
export const mockSpanWithPodOnly: Span = {
...mockSpanWithInfraMetadata,
spanId: 'pod-only-span-id',
tagMap: {
'k8s.cluster.name': TEST_CLUSTER_NAME,
'k8s.pod.name': TEST_POD_NAME,
'service.name': 'api-service',
},
};
// Mock span with only node metadata
export const mockSpanWithNodeOnly: Span = {
...mockSpanWithInfraMetadata,
spanId: 'node-only-span-id',
tagMap: {
'k8s.node.name': TEST_NODE_NAME,
'service.name': 'api-service',
},
};
// Mock span with only host metadata
export const mockSpanWithHostOnly: Span = {
...mockSpanWithInfraMetadata,
spanId: 'host-only-span-id',
tagMap: {
'host.name': TEST_HOST_NAME,
'service.name': 'api-service',
},
};
// Mock span without any infrastructure metadata
export const mockSpanWithoutInfraMetadata: Span = {
...mockSpanWithInfraMetadata,
spanId: 'no-infra-span-id',
tagMap: {
'service.name': 'api-service',
'http.method': 'GET',
'http.status_code': '200',
},
};
// Mock infrastructure metrics API responses
export const mockPodMetricsResponse = {
payload: {
data: {
newResult: {
data: {
result: [
{
metric: { pod_name: TEST_POD_NAME },
values: [
[1640995200, '0.5'], // CPU usage
[1640995260, '0.6'],
],
},
],
},
},
},
},
};
export const mockNodeMetricsResponse = {
payload: {
data: {
newResult: {
data: {
result: [
{
metric: { node_name: TEST_NODE_NAME },
values: [
[1640995200, '2.1'], // Memory usage
[1640995260, '2.3'],
],
},
],
},
},
},
},
};
export const mockEmptyMetricsResponse = {
payload: {
data: {
newResult: {
data: {
result: [],
},
},
},
},
};
// Expected infrastructure metadata extractions
export const expectedInfraMetadata = {
clusterName: TEST_CLUSTER_NAME,
podName: TEST_POD_NAME,
nodeName: TEST_NODE_NAME,
hostName: TEST_HOST_NAME,
};
export const expectedPodOnlyMetadata = {
clusterName: TEST_CLUSTER_NAME,
podName: TEST_POD_NAME,
nodeName: '',
hostName: '',
spanTimestamp: '2022-01-01T00:00:00.000Z',
};
export const expectedNodeOnlyMetadata = {
clusterName: '',
podName: '',
nodeName: TEST_NODE_NAME,
hostName: '',
spanTimestamp: '2022-01-01T00:00:00.000Z',
};
export const expectedHostOnlyMetadata = {
clusterName: '',
podName: '',
nodeName: '',
hostName: TEST_HOST_NAME,
spanTimestamp: '2022-01-01T00:00:00.000Z',
};

View File

@@ -1,224 +0,0 @@
import { SPAN_ATTRIBUTES } from 'container/ApiMonitoring/Explorer/Domains/DomainDetails/constants';
import { ILog } from 'types/api/logs/log';
import { Span } from 'types/api/trace/getTraceV2';
// Constants
const TEST_SPAN_ID = 'test-span-id';
const TEST_TRACE_ID = 'test-trace-id';
const TEST_SERVICE = 'test-service';
// Mock span data
export const mockSpan: Span = {
spanId: TEST_SPAN_ID,
traceId: TEST_TRACE_ID,
name: TEST_SERVICE,
serviceName: TEST_SERVICE,
timestamp: 1640995200000, // 2022-01-01 00:00:00 in milliseconds
durationNano: 1000000000, // 1 second in nanoseconds
spanKind: 'server',
statusCodeString: 'STATUS_CODE_OK',
statusMessage: '',
parentSpanId: '',
references: [],
event: [],
tagMap: {
'http.method': 'GET',
[SPAN_ATTRIBUTES.HTTP_URL]: '/api/test',
'http.status_code': '200',
},
hasError: false,
rootSpanId: '',
kind: 0,
rootName: '',
hasChildren: false,
hasSibling: false,
subTreeNodeCount: 0,
level: 0,
};
// Mock span with long status message (> 100 characters) for testing truncation
export const mockSpanWithLongStatusMessage: Span = {
...mockSpan,
statusMessage:
'Error: Connection timeout occurred while trying to reach the database server. The connection pool was exhausted and all retry attempts failed after 30 seconds.',
};
// Mock span with short status message (<= 100 characters)
export const mockSpanWithShortStatusMessage: Span = {
...mockSpan,
statusMessage: 'Connection successful',
};
// Mock logs with proper relationships
export const mockSpanLogs: ILog[] = [
{
id: 'span-log-1',
timestamp: '2022-01-01T00:00:01.000Z',
body: 'Processing request in span',
severity_text: 'INFO',
severity_number: 9,
spanID: TEST_SPAN_ID,
span_id: TEST_SPAN_ID,
date: '',
traceId: TEST_TRACE_ID,
traceFlags: 0,
severityText: '',
severityNumber: 0,
resources_string: {},
scope_string: {},
attributesString: {},
attributes_string: {},
attributesInt: {},
attributesFloat: {},
},
{
id: 'span-log-2',
timestamp: '2022-01-01T00:00:02.000Z',
body: 'Span operation completed',
severity_text: 'INFO',
severity_number: 9,
spanID: TEST_SPAN_ID,
span_id: TEST_SPAN_ID,
date: '',
traceId: TEST_TRACE_ID,
traceFlags: 0,
severityText: '',
severityNumber: 0,
resources_string: {},
scope_string: {},
attributesString: {},
attributes_string: {},
attributesInt: {},
attributesFloat: {},
},
];
export const mockContextLogs: ILog[] = [
{
id: 'context-log-before',
timestamp: '2021-12-31T23:59:59.000Z',
body: 'Context log before span',
severity_text: 'INFO',
severity_number: 9,
spanID: 'different-span-id',
span_id: 'different-span-id',
date: '',
traceId: TEST_TRACE_ID,
traceFlags: 0,
severityText: '',
severityNumber: 0,
resources_string: {},
scope_string: {},
attributesString: {},
attributes_string: {},
attributesInt: {},
attributesFloat: {},
},
{
id: 'context-log-after',
timestamp: '2022-01-01T00:00:03.000Z',
body: 'Context log after span',
severity_text: 'INFO',
severity_number: 9,
spanID: 'another-different-span-id',
span_id: 'another-different-span-id',
date: '',
traceId: TEST_TRACE_ID,
traceFlags: 0,
severityText: '',
severityNumber: 0,
resources_string: {},
scope_string: {},
attributesString: {},
attributes_string: {},
attributesInt: {},
attributesFloat: {},
},
];
// Combined logs in chronological order
export const mockAllLogs: ILog[] = [
mockContextLogs[0], // before
...mockSpanLogs, // span logs
mockContextLogs[1], // after
];
// Mock API responses
export const mockSpanLogsResponse = {
payload: {
data: {
newResult: {
data: {
result: [
{
list: mockSpanLogs.map((log) => ({
data: log,
timestamp: log.timestamp,
})),
},
],
},
},
},
},
};
export const mockBeforeLogsResponse = {
payload: {
data: {
newResult: {
data: {
result: [
{
list: [mockContextLogs[0]].map((log) => ({
data: log,
timestamp: log.timestamp,
})),
},
],
},
},
},
},
};
export const mockAfterLogsResponse = {
payload: {
data: {
newResult: {
data: {
result: [
{
list: [mockContextLogs[1]].map((log) => ({
data: log,
timestamp: log.timestamp,
})),
},
],
},
},
},
},
};
export const mockEmptyLogsResponse = {
payload: {
data: {
newResult: {
data: {
result: [
{
list: [],
},
],
},
},
},
},
};
// Expected v5 filter expressions
export const expectedSpanFilterExpression = `trace_id = '${TEST_TRACE_ID}' AND span_id = '${TEST_SPAN_ID}'`;
export const expectedBeforeFilterExpression = `trace_id = '${TEST_TRACE_ID}' AND id < 'span-log-1'`;
export const expectedAfterFilterExpression = `trace_id = '${TEST_TRACE_ID}' AND id > 'span-log-2'`;
export const expectedTraceOnlyFilterExpression = `trace_id = '${TEST_TRACE_ID}'`;

View File

@@ -1,11 +0,0 @@
export enum RelatedSignalsViews {
LOGS = 'logs',
// METRICS = 'metrics',
INFRA = 'infra',
}
export const RELATED_SIGNALS_VIEW_TYPES = {
LOGS: RelatedSignalsViews.LOGS,
// METRICS: RelatedSignalsViews.METRICS,
INFRA: RelatedSignalsViews.INFRA,
};

View File

@@ -1,24 +0,0 @@
import { Span } from 'types/api/trace/getTraceV2';
/**
* Infrastructure metadata keys that indicate infra signals are available
*/
export const INFRA_METADATA_KEYS = [
'k8s.cluster.name',
'k8s.pod.name',
'k8s.node.name',
'host.name',
] as const;
/**
* Checks if a span has any infrastructure metadata attributes
* @param span - The span to check for infrastructure metadata
* @returns true if the span has at least one infrastructure metadata key, false otherwise
*/
export function hasInfraMetadata(span: Span | undefined): boolean {
if (!span?.tagMap) {
return false;
}
return INFRA_METADATA_KEYS.some((key) => span.tagMap?.[key]);
}

View File

@@ -1,186 +0,0 @@
.trace-metadata {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0px 16px 0px 16px;
.metadata-info {
display: flex;
flex-direction: column;
gap: 10px;
.first-row {
display: flex;
align-items: center;
.previous-btn {
display: flex;
height: 30px;
padding: 6px 8px;
align-items: center;
gap: 4px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
border-radius: 4px;
box-shadow: none;
}
.trace-name {
display: flex;
padding: 6px 8px;
margin-left: 6px;
align-items: center;
gap: 4px;
border: 1px solid var(--l1-border);
border-radius: 4px 0px 0px 4px;
background: var(--l2-background);
.drafting {
color: var(--l1-foreground);
}
.trace-id {
color: var(--l1-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
}
}
.trace-id-value {
display: flex;
padding: 6px 8px;
justify-content: center;
align-items: center;
gap: 10px;
background: var(--l3-background);
color: var(--l2-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
border: 1px solid var(--l1-border);
border-left: unset;
border-radius: 0px 4px 4px 0px;
}
}
.second-row {
display: flex;
gap: 24px;
.service-entry-info {
display: flex;
gap: 6px;
color: var(--l2-foreground);
align-items: center;
.text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.root-span-name {
display: flex;
padding: 2px 8px;
align-items: center;
gap: 8px;
border-radius: 50px;
border: 1px solid var(--l1-border);
background: var(--l1-border);
}
}
.trace-duration {
display: flex;
gap: 6px;
color: var(--l2-foreground);
align-items: center;
.text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
.start-time-info {
display: flex;
gap: 6px;
color: var(--l2-foreground);
align-items: center;
.text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
}
}
.datapoints-info {
display: flex;
gap: 16px;
.separator {
width: 1px;
background: var(--l3-background);
}
.data-point {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 4px;
.text {
color: var(--l2-foreground);
text-align: center;
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
}
.value {
color: var(--l1-foreground);
font-variant-numeric: lining-nums tabular-nums stacked-fractions
slashed-zero;
font-feature-settings:
'case' on,
'cpsp' on,
'dlig' on,
'salt' on;
font-family: Inter;
font-size: 20px;
font-style: normal;
font-weight: 500;
line-height: 28px; /* 140% */
letter-spacing: -0.1px;
text-transform: uppercase;
text-align: right;
}
}
}
}

View File

@@ -1,171 +0,0 @@
import { useMemo } from 'react';
import { useLocation, useRouteMatch } from 'react-router-dom';
import { Skeleton, Tooltip } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import removeLocalStorageKey from 'api/browser/localstorage/remove';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { LOCALSTORAGE } from 'constants/localStorage';
import ROUTES from 'constants/routes';
import dayjs from 'dayjs';
import history from 'lib/history';
import {
ArrowLeft,
BetweenHorizontalStart,
CalendarClock,
DraftingCompass,
Timer,
} from '@signozhq/icons';
import { useTimezone } from 'providers/Timezone';
import './TraceMetadata.styles.scss';
export interface ITraceMetadataProps {
traceID: string;
rootServiceName: string;
rootSpanName: string;
startTime: number;
duration: number;
totalSpans: number;
totalErrorSpans: number;
notFound: boolean;
isDataLoading: boolean;
}
function TraceMetadata(props: ITraceMetadataProps): JSX.Element {
const {
traceID,
rootServiceName,
rootSpanName,
startTime,
duration,
totalErrorSpans,
totalSpans,
notFound,
isDataLoading,
} = props;
const { timezone } = useTimezone();
const startTimeInMs = useMemo(
() =>
dayjs(startTime * 1e3)
.tz(timezone.value)
.format(DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS),
[startTime, timezone.value],
);
const handlePreviousBtnClick = (): void => {
if (window.history.length > 1) {
history.goBack();
} else {
history.push(ROUTES.TRACES_EXPLORER);
}
};
const isOnOldRoute = !!useRouteMatch({
path: ROUTES.TRACE_DETAIL_OLD,
exact: true,
});
const location = useLocation();
const handleSwitchToNewView = (): void => {
removeLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_PREFER_OLD_VIEW);
history.replace({
pathname: `/trace/${traceID}`,
search: location.search,
hash: location.hash,
state: location.state,
});
};
return (
<div className="trace-metadata">
<section className="metadata-info">
<div className="first-row">
<Button
variant="solid"
color="secondary"
size="icon"
className="previous-btn"
prefix={<ArrowLeft size={14} />}
onClick={handlePreviousBtnClick}
/>
<div className="trace-name">
<DraftingCompass size={14} className="drafting" />
<Typography.Text className="trace-id">Trace ID</Typography.Text>
</div>
<Typography.Text className="trace-id-value">{traceID}</Typography.Text>
{isOnOldRoute && (
<Button
variant="solid"
color="primary"
size="md"
className="new-view-btn"
onClick={handleSwitchToNewView}
>
Try new experience
</Button>
)}
</div>
{isDataLoading && (
<div className="second-row">
<div className="service-entry-info">
<BetweenHorizontalStart size={14} />
<Skeleton.Input active className="skeleton-input" size="small" />
<Skeleton.Input active className="skeleton-input" size="small" />
<Skeleton.Input active className="skeleton-input" size="small" />
</div>
</div>
)}
{!isDataLoading && !notFound && (
<div className="second-row">
<div className="service-entry-info">
<BetweenHorizontalStart size={14} />
<Typography.Text className="text">{rootServiceName}</Typography.Text>
&#8212;
<Typography.Text className="text root-span-name">
{rootSpanName}
</Typography.Text>
</div>
<div className="trace-duration">
<Tooltip title="Duration of trace">
<Timer size={14} />
</Tooltip>
<Typography.Text className="text">
{getYAxisFormattedValue(`${duration}`, 'ms')}
</Typography.Text>
</div>
<div className="start-time-info">
<Tooltip title="Start timestamp">
<CalendarClock size={14} />
</Tooltip>
<Typography.Text className="text">
{startTimeInMs || 'N/A'}
</Typography.Text>
</div>
</div>
)}
</section>
{!notFound && (
<section className="datapoints-info">
<div className="data-point">
<Typography.Text className="text">Total Spans</Typography.Text>
<Typography.Text className="value">{totalSpans}</Typography.Text>
</div>
<div className="separator" />
<div className="data-point">
<Typography.Text className="text">Error Spans</Typography.Text>
<Typography.Text className="value">{totalErrorSpans}</Typography.Text>
</div>
</section>
)}
</div>
);
}
export default TraceMetadata;

View File

@@ -1,239 +0,0 @@
// Modal base styles
.add-span-to-funnel-modal {
&__loading-spinner {
display: flex;
align-items: center;
justify-content: center;
height: 400px;
}
&-container {
.ant-modal {
&-content,
&-header {
background: var(--l1-background);
}
&-header {
border-bottom: none;
.ant-modal-title {
color: var(--l1-foreground);
}
}
&-body {
padding: 14px 16px !important;
padding-bottom: 0 !important;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
&-footer {
margin-top: 0;
background: var(--l2-background);
border-top: 1px solid var(--l1-border);
padding: 16px !important;
.add-span-to-funnel-modal {
&__save-button {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
color: var(--l1-foreground);
font-size: 12px;
font-weight: 500;
line-height: 24px;
width: 135px;
.ant-btn-icon {
display: flex;
}
&:disabled {
color: var(--l2-foreground);
.ant-btn-icon {
svg {
stroke: var(--l2-foreground);
}
}
}
}
&__discard-button {
background: var(--l1-border);
}
}
.ant-btn {
border-radius: 2px;
padding: 4px 8px;
margin: 0 !important;
border: none;
box-shadow: none;
}
}
}
}
}
// Main modal styles
.add-span-to-funnel-modal {
// Common button styles
%button-base {
display: flex;
align-items: center;
font-family: Inter;
}
// Details view styles
&--details {
.traces-funnel-details {
height: unset;
&__steps-config {
width: unset;
border: none;
}
.funnel-step-wrapper {
gap: 15px;
}
.steps-content {
padding: 0 16px;
max-height: 500px;
}
}
}
// Search section
&__search {
display: flex;
gap: 12px;
margin-bottom: 14px;
align-items: center;
&-input {
flex: 1;
padding: 6px 8px;
background: var(--l3-background);
.ant-input-prefix {
height: 18px;
margin-inline-end: 6px;
svg {
opacity: 0.4;
}
}
&,
input {
font-family: Inter;
font-size: 14px;
line-height: 18px;
letter-spacing: -0.07px;
font-weight: 400;
background: var(--l3-background);
}
input::placeholder {
color: var(--l2-foreground);
opacity: 0.4;
}
}
}
// Create button
&__create-button {
@extend %button-base;
width: 153px;
padding: 4px 8px;
justify-content: center;
gap: 4px;
flex-shrink: 0;
border-radius: 2px;
background: var(--l1-border);
border: none;
box-shadow: none;
color: var(--l2-foreground);
font-size: 12px;
font-weight: 500;
line-height: 24px;
}
.funnel-item {
padding: 8px 8px 12px 16px;
&,
&:first-child {
border-radius: 6px;
}
&__header {
line-height: 20px;
}
&__details {
line-height: 18px;
}
}
// List section
&__list {
max-height: 400px;
overflow-y: scroll;
.funnels-empty {
&__content {
padding: 0;
}
}
.funnels-list {
gap: 8px;
.funnel-item {
padding: 8px 16px 12px;
&__details {
margin-top: 8px;
}
}
}
}
&__spinner {
height: 400px;
}
// Back button
&__back-button {
@extend %button-base;
gap: 6px;
color: var(--l2-foreground);
font-size: 14px;
line-height: 20px;
margin-bottom: 14px;
}
// Details section
&__details {
display: flex;
flex-direction: column;
gap: 24px;
.funnel-configuration__steps {
padding: 0;
.funnel-step {
&__content .filters__service-and-span .ant-select {
width: 170px;
}
&__footer .error {
width: 25%;
}
}
.inter-step-config {
width: calc(100% - 104px);
}
}
.funnel-item__actions-popover {
display: none;
}
}
}

View File

@@ -1,294 +0,0 @@
import { ChangeEvent, useEffect, useMemo, useState } from 'react';
import { ArrowLeft, Check, Loader, Plus, Search } from '@signozhq/icons';
import { Input } from '@signozhq/ui/input';
import { Button, Spin } from 'antd';
import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import SignozModal from 'components/SignozModal/SignozModal';
import {
useFunnelDetails,
useFunnelsList,
} from 'hooks/TracesFunnels/useFunnels';
import { isEqual } from 'lodash-es';
import FunnelConfiguration from 'pages/TracesFunnelDetails/components/FunnelConfiguration/FunnelConfiguration';
import { TracesFunnelsContentRenderer } from 'pages/TracesFunnels';
import CreateFunnel from 'pages/TracesFunnels/components/CreateFunnel/CreateFunnel';
import { FunnelListItem } from 'pages/TracesFunnels/components/FunnelsList/FunnelsList';
import {
FunnelProvider,
useFunnelContext,
} from 'pages/TracesFunnels/FunnelContext';
import { filterFunnelsByQuery } from 'pages/TracesFunnels/utils';
import { Span } from 'types/api/trace/getTraceV2';
import { FunnelData } from 'types/api/traceFunnels';
import './AddSpanToFunnelModal.styles.scss';
enum ModalView {
LIST = 'list',
DETAILS = 'details',
}
function FunnelDetailsView({
funnel,
span,
triggerAutoSave,
showNotifications,
onChangesDetected,
triggerDiscard,
}: {
funnel: FunnelData;
span: Span;
triggerAutoSave: boolean;
showNotifications: boolean;
onChangesDetected: (hasChanges: boolean) => void;
triggerDiscard: boolean;
}): JSX.Element {
const { handleRestoreSteps, steps } = useFunnelContext();
// Track changes between current steps and original steps
useEffect(() => {
const hasChanges = !isEqual(steps, funnel.steps);
if (onChangesDetected) {
onChangesDetected(hasChanges);
}
}, [steps, funnel.steps, onChangesDetected]);
// Handle discard when triggered from parent
useEffect(() => {
if (triggerDiscard && funnel.steps) {
handleRestoreSteps(funnel.steps);
}
}, [triggerDiscard, funnel.steps, handleRestoreSteps]);
return (
<div className="add-span-to-funnel-modal__details">
<FunnelListItem
funnel={funnel}
shouldRedirectToTracesListOnDeleteSuccess={false}
isSpanDetailsPage
/>
<FunnelConfiguration
funnel={funnel}
isTraceDetailsPage
span={span}
triggerAutoSave={triggerAutoSave}
showNotifications={showNotifications}
/>
</div>
);
}
interface AddSpanToFunnelModalProps {
isOpen: boolean;
onClose: () => void;
span: Span;
}
function AddSpanToFunnelModal({
isOpen,
onClose,
span,
}: AddSpanToFunnelModalProps): JSX.Element {
const [activeView, setActiveView] = useState<ModalView>(ModalView.LIST);
const [searchQuery, setSearchQuery] = useState<string>('');
const [selectedFunnelId, setSelectedFunnelId] = useState<string | undefined>(
undefined,
);
const [isCreateModalOpen, setIsCreateModalOpen] = useState<boolean>(false);
const [triggerSave, setTriggerSave] = useState<boolean>(false);
const [isUnsavedChanges, setIsUnsavedChanges] = useState<boolean>(false);
const [triggerDiscard, setTriggerDiscard] = useState<boolean>(false);
const [isCreatedFromSpan, setIsCreatedFromSpan] = useState<boolean>(false);
const handleSearch = (e: ChangeEvent<HTMLInputElement>): void => {
setSearchQuery(e.target.value);
};
const { data, isLoading, isError, isFetching } = useFunnelsList();
const filteredData = useMemo(
() =>
filterFunnelsByQuery(data?.payload || [], searchQuery).sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
),
[data?.payload, searchQuery],
);
const {
data: funnelDetails,
isLoading: isFunnelDetailsLoading,
isFetching: isFunnelDetailsFetching,
} = useFunnelDetails({
funnelId: selectedFunnelId,
});
const handleFunnelClick = (funnel: FunnelData): void => {
setSelectedFunnelId(funnel.funnel_id);
setActiveView(ModalView.DETAILS);
setIsCreatedFromSpan(false);
};
const handleBack = (): void => {
setActiveView(ModalView.LIST);
setSelectedFunnelId(undefined);
setIsUnsavedChanges(false);
setTriggerSave(false);
setIsCreatedFromSpan(false);
};
const handleCreateNewClick = (): void => {
setIsCreateModalOpen(true);
};
const handleSaveFunnel = (): void => {
setTriggerSave(true);
// Reset trigger after a brief moment to allow the save to be processed
setTimeout(() => {
setTriggerSave(false);
onClose();
}, 100);
};
const handleDiscard = (): void => {
setTriggerDiscard(true);
// Reset trigger after a brief moment
setTimeout(() => {
setTriggerDiscard(false);
onClose();
}, 100);
};
const renderListView = (): JSX.Element => (
<div className="add-span-to-funnel-modal">
{!!filteredData?.length && (
<div className="add-span-to-funnel-modal__search">
<Input
className="add-span-to-funnel-modal__search-input"
placeholder="Search by name, description, or tags..."
prefix={<Search size={12} />}
value={searchQuery}
onChange={handleSearch}
/>
</div>
)}
<div className="add-span-to-funnel-modal__list">
<OverlayScrollbar>
<TracesFunnelsContentRenderer
isError={isError}
isLoading={isLoading || isFetching}
data={filteredData || []}
onCreateFunnel={handleCreateNewClick}
onFunnelClick={(funnel: FunnelData): void => handleFunnelClick(funnel)}
shouldRedirectToTracesListOnDeleteSuccess={false}
/>
</OverlayScrollbar>
</div>
<CreateFunnel
isOpen={isCreateModalOpen}
onClose={(funnelId): void => {
if (funnelId) {
setSelectedFunnelId(funnelId);
setActiveView(ModalView.DETAILS);
setIsCreatedFromSpan(true);
}
setIsCreateModalOpen(false);
}}
redirectToDetails={false}
/>
</div>
);
const renderDetailsView = ({ span }: { span: Span }): JSX.Element => (
<div className="add-span-to-funnel-modal add-span-to-funnel-modal--details">
<Button
type="text"
className="add-span-to-funnel-modal__back-button"
onClick={handleBack}
>
<ArrowLeft size={14} />
All funnels
</Button>
<div className="traces-funnel-details">
<div className="traces-funnel-details__steps-config">
<Spin
className="add-span-to-funnel-modal__loading-spinner"
spinning={isFunnelDetailsLoading || isFunnelDetailsFetching}
indicator={<Loader className="animate-spin" size="md" />}
>
{selectedFunnelId && funnelDetails?.payload && (
<FunnelProvider
funnelId={selectedFunnelId}
hasSingleStep={isCreatedFromSpan}
>
<FunnelDetailsView
funnel={funnelDetails.payload}
span={span}
triggerAutoSave={triggerSave}
showNotifications
onChangesDetected={setIsUnsavedChanges}
triggerDiscard={triggerDiscard}
/>
</FunnelProvider>
)}
</Spin>
</div>
</div>
</div>
);
return (
<SignozModal
open={isOpen}
onCancel={onClose}
width={570}
title="Add span to funnel"
className={cx('add-span-to-funnel-modal-container', {
'add-span-to-funnel-modal-container--details':
activeView === ModalView.DETAILS,
})}
footer={
activeView === ModalView.DETAILS
? [
<Button
type="default"
key="discard"
onClick={handleDiscard}
className="add-span-to-funnel-modal__discard-button"
disabled={!isUnsavedChanges}
>
Discard
</Button>,
<Button
key="save"
type="primary"
className="add-span-to-funnel-modal__save-button"
onClick={handleSaveFunnel}
disabled={!isUnsavedChanges}
icon={<Check size={14} color="var(--bg-vanilla-100)" />}
>
Save Funnel
</Button>,
]
: [
<Button
key="create"
type="default"
className="add-span-to-funnel-modal__create-button"
onClick={handleCreateNewClick}
icon={<Plus size={14} />}
>
Create new funnel
</Button>,
]
}
>
{activeView === ModalView.LIST
? renderListView()
: renderDetailsView({ span })}
</SignozModal>
);
}
export default AddSpanToFunnelModal;

View File

@@ -1,28 +0,0 @@
.span-line-action-buttons {
display: flex;
position: absolute;
transform: translate(-50%, -50%);
top: 50%;
right: 0;
cursor: pointer;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
.ant-btn-default {
border: none;
box-shadow: none;
padding: 9px;
justify-content: center;
align-items: center;
display: flex;
&.active-tab {
background-color: var(--l1-border);
}
}
.copy-span-btn {
border-color: var(--l1-border) !important;
}
}

View File

@@ -1,131 +0,0 @@
import { fireEvent, screen } from '@testing-library/react';
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
import { render } from 'tests/test-utils';
import { Span } from 'types/api/trace/getTraceV2';
import SpanLineActionButtons from '../index';
// Mock the useCopySpanLink hook
jest.mock('hooks/trace/useCopySpanLink');
const mockSpan: Span = {
spanId: 'test-span-id',
name: 'test-span',
serviceName: 'test-service',
durationNano: 1000,
timestamp: 1234567890,
rootSpanId: 'test-root-span-id',
parentSpanId: 'test-parent-span-id',
traceId: 'test-trace-id',
hasError: false,
kind: 0,
references: [],
tagMap: {},
event: [],
rootName: 'test-root-name',
statusMessage: 'test-status-message',
statusCodeString: 'test-status-code-string',
spanKind: 'test-span-kind',
hasChildren: false,
hasSibling: false,
subTreeNodeCount: 0,
level: 0,
};
describe('SpanLineActionButtons', () => {
beforeEach(() => {
// Clear mock before each test
jest.clearAllMocks();
});
it('renders copy link button with correct icon', () => {
(useCopySpanLink as jest.Mock).mockReturnValue({
onSpanCopy: jest.fn(),
});
render(<SpanLineActionButtons span={mockSpan} />);
// Check if the button is rendered with an icon
const copyButton = screen.getByRole('button');
expect(copyButton).toBeInTheDocument();
expect(copyButton.querySelector('svg')).toBeInTheDocument();
});
it('calls onSpanCopy when copy button is clicked', () => {
const mockOnSpanCopy = jest.fn();
(useCopySpanLink as jest.Mock).mockReturnValue({
onSpanCopy: mockOnSpanCopy,
});
render(<SpanLineActionButtons span={mockSpan} />);
// Click the copy button
const copyButton = screen.getByRole('button');
fireEvent.click(copyButton);
// Verify the copy function was called
expect(mockOnSpanCopy).toHaveBeenCalledTimes(1);
});
it('applies correct styling classes', () => {
(useCopySpanLink as jest.Mock).mockReturnValue({
onSpanCopy: jest.fn(),
});
render(<SpanLineActionButtons span={mockSpan} />);
// Check if the main container has the correct class
const container = screen
.getByRole('button')
.closest('.span-line-action-buttons');
expect(container).toHaveClass('span-line-action-buttons');
// Check if the button has the correct class
const copyButton = screen.getByRole('button');
expect(copyButton).toHaveClass('copy-span-btn');
});
it('copies span link to clipboard when copy button is clicked', () => {
const mockSetCopy = jest.fn();
const mockUrlQuery = {
delete: jest.fn(),
set: jest.fn(),
toString: jest.fn().mockReturnValue('spanId=test-span-id'),
};
const mockPathname = '/test-path';
const mockLocation = {
origin: 'http://localhost:3000',
};
// Mock window.location
Object.defineProperty(window, 'location', {
value: mockLocation,
writable: true,
});
// Mock useCopySpanLink hook
(useCopySpanLink as jest.Mock).mockReturnValue({
onSpanCopy: (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
mockUrlQuery.delete('spanId');
mockUrlQuery.set('spanId', mockSpan.spanId);
const link = `${
window.location.origin
}${mockPathname}?${mockUrlQuery.toString()}`;
mockSetCopy(link);
},
});
render(<SpanLineActionButtons span={mockSpan} />);
// Click the copy button
const copyButton = screen.getByRole('button');
fireEvent.click(copyButton);
// Verify the copy function was called with correct link
expect(mockSetCopy).toHaveBeenCalledWith(
'http://localhost:3000/test-path?spanId=test-span-id',
);
});
});

View File

@@ -1,28 +0,0 @@
import { Link } from '@signozhq/icons';
import { Button, Tooltip } from 'antd';
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
import { Span } from 'types/api/trace/getTraceV2';
import './SpanLineActionButtons.styles.scss';
export interface SpanLineActionButtonsProps {
span: Span;
}
export default function SpanLineActionButtons({
span,
}: SpanLineActionButtonsProps): JSX.Element {
const { onSpanCopy } = useCopySpanLink(span);
return (
<div className="span-line-action-buttons">
<Tooltip title="Copy Span Link">
<Button
size="small"
icon={<Link size={14} />}
onClick={onSpanCopy}
className="copy-span-btn"
/>
</Tooltip>
</div>
);
}

View File

@@ -1,9 +0,0 @@
.trace-waterfall {
height: calc(70vh - 236px);
.loading-skeleton {
justify-content: center;
align-items: center;
padding: 20px;
}
}

View File

@@ -1,137 +0,0 @@
import { Dispatch, SetStateAction, useMemo } from 'react';
import { Skeleton } from 'antd';
import { AxiosError } from 'axios';
import Spinner from 'components/Spinner';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { GetTraceV2SuccessResponse, Span } from 'types/api/trace/getTraceV2';
import { TraceWaterfallStates } from './constants';
import Error from './TraceWaterfallStates/Error/Error';
import NoData from './TraceWaterfallStates/NoData/NoData';
import Success from './TraceWaterfallStates/Success/Success';
import './TraceWaterfall.styles.scss';
export interface IInterestedSpan {
spanId: string;
isUncollapsed: boolean;
}
interface ITraceWaterfallProps {
traceId: string;
uncollapsedNodes: string[];
traceData:
| SuccessResponse<GetTraceV2SuccessResponse, unknown>
| ErrorResponse
| undefined;
isFetchingTraceData: boolean;
errorFetchingTraceData: unknown;
interestedSpanId: IInterestedSpan;
setInterestedSpanId: Dispatch<SetStateAction<IInterestedSpan>>;
setTraceFlamegraphStatsWidth: Dispatch<SetStateAction<number>>;
selectedSpan: Span | undefined;
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
}
function TraceWaterfall(props: ITraceWaterfallProps): JSX.Element {
const {
traceData,
isFetchingTraceData,
errorFetchingTraceData,
interestedSpanId,
traceId,
uncollapsedNodes,
setInterestedSpanId,
setTraceFlamegraphStatsWidth,
setSelectedSpan,
selectedSpan,
} = props;
// get the current state of trace waterfall based on the API lifecycle
const traceWaterfallState = useMemo(() => {
if (isFetchingTraceData) {
if (
traceData &&
traceData.payload &&
traceData.payload.spans &&
traceData.payload.spans.length > 0
) {
return TraceWaterfallStates.FETCHING_WITH_OLD_DATA_PRESENT;
}
return TraceWaterfallStates.LOADING;
}
if (errorFetchingTraceData) {
return TraceWaterfallStates.ERROR;
}
if (
traceData &&
traceData.payload &&
traceData.payload.spans &&
traceData.payload.spans.length === 0
) {
return TraceWaterfallStates.NO_DATA;
}
return TraceWaterfallStates.SUCCESS;
}, [errorFetchingTraceData, isFetchingTraceData, traceData]);
// capture the spans from the response, since we do not need to do any manipulation on the same we will keep this as a simple constant [ memoized ]
const spans = useMemo(
() => traceData?.payload?.spans || [],
[traceData?.payload?.spans],
);
// get the content based on the current state of the trace waterfall
const getContent = useMemo(() => {
switch (traceWaterfallState) {
case TraceWaterfallStates.LOADING:
return (
<div className="loading-skeleton">
<Skeleton active paragraph={{ rows: 6 }} />
</div>
);
case TraceWaterfallStates.ERROR:
return <Error error={errorFetchingTraceData as AxiosError} />;
case TraceWaterfallStates.NO_DATA:
return <NoData id={traceId} />;
case TraceWaterfallStates.SUCCESS:
case TraceWaterfallStates.FETCHING_WITH_OLD_DATA_PRESENT:
return (
<Success
spans={spans}
traceMetadata={{
traceId,
startTime: traceData?.payload?.startTimestampMillis || 0,
endTime: traceData?.payload?.endTimestampMillis || 0,
hasMissingSpans: traceData?.payload?.hasMissingSpans || false,
}}
interestedSpanId={interestedSpanId || ''}
uncollapsedNodes={uncollapsedNodes}
setInterestedSpanId={setInterestedSpanId}
setTraceFlamegraphStatsWidth={setTraceFlamegraphStatsWidth}
selectedSpan={selectedSpan}
setSelectedSpan={setSelectedSpan}
/>
);
default:
return <Spinner tip="Fetching the trace!" />;
}
}, [
errorFetchingTraceData,
interestedSpanId,
selectedSpan,
setInterestedSpanId,
setSelectedSpan,
setTraceFlamegraphStatsWidth,
spans,
traceData?.payload?.endTimestampMillis,
traceData?.payload?.hasMissingSpans,
traceData?.payload?.startTimestampMillis,
traceId,
traceWaterfallState,
uncollapsedNodes,
]);
return <div className="trace-waterfall">{getContent}</div>;
}
export default TraceWaterfall;

View File

@@ -1,30 +0,0 @@
.error-waterfall {
display: flex;
padding: 12px;
margin: 20px;
gap: 12px;
align-items: flex-start;
border-radius: 4px;
background: var(--danger-background);
.text {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
flex-shrink: 0;
}
.value {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}

View File

@@ -1,26 +0,0 @@
import { Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { AxiosError } from 'axios';
import './Error.styles.scss';
interface IErrorProps {
error: AxiosError;
}
function Error(props: IErrorProps): JSX.Element {
const { error } = props;
return (
<div className="error-waterfall">
<Typography.Text className="text">Something went wrong!</Typography.Text>
<Tooltip title={error?.message}>
<Typography.Text className="value" truncate={1}>
{error?.message}
</Typography.Text>
</Tooltip>
</div>
);
}
export default Error;

View File

@@ -1,12 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
interface INoDataProps {
id: string;
}
function NoData(props: INoDataProps): JSX.Element {
const { id } = props;
return <Typography.Text>No Trace found with the id: {id} </Typography.Text>;
}
export default NoData;

View File

@@ -1,46 +0,0 @@
.filter-row {
display: flex;
align-items: center;
padding: 16px 20px 0px 20px;
gap: 12px;
.query-builder-search-v2 {
width: 100%;
}
.pre-next-toggle {
display: flex;
flex-shrink: 0;
gap: 12px;
.ant-typography {
display: flex;
align-items: center;
color: var(--l2-foreground);
font-family: 'Geist Mono';
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
}
.ant-btn {
display: flex;
align-items: center;
justify-content: center;
box-shadow: none;
}
}
.no-results {
display: flex;
align-items: center;
flex-shrink: 0;
color: var(--l2-foreground);
font-family: 'Geist Mono';
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
}
}

View File

@@ -1,214 +0,0 @@
import { useCallback, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import {
ChevronDown,
ChevronUp,
Loader,
SolidInfoCircle,
} from '@signozhq/icons';
import { Button, Spin, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { AxiosError } from 'axios';
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { uniqBy } from 'lodash-es';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { TracesAggregatorOperator } from 'types/common/queryBuilder';
import { BASE_FILTER_QUERY } from './constants';
import './Filters.styles.scss';
function prepareQuery(filters: TagFilter, traceID: string): Query {
return {
...initialQueriesMap.traces,
builder: {
...initialQueriesMap.traces.builder,
queryData: [
{
...initialQueriesMap.traces.builder.queryData[0],
aggregateOperator: TracesAggregatorOperator.NOOP,
orderBy: [{ columnName: 'timestamp', order: 'asc' }],
filters: {
...filters,
items: [
...filters.items,
{
id: '5ab8e1cf',
key: {
key: 'trace_id',
dataType: DataTypes.String,
type: '',
id: 'trace_id--string----true',
},
op: '=',
value: traceID,
},
],
},
},
],
},
};
}
function Filters({
startTime,
endTime,
traceID,
onFilteredSpansChange = (): void => {},
}: {
startTime: number;
endTime: number;
traceID: string;
onFilteredSpansChange?: (spanIds: string[], isFilterActive: boolean) => void;
}): JSX.Element {
const [filters, setFilters] = useState<TagFilter>(
BASE_FILTER_QUERY.filters || { items: [], op: 'AND' },
);
const [noData, setNoData] = useState<boolean>(false);
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
const [currentSearchedIndex, setCurrentSearchedIndex] = useState<number>(0);
const handleFilterChange = useCallback(
(value: TagFilter): void => {
if (value.items.length === 0) {
setFilteredSpanIds([]);
onFilteredSpansChange?.([], false);
setCurrentSearchedIndex(0);
setNoData(false);
}
setFilters(value);
},
[onFilteredSpansChange],
);
const { search } = useLocation();
const history = useHistory();
const handlePrevNext = useCallback(
(index: number, spanId?: string): void => {
const searchParams = new URLSearchParams(search);
if (spanId) {
searchParams.set('spanId', spanId);
} else {
searchParams.set('spanId', filteredSpanIds[index]);
}
history.replace({ search: searchParams.toString() });
},
[filteredSpanIds, history, search],
);
const { isFetching, error } = useGetQueryRange(
{
query: prepareQuery(filters, traceID),
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
start: startTime,
end: endTime,
params: {
dataSource: 'traces',
},
tableParams: {
pagination: {
offset: 0,
limit: 200,
},
selectColumns: [
{
key: 'name',
dataType: 'string',
type: 'tag',
id: 'name--string--tag--true',
isIndexed: false,
},
],
},
},
DEFAULT_ENTITY_VERSION,
{
queryKey: [filters],
enabled: filters.items.length > 0,
onSuccess: (data) => {
const isFilterActive = filters.items.length > 0;
if (data?.payload.data.newResult.data.result[0].list) {
const uniqueSpans = uniqBy(
data?.payload.data.newResult.data.result[0].list,
'data.spanID',
);
const spanIds = uniqueSpans.map((val) => val.data.spanID);
setFilteredSpanIds(spanIds);
onFilteredSpansChange?.(spanIds, isFilterActive);
handlePrevNext(0, spanIds[0]);
setNoData(false);
} else {
setNoData(true);
setFilteredSpanIds([]);
onFilteredSpansChange?.([], isFilterActive);
setCurrentSearchedIndex(0);
}
},
},
);
return (
<div className="filter-row">
<QueryBuilderSearchV2
query={{
...BASE_FILTER_QUERY,
filters,
}}
onChange={handleFilterChange}
hideSpanScopeSelector={false}
skipQueryBuilderRedirect
selectProps={{ listHeight: 125 }}
/>
{filteredSpanIds.length > 0 && (
<div className="pre-next-toggle">
<Typography.Text>
{currentSearchedIndex + 1} / {filteredSpanIds.length}
</Typography.Text>
<Button
icon={<ChevronUp size={14} />}
disabled={currentSearchedIndex === 0}
type="text"
onClick={(): void => {
handlePrevNext(currentSearchedIndex - 1);
setCurrentSearchedIndex((prev) => prev - 1);
}}
/>
<Button
icon={<ChevronDown size={14} />}
type="text"
disabled={currentSearchedIndex === filteredSpanIds.length - 1}
onClick={(): void => {
handlePrevNext(currentSearchedIndex + 1);
setCurrentSearchedIndex((prev) => prev + 1);
}}
/>
</div>
)}
{isFetching && (
<Spin indicator={<Loader className="animate-spin" />} size="small" />
)}
{error && (
<Tooltip title={(error as AxiosError)?.message || 'Something went wrong'}>
<SolidInfoCircle size={14} />
</Tooltip>
)}
{noData && (
<Typography.Text className="no-results">No results found</Typography.Text>
)}
</div>
);
}
Filters.defaultProps = {
onFilteredSpansChange: undefined,
};
export default Filters;

View File

@@ -1,38 +0,0 @@
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
export const BASE_FILTER_QUERY: IBuilderQuery = {
queryName: 'A',
dataSource: DataSource.TRACES,
aggregateOperator: 'noop',
aggregateAttribute: {
id: '------false',
dataType: DataTypes.EMPTY,
key: '',
type: '',
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
functions: [],
filters: {
items: [],
op: 'AND',
},
expression: 'A',
disabled: false,
stepInterval: 60,
having: [],
limit: 200,
orderBy: [
{
columnName: 'timestamp',
order: 'desc',
},
],
groupBy: [],
legend: '',
reduceTo: ReduceOperators.AVG,
offset: 0,
selectColumns: [],
};

View File

@@ -1,416 +0,0 @@
.success-content {
overflow-y: hidden;
overflow-x: hidden;
max-width: 100%;
.missing-spans {
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
margin: 16px;
padding: 12px;
border-radius: 4px;
background: color-mix(in srgb, var(--bg-robin-600) 10%, transparent);
.left-info {
display: flex;
align-items: center;
gap: 8px;
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
.text {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
.right-info {
display: flex;
align-items: center;
justify-content: center;
flex-direction: row-reverse;
gap: 8px;
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.right-info:hover {
background-color: unset;
color: var(--bg-robin-200);
}
}
.waterfall-table {
height: calc(70vh - 236px);
overflow: auto;
overflow-x: hidden;
padding: 0px 20px 20px 20px;
&::-webkit-scrollbar {
width: 0.1rem;
}
// default table overrides css for table v3
.div-table {
width: 100% !important;
border: none !important;
}
.div-thead {
position: sticky;
top: 0;
z-index: 2;
background-color: var(--l1-background) !important;
.div-tr {
height: 16px;
}
}
.div-tr {
display: flex;
width: 100%;
align-items: center;
height: 54px;
}
.div-tr:hover {
border-radius: 4px;
background: color-mix(
in srgb,
var(--bg-robin-200) 6%,
transparent
) !important;
.div-td .span-overview .second-row .add-funnel-button {
opacity: 1;
}
.span-overview {
background: unset !important;
.span-overview-content {
background: unset !important;
}
}
}
.div-th,
.div-td {
box-shadow: none;
padding: 0px !important;
}
.div-th {
padding: 2px 4px;
position: relative;
font-weight: bold;
text-align: center;
}
.div-td {
display: flex;
height: 54px;
align-items: center;
overflow: hidden;
.span-overview {
display: flex;
align-items: center;
flex-shrink: 0;
height: 100%;
width: 100%;
cursor: pointer;
.connector-lines {
display: flex;
}
.span-overview-content {
display: flex;
flex-shrink: 0;
flex-direction: column;
align-items: flex-start;
gap: 5px;
width: 100%;
background-color: var(--l1-background);
height: 100%;
justify-content: center;
&:not(:first-child) {
.first-row {
width: calc(100% - 28px);
}
}
.first-row {
display: flex;
align-items: center;
justify-content: space-between;
height: 20px;
width: 100%;
.span-det {
display: flex;
gap: 6px;
flex-shrink: 0;
align-items: center;
.collapse-uncollapse-button {
display: flex;
align-items: center;
justify-content: center;
padding: 4px 4px;
gap: 4px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l1-border);
box-shadow: none;
height: 20px;
.children-count {
color: var(--l2-foreground);
font-family: 'Space Mono';
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: 0.28px;
}
}
.span-name {
color: var(--l1-foreground);
font-family: 'Inter';
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: 0.28px;
}
}
.status-code-container {
display: flex;
padding-right: 10px;
.status-code {
display: flex;
height: 20px;
padding: 3px;
align-items: center;
border-radius: 3px;
}
.success {
border: 1px solid var(--primary-background);
background: var(--primary-background);
}
.error {
border: 1px solid var(--danger-background);
background: var(--danger-background);
}
}
}
.second-row {
display: flex;
align-items: center;
gap: 8px;
height: 18px;
width: 100%;
.service-name {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
}
.add-funnel-button {
position: relative;
z-index: 1;
opacity: 0;
display: flex;
align-items: center;
gap: 6px;
transition: opacity 0.1s ease-in-out;
&__separator {
color: var(--l2-foreground);
}
&__button {
display: flex;
align-items: center;
justify-content: center;
}
}
}
}
}
.span-duration {
display: flex;
flex-direction: column;
height: 54px;
position: relative;
width: 100%;
padding-left: 15px;
cursor: pointer;
.span-line {
position: relative;
height: 12px;
top: 35%;
border-radius: 6px;
}
.event-dot {
position: absolute;
top: 50%;
transform: translate(-50%, -50%) rotate(45deg);
width: 6px;
height: 6px;
background-color: var(--primary-background);
border: 1px solid var(--bg-robin-600);
cursor: pointer;
z-index: 1;
&.error {
background-color: var(--danger-background);
border-color: var(--bg-cherry-600);
}
&:hover {
transform: translate(-50%, -50%) rotate(45deg) scale(1.5);
}
}
.span-line-text {
position: relative;
top: 40%;
font-variant-numeric: lining-nums tabular-nums stacked-fractions
slashed-zero;
font-feature-settings:
'case' on,
'cpsp' on,
'dlig' on,
'salt' on;
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
}
}
.interested-span,
.selected-non-matching-span {
border-radius: 4px;
background: color-mix(
in srgb,
var(--bg-robin-200) 6%,
transparent
) !important;
.span-overview-content {
background: unset;
}
}
.dimmed-span {
opacity: 0.4;
}
.highlighted-span {
opacity: 1;
}
.selected-non-matching-span {
.span-overview-content,
.span-line-text {
opacity: 0.5;
}
}
}
.div-td + .div-td {
border-left: 1px solid var(--l1-border);
}
.div-th + .div-th {
border-left: 1px solid var(--l1-border);
}
.div-tr .div-th:nth-child(2) {
width: calc(100% - var(--header-span-name-size) * 1px) !important;
}
.div-tr .div-td:nth-child(2) {
width: calc(100% - var(--header-span-name-size) * 1px) !important;
}
.resizer {
width: 10px !important;
position: absolute;
top: 0;
height: calc(70vh - 236px);
right: 0;
width: 2px;
background: color-mix(in srgb, var(--bg-aqua-500) 20%, transparent);
cursor: col-resize;
user-select: none;
touch-action: none;
}
.resizer.isResizing {
background: color-mix(in srgb, var(--bg-aqua-500) 20%, transparent);
opacity: 1;
}
@media (hover: hover) {
.resizer {
opacity: 0;
}
*:hover > .resizer {
opacity: 1;
}
}
}
.missing-spans-waterfall-table {
height: calc(70vh - 312px);
}
}
.span-dets {
.related-logs {
display: flex;
width: 160px;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 8px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l1-border);
box-shadow: none;
}
}

View File

@@ -1,591 +0,0 @@
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { ColumnDef, createColumnHelper } from '@tanstack/react-table';
import { Virtualizer } from '@tanstack/react-virtual';
import { Button, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import SpanHoverCard from 'components/SpanHoverCard/SpanHoverCard';
import { TableV3 } from 'components/TableV3/TableV3';
import { themeColors } from 'constants/theme';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import AddSpanToFunnelModal from 'container/TraceWaterfall/AddSpanToFunnelModal/AddSpanToFunnelModal';
import SpanLineActionButtons from 'container/TraceWaterfall/SpanLineActionButtons';
import { IInterestedSpan } from 'container/TraceWaterfall/TraceWaterfall';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import {
ArrowUpRight,
ChevronDown,
ChevronRight,
CircleAlert,
Leaf,
} from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
import { Span } from 'types/api/trace/getTraceV2';
import { toFixed } from 'utils/toFixed';
import funnelAddUrl from '@/assets/Icons/funnel-add.svg';
import Filters from './Filters/Filters';
import './Success.styles.scss';
// css config
const CONNECTOR_WIDTH = 28;
const VERTICAL_CONNECTOR_WIDTH = 1;
interface ITraceMetadata {
traceId: string;
startTime: number;
endTime: number;
hasMissingSpans: boolean;
}
interface ISuccessProps {
spans: Span[];
traceMetadata: ITraceMetadata;
interestedSpanId: IInterestedSpan;
uncollapsedNodes: string[];
setInterestedSpanId: Dispatch<SetStateAction<IInterestedSpan>>;
setTraceFlamegraphStatsWidth: Dispatch<SetStateAction<number>>;
selectedSpan: Span | undefined;
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
}
function SpanOverview({
span,
isSpanCollapsed,
handleCollapseUncollapse,
handleSpanClick,
handleAddSpanToFunnel,
selectedSpan,
filteredSpanIds,
isFilterActive,
traceMetadata,
}: {
span: Span;
isSpanCollapsed: boolean;
handleCollapseUncollapse: (id: string, collapse: boolean) => void;
selectedSpan: Span | undefined;
handleSpanClick: (span: Span) => void;
handleAddSpanToFunnel: (span: Span) => void;
filteredSpanIds: string[];
isFilterActive: boolean;
traceMetadata: ITraceMetadata;
}): JSX.Element {
const isRootSpan = span.level === 0;
const { hasEditPermission } = useAppContext();
let color = generateColor(span.serviceName, themeColors.traceDetailColors);
if (span.hasError) {
color = `var(--danger-background)`;
}
// Smart highlighting logic
const isMatching =
isFilterActive && (filteredSpanIds || []).includes(span.spanId);
const isSelected = selectedSpan?.spanId === span.spanId;
const isDimmed = isFilterActive && !isMatching && !isSelected;
const isHighlighted = isFilterActive && isMatching && !isSelected;
const isSelectedNonMatching = isSelected && isFilterActive && !isMatching;
return (
<SpanHoverCard span={span} traceMetadata={traceMetadata}>
<div
className={cx('span-overview', {
'interested-span': isSelected && (!isFilterActive || isMatching),
'highlighted-span': isHighlighted,
'selected-non-matching-span': isSelectedNonMatching,
'dimmed-span': isDimmed,
})}
style={{
paddingLeft: `${
isRootSpan
? span.level * CONNECTOR_WIDTH
: (span.level - 1) * (CONNECTOR_WIDTH + VERTICAL_CONNECTOR_WIDTH)
}px`,
backgroundImage: `url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" width="28" height="54"><line x1="0" y1="0" x2="0" y2="54" stroke="rgb(29 33 45)" stroke-width="1" /></svg>')`,
backgroundRepeat: 'repeat',
backgroundSize: `${CONNECTOR_WIDTH + 1}px 54px`,
}}
onClick={(): void => handleSpanClick(span)}
>
{!isRootSpan && (
<div className="connector-lines">
<div
style={{
width: `${CONNECTOR_WIDTH}px`,
height: '1px',
borderTop: '1px solid var(--bg-slate-400)',
display: 'flex',
flexShrink: 0,
position: 'relative',
top: '-10px',
}}
/>
</div>
)}
<div className="span-overview-content">
<section className="first-row">
<div className="span-det">
{span.hasChildren ? (
<Button
onClick={(event): void => {
event.stopPropagation();
event.preventDefault();
handleCollapseUncollapse(span.spanId, !isSpanCollapsed);
}}
className="collapse-uncollapse-button"
>
{isSpanCollapsed ? (
<ChevronRight size={14} />
) : (
<ChevronDown size={14} />
)}
<Typography.Text className="children-count">
{span.subTreeNodeCount}
</Typography.Text>
</Button>
) : (
<Button className="collapse-uncollapse-button">
<Leaf size={14} />
</Button>
)}
<Typography.Text className="span-name">{span.name}</Typography.Text>
</div>
<HttpStatusBadge statusCode={span.tagMap?.['http.status_code']} />
</section>
<section className="second-row">
<div style={{ width: '2px', background: color, height: '100%' }} />
<Typography.Text className="service-name">
{span.serviceName}
</Typography.Text>
{!!span.serviceName && !!span.name && (
<div className="add-funnel-button">
<span className="add-funnel-button__separator">·</span>
<Tooltip
title={
!hasEditPermission
? 'You need editor or admin access to add spans to funnels'
: ''
}
>
<Button
type="text"
size="small"
className="add-funnel-button__button"
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
handleAddSpanToFunnel(span);
}}
disabled={!hasEditPermission}
icon={
<img
className="add-funnel-button__icon"
src={funnelAddUrl}
alt="funnel-icon"
/>
}
/>
</Tooltip>
</div>
)}
</section>
</div>
</div>
</SpanHoverCard>
);
}
export function SpanDuration({
span,
traceMetadata,
handleSpanClick,
selectedSpan,
filteredSpanIds,
isFilterActive,
}: {
span: Span;
traceMetadata: ITraceMetadata;
selectedSpan: Span | undefined;
handleSpanClick: (span: Span) => void;
filteredSpanIds: string[];
isFilterActive: boolean;
}): JSX.Element {
const { time, timeUnitName } = convertTimeToRelevantUnit(
span.durationNano / 1e6,
);
const spread = traceMetadata.endTime - traceMetadata.startTime;
const leftOffset = ((span.timestamp - traceMetadata.startTime) * 1e2) / spread;
const width = (span.durationNano * 1e2) / (spread * 1e6);
let color = generateColor(span.serviceName, themeColors.traceDetailColors);
if (span.hasError) {
color = `var(--danger-background)`;
}
const [hasActionButtons, setHasActionButtons] = useState(false);
const isMatching =
isFilterActive && (filteredSpanIds || []).includes(span.spanId);
const isSelected = selectedSpan?.spanId === span.spanId;
const isDimmed = isFilterActive && !isMatching && !isSelected;
const isHighlighted = isFilterActive && isMatching && !isSelected;
const isSelectedNonMatching = isSelected && isFilterActive && !isMatching;
const handleMouseEnter = (): void => {
setHasActionButtons(true);
};
const handleMouseLeave = (): void => {
setHasActionButtons(false);
};
// Calculate text positioning to handle overflow cases
const textStyle = useMemo(() => {
const spanRightEdge = leftOffset + width;
const textWidthApprox = 8; // Approximate text width in percentage
// If span would cause text overflow, right-align text to span end
if (leftOffset > 100 - textWidthApprox) {
return {
right: `${100 - spanRightEdge}%`,
color,
textAlign: 'right' as const,
};
}
// Default: left-align text to span start
return {
left: `${leftOffset}%`,
color,
};
}, [leftOffset, width, color]);
return (
<SpanHoverCard span={span} traceMetadata={traceMetadata}>
<div
className={cx('span-duration', {
'interested-span': isSelected && (!isFilterActive || isMatching),
'highlighted-span': isHighlighted,
'selected-non-matching-span': isSelectedNonMatching,
'dimmed-span': isDimmed,
})}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={(): void => handleSpanClick(span)}
>
<div
className="span-line"
style={{
left: `${leftOffset}%`,
width: `${width}%`,
backgroundColor: color,
position: 'relative',
}}
>
{span.event?.map((event) => {
const eventTimeMs = event.timeUnixNano / 1e6;
const eventOffsetPercent =
((eventTimeMs - span.timestamp) / (span.durationNano / 1e6)) * 100;
const clampedOffset = Math.max(1, Math.min(eventOffsetPercent, 99));
const { isError } = event;
const { time, timeUnitName } = convertTimeToRelevantUnit(
eventTimeMs - span.timestamp,
);
return (
<Tooltip
key={`${span.spanId}-event-${event.name}-${event.timeUnixNano}`}
title={`${event.name} @ ${toFixed(time, 2)} ${timeUnitName}`}
>
<div
className={`event-dot ${isError ? 'error' : ''}`}
style={{
left: `${clampedOffset}%`,
}}
/>
</Tooltip>
);
})}
</div>
{hasActionButtons && <SpanLineActionButtons span={span} />}
<Typography.Text
className="span-line-text"
truncate={1}
style={textStyle}
>{`${toFixed(time, 2)} ${timeUnitName}`}</Typography.Text>
</div>
</SpanHoverCard>
);
}
// table config
const columnDefHelper = createColumnHelper<Span>();
function getWaterfallColumns({
handleCollapseUncollapse,
uncollapsedNodes,
traceMetadata,
selectedSpan,
handleSpanClick,
handleAddSpanToFunnel,
filteredSpanIds,
isFilterActive,
}: {
handleCollapseUncollapse: (id: string, collapse: boolean) => void;
uncollapsedNodes: string[];
traceMetadata: ITraceMetadata;
selectedSpan: Span | undefined;
handleSpanClick: (span: Span) => void;
handleAddSpanToFunnel: (span: Span) => void;
filteredSpanIds: string[];
isFilterActive: boolean;
}): ColumnDef<Span, any>[] {
const waterfallColumns: ColumnDef<Span, any>[] = [
columnDefHelper.display({
id: 'span-name',
header: '',
cell: (props): JSX.Element => (
<SpanOverview
span={props.row.original}
handleCollapseUncollapse={handleCollapseUncollapse}
isSpanCollapsed={!uncollapsedNodes.includes(props.row.original.spanId)}
selectedSpan={selectedSpan}
handleSpanClick={handleSpanClick}
handleAddSpanToFunnel={handleAddSpanToFunnel}
traceMetadata={traceMetadata}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
/>
),
size: 450,
/**
* Note: The TanStack table currently does not support percentage-based column sizing.
* Therefore, we specify both `minSize` and `maxSize` for the "span-name" column to ensure
* that its width remains between 240px and 900px. Setting a `maxSize` here is important
* because the "span-duration" column has column resizing disabled, making it difficult
* to enforce a minimum width for that column. By constraining the "span-name" column,
* we indirectly control the minimum width available for the "span-duration" column.
*/
minSize: 240,
maxSize: 900,
}),
columnDefHelper.display({
id: 'span-duration',
header: () => <div />,
enableResizing: false,
cell: (props): JSX.Element => (
<SpanDuration
span={props.row.original}
traceMetadata={traceMetadata}
selectedSpan={selectedSpan}
handleSpanClick={handleSpanClick}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
/>
),
}),
];
return waterfallColumns;
}
function Success(props: ISuccessProps): JSX.Element {
const {
spans,
traceMetadata,
interestedSpanId,
uncollapsedNodes,
setInterestedSpanId,
setTraceFlamegraphStatsWidth,
setSelectedSpan,
selectedSpan,
} = props;
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
const [isFilterActive, setIsFilterActive] = useState<boolean>(false);
const virtualizerRef = useRef<Virtualizer<HTMLDivElement, Element>>();
const handleFilteredSpansChange = useCallback(
(spanIds: string[], isActive: boolean) => {
setFilteredSpanIds(spanIds);
setIsFilterActive(isActive);
},
[],
);
const handleCollapseUncollapse = useCallback(
(spanId: string, collapse: boolean) => {
setInterestedSpanId({ spanId, isUncollapsed: !collapse });
},
[setInterestedSpanId],
);
const handleVirtualizerInstanceChanged = (
instance: Virtualizer<HTMLDivElement, Element>,
): void => {
const { range } = instance;
// when there are less than 500 elements in the API call that means there is nothing to fetch on top and bottom so
// do not trigger the API call
if (spans.length < 500) {
return;
}
if (range?.startIndex === 0 && instance.isScrolling) {
// do not trigger for trace root as nothing to fetch above
if (spans[0].level !== 0) {
setInterestedSpanId({ spanId: spans[0].spanId, isUncollapsed: false });
}
return;
}
if (range?.endIndex === spans.length - 1 && instance.isScrolling) {
setInterestedSpanId({
spanId: spans[spans.length - 1].spanId,
isUncollapsed: false,
});
}
};
const [isAddSpanToFunnelModalOpen, setIsAddSpanToFunnelModalOpen] =
useState(false);
const [selectedSpanToAddToFunnel, setSelectedSpanToAddToFunnel] = useState<
Span | undefined
>(undefined);
const handleAddSpanToFunnel = useCallback((span: Span): void => {
setIsAddSpanToFunnelModalOpen(true);
setSelectedSpanToAddToFunnel(span);
}, []);
const urlQuery = useUrlQuery();
const { safeNavigate } = useSafeNavigate();
const handleSpanClick = useCallback(
(span: Span): void => {
setSelectedSpan(span);
if (span?.spanId) {
urlQuery.set('spanId', span?.spanId);
}
safeNavigate({ search: urlQuery.toString() });
},
[setSelectedSpan, urlQuery, safeNavigate],
);
const columns = useMemo(
() =>
getWaterfallColumns({
handleCollapseUncollapse,
uncollapsedNodes,
traceMetadata,
selectedSpan,
handleSpanClick,
handleAddSpanToFunnel,
filteredSpanIds,
isFilterActive,
}),
[
handleCollapseUncollapse,
uncollapsedNodes,
traceMetadata,
selectedSpan,
handleSpanClick,
handleAddSpanToFunnel,
filteredSpanIds,
isFilterActive,
],
);
useEffect(() => {
if (interestedSpanId.spanId !== '' && virtualizerRef.current) {
const idx = spans.findIndex(
(span) => span.spanId === interestedSpanId.spanId,
);
if (idx !== -1) {
setTimeout(() => {
virtualizerRef.current?.scrollToIndex(idx, {
align: 'center',
behavior: 'auto',
});
}, 400);
setSelectedSpan(spans[idx]);
}
} else {
setSelectedSpan((prev) => {
if (!prev) {
return spans[0];
}
return prev;
});
}
}, [interestedSpanId, setSelectedSpan, spans]);
return (
<div className="success-content">
{traceMetadata.hasMissingSpans && (
<div className="missing-spans">
<section className="left-info">
<CircleAlert size={14} />
<Typography.Text className="text">
This trace has missing spans
</Typography.Text>
</section>
<Button
icon={<ArrowUpRight size={14} />}
className="right-info"
type="text"
onClick={(): WindowProxy | null =>
window.open(
'https://signoz.io/docs/userguide/traces/#missing-spans',
'_blank',
)
}
>
Learn More
</Button>
</div>
)}
<Filters
startTime={traceMetadata.startTime / 1e3}
endTime={traceMetadata.endTime / 1e3}
traceID={traceMetadata.traceId}
onFilteredSpansChange={handleFilteredSpansChange}
/>
<TableV3
columns={columns}
data={spans}
config={{
handleVirtualizerInstanceChanged,
}}
customClassName={cx(
'waterfall-table',
traceMetadata.hasMissingSpans ? 'missing-spans-waterfall-table' : '',
)}
virtualiserRef={virtualizerRef}
setColumnWidths={setTraceFlamegraphStatsWidth}
/>
{selectedSpanToAddToFunnel && (
<AddSpanToFunnelModal
span={selectedSpanToAddToFunnel}
isOpen={isAddSpanToFunnelModalOpen}
onClose={(): void => setIsAddSpanToFunnelModalOpen(false)}
/>
)}
</div>
);
}
export default Success;

View File

@@ -1,264 +0,0 @@
import useUrlQuery from 'hooks/useUrlQuery';
import { fireEvent, render, screen } from 'tests/test-utils';
import { Span } from 'types/api/trace/getTraceV2';
import { SpanDuration } from '../Success';
// Constants to avoid string duplication
const SPAN_DURATION_TEXT = '1.16 ms';
const SPAN_DURATION_CLASS = '.span-duration';
const INTERESTED_SPAN_CLASS = 'interested-span';
const HIGHLIGHTED_SPAN_CLASS = 'highlighted-span';
const DIMMED_SPAN_CLASS = 'dimmed-span';
const SELECTED_NON_MATCHING_SPAN_CLASS = 'selected-non-matching-span';
// Mock the hooks
jest.mock('hooks/useUrlQuery');
jest.mock('@signozhq/ui/badge', () => ({
...jest.requireActual('@signozhq/ui/badge'),
Badge: jest.fn(),
}));
const mockSpan: Span = {
spanId: 'test-span-id',
name: 'test-span',
serviceName: 'test-service',
durationNano: 1160000, // 1ms in nano
timestamp: 1234567890,
rootSpanId: 'test-root-span-id',
parentSpanId: 'test-parent-span-id',
traceId: 'test-trace-id',
hasError: false,
kind: 0,
references: [],
tagMap: {},
event: [],
rootName: 'test-root-name',
statusMessage: 'test-status-message',
statusCodeString: 'test-status-code-string',
spanKind: 'test-span-kind',
hasChildren: false,
hasSibling: false,
subTreeNodeCount: 0,
level: 0,
};
const mockTraceMetadata = {
traceId: 'test-trace-id',
startTime: 1234567000,
endTime: 1234569000,
hasMissingSpans: false,
};
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: mockSafeNavigate,
}),
}));
describe('SpanDuration', () => {
const mockSetSelectedSpan = jest.fn();
const mockUrlQuerySet = jest.fn();
const mockUrlQueryGet = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
// Mock URL query hook
(useUrlQuery as jest.Mock).mockReturnValue({
set: mockUrlQuerySet,
get: mockUrlQueryGet,
toString: () => 'spanId=test-span-id',
});
});
it('calls handleSpanClick when clicked', () => {
const mockHandleSpanClick = jest.fn();
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={undefined}
handleSpanClick={mockHandleSpanClick}
filteredSpanIds={[]}
isFilterActive={false}
/>,
);
// Find and click the span duration element
const spanElement = screen.getByText(SPAN_DURATION_TEXT);
fireEvent.click(spanElement);
// Verify handleSpanClick was called with the correct span
expect(mockHandleSpanClick).toHaveBeenCalledWith(mockSpan);
});
it('shows action buttons on hover', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={undefined}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={[]}
isFilterActive={false}
/>,
);
const spanElement = screen.getByText(SPAN_DURATION_TEXT);
// Initially, action buttons should not be visible
expect(screen.queryByRole('button')).not.toBeInTheDocument();
// Hover over the span
fireEvent.mouseEnter(spanElement);
// Action buttons should now be visible
expect(screen.getByRole('button')).toBeInTheDocument();
// Mouse leave should hide the buttons
fireEvent.mouseLeave(spanElement);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('applies interested-span class when span is selected', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={mockSpan}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={[]}
isFilterActive={false}
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(INTERESTED_SPAN_CLASS);
});
it('applies highlighted-span class when span matches filter', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={undefined}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={[mockSpan.spanId]}
isFilterActive
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(HIGHLIGHTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
});
it('applies dimmed-span class when span does not match filter', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={undefined}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={['other-span-id']}
isFilterActive
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(DIMMED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
});
it('prioritizes interested-span over highlighted-span when span is selected and matches filter', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={mockSpan}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={[mockSpan.spanId]}
isFilterActive
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(INTERESTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(DIMMED_SPAN_CLASS);
});
it('applies selected-non-matching-span class when span is selected but does not match filter', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={mockSpan}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={['different-span-id']}
isFilterActive
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(SELECTED_NON_MATCHING_SPAN_CLASS);
expect(spanElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(DIMMED_SPAN_CLASS);
});
it('applies interested-span class when span is selected and no filter is active', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={mockSpan}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={[]}
isFilterActive={false}
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(INTERESTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(SELECTED_NON_MATCHING_SPAN_CLASS);
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(DIMMED_SPAN_CLASS);
});
it('dims span when filter is active but no matches found', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={undefined}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={[]} // Empty array but filter is active
isFilterActive // This is the key difference
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(DIMMED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
});
});

View File

@@ -1,447 +0,0 @@
import React from 'react';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { Span } from 'types/api/trace/getTraceV2';
import Success from '../Success';
// Mock the required hooks with proper typing
const mockSafeNavigate = jest.fn() as jest.MockedFunction<
(params: { search: string }) => void
>;
const mockUrlQuery = new URLSearchParams();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: typeof mockSafeNavigate } => ({
safeNavigate: mockSafeNavigate,
}),
}));
jest.mock('hooks/useUrlQuery', () => (): URLSearchParams => mockUrlQuery);
// App provider is already handled by test-utils
// React Router is already globally mocked
// Mock complex external dependencies that cause provider issues
jest.mock('components/SpanHoverCard/SpanHoverCard', () => {
function SpanHoverCard({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
return <div>{children}</div>;
}
SpanHoverCard.displayName = 'SpanHoverCard';
return SpanHoverCard;
});
// Mock the Filters component that's causing React Query issues
jest.mock('../Filters/Filters', () => {
function Filters(): null {
return null;
}
Filters.displayName = 'Filters';
return Filters;
});
// Mock other potential dependencies
jest.mock(
'container/TraceWaterfall/AddSpanToFunnelModal/AddSpanToFunnelModal',
() => {
function AddSpanToFunnelModal(): null {
return null;
}
AddSpanToFunnelModal.displayName = 'AddSpanToFunnelModal';
return AddSpanToFunnelModal;
},
);
jest.mock('container/TraceWaterfall/SpanLineActionButtons', () => {
function SpanLineActionButtons(): null {
return null;
}
SpanLineActionButtons.displayName = 'SpanLineActionButtons';
return SpanLineActionButtons;
});
jest.mock('components/HttpStatusBadge/HttpStatusBadge', () => {
function HttpStatusBadge(): null {
return null;
}
HttpStatusBadge.displayName = 'HttpStatusBadge';
return HttpStatusBadge;
});
// Mock other utilities that might cause issues
jest.mock('lib/uPlotLib/utils/generateColor', () => ({
generateColor: (): string => '#1890ff',
}));
jest.mock('container/TraceDetail/utils', () => ({
convertTimeToRelevantUnit: (
value: number,
): { time: number; timeUnitName: string } => ({
time: value < 1000 ? value : value / 1000,
timeUnitName: value < 1000 ? 'ms' : 's',
}),
}));
jest.mock('utils/toFixed', () => ({
toFixed: (value: number, decimals: number): string => value.toFixed(decimals),
}));
// Create a simplified mock TableV3 that renders the actual column components
jest.mock('components/TableV3/TableV3', () => ({
TableV3: ({
columns,
data,
}: {
columns: unknown[];
data: Span[];
}): JSX.Element => {
// Get the current props from the columns (which contain the current state)
const spanOverviewColumn = columns[0] as {
cell?: (props: any) => JSX.Element;
};
const spanDurationColumn = columns[1] as {
cell?: (props: any) => JSX.Element;
};
return (
<div data-testid="trace-table">
{data.map((row: Span) => {
// Create proper cell props that match what TanStack Table expects
const cellProps = {
row: {
original: row,
getValue: (): Span => row,
getAllCells: (): any[] => [],
getVisibleCells: (): any[] => [],
getUniqueValues: (): any[] => [],
getIsSelected: (): boolean => false,
getIsSomeSelected: (): boolean => false,
getIsAllSelected: (): boolean => false,
getCanSelect: (): boolean => true,
getCanSelectSubRows: (): boolean => true,
getCanSelectAll: (): boolean => true,
toggleSelected: (): void => {},
getToggleSelectedHandler: (): (() => void) => (): void => {},
},
column: { id: 'span-name' },
table: {},
cell: {},
renderValue: (): Span => row,
getValue: (): Span => row,
};
const durationCellProps = {
...cellProps,
column: { id: 'span-duration' },
};
return (
<div key={row.spanId} data-testid={`table-row-${row.spanId}`}>
{/* Render span overview column */}
<div data-testid={`cell-0-${row.spanId}`}>
{spanOverviewColumn?.cell?.(cellProps)}
</div>
{/* Render span duration column */}
<div data-testid={`cell-1-${row.spanId}`}>
{spanDurationColumn?.cell?.(durationCellProps)}
</div>
</div>
);
})}
</div>
);
},
}));
const mockTraceMetadata = {
traceId: 'test-trace-id',
startTime: 1679748225000000,
endTime: 1679748226000000,
hasMissingSpans: false,
};
const createMockSpan = (spanId: string, level = 1): Span => ({
spanId,
traceId: 'test-trace-id',
rootSpanId: 'span-1',
parentSpanId: level === 0 ? '' : 'span-1',
name: `Test Span ${spanId}`,
serviceName: 'test-service',
timestamp: mockTraceMetadata.startTime + level * 100000,
durationNano: 50000000,
level,
hasError: false,
kind: 1,
references: [],
tagMap: {},
event: [],
rootName: 'Test Root Span',
statusMessage: '',
statusCodeString: 'OK',
spanKind: 'server',
hasChildren: false,
hasSibling: false,
subTreeNodeCount: 1,
});
const mockSpans = [
createMockSpan('span-1', 0),
createMockSpan('span-2', 1),
createMockSpan('span-3', 1),
];
// Shared TestComponent for all tests
function TestComponent(): JSX.Element {
const [selectedSpan, setSelectedSpan] = React.useState<Span | undefined>(
undefined,
);
return (
<Success
spans={mockSpans}
traceMetadata={mockTraceMetadata}
interestedSpanId={{ spanId: '', isUncollapsed: false }}
uncollapsedNodes={mockSpans.map((s) => s.spanId)}
setInterestedSpanId={jest.fn()}
setTraceFlamegraphStatsWidth={jest.fn()}
selectedSpan={selectedSpan}
setSelectedSpan={setSelectedSpan}
/>
);
}
describe('Span Click User Flows', () => {
const FIRST_SPAN_TEST_ID = 'cell-0-span-1';
const FIRST_SPAN_DURATION_TEST_ID = 'cell-1-span-1';
const SECOND_SPAN_TEST_ID = 'cell-0-span-2';
const SPAN_OVERVIEW_CLASS = '.span-overview';
const SPAN_DURATION_CLASS = '.span-duration';
const INTERESTED_SPAN_CLASS = 'interested-span';
const SECOND_SPAN_DURATION_TEST_ID = 'cell-1-span-2';
beforeEach(() => {
jest.clearAllMocks();
// Clear all URL parameters
Array.from(mockUrlQuery.keys()).forEach((key) => mockUrlQuery.delete(key));
});
it('clicking span updates URL with spanId parameter', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<Success
spans={mockSpans}
traceMetadata={mockTraceMetadata}
interestedSpanId={{ spanId: '', isUncollapsed: false }}
uncollapsedNodes={mockSpans.map((s) => s.spanId)}
setInterestedSpanId={jest.fn()}
setTraceFlamegraphStatsWidth={jest.fn()}
selectedSpan={undefined}
setSelectedSpan={jest.fn()}
/>,
undefined,
{ initialRoute: '/trace' },
);
// Initially URL should not have spanId
expect(mockUrlQuery.get('spanId')).toBeNull();
// Click on the actual span element (not the wrapper)
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
const spanElement = spanOverview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
await user.click(spanElement);
// Verify URL was updated with spanId
expect(mockUrlQuery.get('spanId')).toBe('span-1');
expect(mockSafeNavigate).toHaveBeenCalledWith({
search: expect.stringContaining('spanId=span-1'),
});
});
it('clicking span duration visually selects the span', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestComponent />, undefined, {
initialRoute: '/trace',
});
// Wait for initial render and selection
await waitFor(() => {
const spanDuration = screen.getByTestId(FIRST_SPAN_DURATION_TEST_ID);
const spanDurationElement = spanDuration.querySelector(
SPAN_DURATION_CLASS,
) as HTMLElement;
expect(spanDurationElement).toHaveClass(INTERESTED_SPAN_CLASS);
});
// Click on span-2 to test selection change
const span2Duration = screen.getByTestId(SECOND_SPAN_DURATION_TEST_ID);
const span2DurationElement = span2Duration.querySelector(
SPAN_DURATION_CLASS,
) as HTMLElement;
await user.click(span2DurationElement);
// Wait for the state update and re-render
await waitFor(() => {
const spanDuration = screen.getByTestId(FIRST_SPAN_DURATION_TEST_ID);
const spanDurationElement = spanDuration.querySelector(
SPAN_DURATION_CLASS,
) as HTMLElement;
const span2Duration = screen.getByTestId(SECOND_SPAN_DURATION_TEST_ID);
const span2DurationElement = span2Duration.querySelector(
SPAN_DURATION_CLASS,
) as HTMLElement;
expect(spanDurationElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
expect(span2DurationElement).toHaveClass(INTERESTED_SPAN_CLASS);
});
});
it('both click areas produce the same visual result', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestComponent />, undefined, {
initialRoute: '/trace',
});
// Wait for initial render and selection
await waitFor(() => {
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
const spanDuration = screen.getByTestId(FIRST_SPAN_DURATION_TEST_ID);
const spanOverviewElement = spanOverview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
const spanDurationElement = spanDuration.querySelector(
SPAN_DURATION_CLASS,
) as HTMLElement;
// Initially both areas should show the same visual selection (first span is auto-selected)
expect(spanOverviewElement).toHaveClass(INTERESTED_SPAN_CLASS);
expect(spanDurationElement).toHaveClass(INTERESTED_SPAN_CLASS);
});
// Click span-2 to test selection change
const span2Overview = screen.getByTestId(SECOND_SPAN_TEST_ID);
const span2Element = span2Overview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
await user.click(span2Element);
// Wait for the state update and re-render
await waitFor(() => {
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
const spanDuration = screen.getByTestId(FIRST_SPAN_DURATION_TEST_ID);
const spanOverviewElement = spanOverview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
const spanDurationElement = spanDuration.querySelector(
SPAN_DURATION_CLASS,
) as HTMLElement;
// Now span-2 should be selected, span-1 should not
expect(spanOverviewElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
expect(spanDurationElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
// Check that span-2 is selected
const span2Overview = screen.getByTestId(SECOND_SPAN_TEST_ID);
const span2Duration = screen.getByTestId(SECOND_SPAN_DURATION_TEST_ID);
const span2OverviewElement = span2Overview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
const span2DurationElement = span2Duration.querySelector(
SPAN_DURATION_CLASS,
) as HTMLElement;
expect(span2OverviewElement).toHaveClass(INTERESTED_SPAN_CLASS);
expect(span2DurationElement).toHaveClass(INTERESTED_SPAN_CLASS);
});
});
it('clicking different spans updates selection correctly', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestComponent />, undefined, {
initialRoute: '/trace',
});
// Wait for initial render and selection
await waitFor(() => {
const span1Overview = screen.getByTestId(FIRST_SPAN_TEST_ID);
const span1Element = span1Overview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
expect(span1Element).toHaveClass(INTERESTED_SPAN_CLASS);
});
// Click second span
const span2Overview = screen.getByTestId(SECOND_SPAN_TEST_ID);
const span2Element = span2Overview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
await user.click(span2Element);
// Wait for the state update and re-render
await waitFor(() => {
const span1Overview = screen.getByTestId(FIRST_SPAN_TEST_ID);
const span1Element = span1Overview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
const span2Overview = screen.getByTestId(SECOND_SPAN_TEST_ID);
const span2Element = span2Overview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
// Second span should be selected, first should not
expect(span1Element).not.toHaveClass(INTERESTED_SPAN_CLASS);
expect(span2Element).toHaveClass(INTERESTED_SPAN_CLASS);
});
});
it('preserves existing URL parameters when selecting spans', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
// Pre-populate URL with existing parameters
mockUrlQuery.set('existingParam', 'existingValue');
mockUrlQuery.set('anotherParam', 'anotherValue');
render(
<Success
spans={mockSpans}
traceMetadata={mockTraceMetadata}
interestedSpanId={{ spanId: '', isUncollapsed: false }}
uncollapsedNodes={mockSpans.map((s) => s.spanId)}
setInterestedSpanId={jest.fn()}
setTraceFlamegraphStatsWidth={jest.fn()}
selectedSpan={undefined}
setSelectedSpan={jest.fn()}
/>,
undefined,
{ initialRoute: '/trace' },
);
// Click on the actual span element (not the wrapper)
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
const spanElement = spanOverview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
await user.click(spanElement);
// Verify existing parameters are preserved and spanId is added
expect(mockUrlQuery.get('existingParam')).toBe('existingValue');
expect(mockUrlQuery.get('anotherParam')).toBe('anotherValue');
expect(mockUrlQuery.get('spanId')).toBe('span-1');
// Verify navigation was called with all parameters
expect(mockSafeNavigate).toHaveBeenCalledWith({
search: expect.stringMatching(
/existingParam=existingValue.*anotherParam=anotherValue.*spanId=span-1/,
),
});
});
});

View File

@@ -1,7 +0,0 @@
export enum TraceWaterfallStates {
LOADING = 'LOADING',
SUCCESS = 'SUCCESS',
NO_DATA = 'NO_DATA',
ERROR = 'ERROR',
FETCHING_WITH_OLD_DATA_PRESENT = 'FETCHING_WTIH_OLD_DATA_PRESENT',
}

View File

@@ -1,31 +0,0 @@
import { useQuery, UseQueryResult } from 'react-query';
import getTraceFlamegraph from 'api/trace/getTraceFlamegraph';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
GetTraceFlamegraphPayloadProps,
GetTraceFlamegraphSuccessResponse,
} from 'types/api/trace/getTraceFlamegraph';
const useGetTraceFlamegraph = (
props: GetTraceFlamegraphPayloadProps,
): UseLicense =>
useQuery({
queryFn: () => getTraceFlamegraph(props),
queryKey: [
REACT_QUERY_KEY.GET_TRACE_V2_FLAMEGRAPH,
props.traceId,
props.selectedSpanId,
props.selectFields,
],
enabled: !!props.traceId,
keepPreviousData: true,
refetchOnWindowFocus: false,
});
type UseLicense = UseQueryResult<
SuccessResponse<GetTraceFlamegraphSuccessResponse> | ErrorResponse,
unknown
>;
export default useGetTraceFlamegraph;

View File

@@ -1,30 +0,0 @@
import { useQuery, UseQueryResult } from 'react-query';
import getTraceV2 from 'api/trace/getTraceV2';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
GetTraceV2PayloadProps,
GetTraceV2SuccessResponse,
} from 'types/api/trace/getTraceV2';
const useGetTraceV2 = (props: GetTraceV2PayloadProps): UseLicense =>
useQuery({
queryFn: () => getTraceV2(props),
// if any of the props changes then we need to trigger an API call as the older data will be obsolete
queryKey: [
REACT_QUERY_KEY.GET_TRACE_V2_WATERFALL,
props.traceId,
props.selectedSpanId,
props.isSelectedSpanIDUnCollapsed,
],
enabled: !!props.traceId,
keepPreviousData: true,
refetchOnWindowFocus: false,
});
type UseLicense = UseQueryResult<
SuccessResponse<GetTraceV2SuccessResponse> | ErrorResponse,
unknown
>;
export default useGetTraceV2;

View File

@@ -4,14 +4,17 @@ import { Tabs, TabsProps } from 'antd';
import ConfigureIcon from 'assets/AlertHistory/ConfigureIcon';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import ROUTES from 'constants/routes';
import AllAlertChannels from 'container/AllAlertChannels';
import AllAlertRules from 'container/ListAlertRules';
import { PlannedDowntime } from 'container/PlannedDowntime/PlannedDowntime';
import RoutingPolicies from 'container/RoutingPolicies';
import TriggeredAlerts from 'container/TriggeredAlerts';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
import { Cable, GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
import AlertDetails from 'pages/AlertDetails';
import ChannelsEdit from 'pages/ChannelsEdit';
import ChannelsNew from 'pages/ChannelsNew';
import { AlertListSubTabs, AlertListTabs } from './types';
@@ -26,6 +29,9 @@ function AllAlertList(): JSX.Element {
const subTab = urlQuery.get('subTab');
const isAlertHistory = location.pathname === ROUTES.ALERT_HISTORY;
const isAlertOverview = location.pathname === ROUTES.ALERT_OVERVIEW;
const isChannelsNew = location.pathname === ROUTES.CHANNELS_NEW;
const isChannelsEdit = location.pathname.startsWith('/alerts/channels/edit/');
const isChannelDetails = isChannelsNew || isChannelsEdit;
const handleConfigurationTabChange = useCallback(
(subTab: string): void => {
@@ -86,6 +92,22 @@ function AllAlertList(): JSX.Element {
</div>
),
},
{
label: (
<div className="periscope-tab top-level-tab">
<Cable size={14} />
Notification Channels
</div>
),
key: AlertListTabs.CHANNELS,
children: (
<div className="alert-rules-container">
{isChannelsNew && <ChannelsNew />}
{isChannelsEdit && <ChannelsEdit />}
{!isChannelDetails && <AllAlertChannels />}
</div>
),
},
{
label: (
<div className="periscope-tab top-level-tab">
@@ -98,11 +120,21 @@ function AllAlertList(): JSX.Element {
},
];
const getActiveKey = (): string => {
if (isAlertHistory || isAlertOverview) {
return AlertListTabs.ALERT_RULES;
}
if (isChannelDetails) {
return AlertListTabs.CHANNELS;
}
return tab || AlertListTabs.ALERT_RULES;
};
return (
<Tabs
destroyInactiveTabPane
items={items}
activeKey={tab || AlertListTabs.ALERT_RULES}
activeKey={getActiveKey()}
onChange={(tab): void => {
const queryParams = new URLSearchParams();
@@ -120,7 +152,9 @@ function AllAlertList(): JSX.Element {
safeNavigate(`/alerts?${queryParams.toString()}`);
}}
className={`alerts-container ${
isAlertHistory || isAlertOverview ? 'alert-details-tabs' : ''
isAlertHistory || isAlertOverview || isChannelDetails
? 'alert-details-tabs'
: ''
}`}
tabBarExtraContent={
<HeaderRightSection

View File

@@ -7,4 +7,5 @@ export enum AlertListTabs {
TRIGGERED_ALERTS = 'TriggeredAlerts',
ALERT_RULES = 'AlertRules',
CONFIGURATION = 'Configuration',
CHANNELS = 'Channels',
}

View File

@@ -4,7 +4,9 @@ import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query';
import { Typography } from '@signozhq/ui/typography';
import get from 'api/channels/get';
import AlertBreadcrumb from 'components/AlertBreadcrumb';
import Spinner from 'components/Spinner';
import ROUTES from 'constants/routes';
import {
ChannelType,
MsTeamsChannel,
@@ -22,9 +24,9 @@ import './ChannelsEdit.styles.scss';
function ChannelsEdit(): JSX.Element {
const { t } = useTranslation();
// Extract channelId from URL pathname since useParams doesn't work in nested routing
// Extract channelId from URL pathname
const { pathname } = window.location;
const channelIdMatch = pathname.match(/\/settings\/channels\/edit\/([^/]+)/);
const channelIdMatch = pathname.match(/\/alerts\/channels\/edit\/([^/]+)/);
const channelId = channelIdMatch ? channelIdMatch[1] : undefined;
const { isFetching, isError, data, error } = useQuery<
@@ -135,17 +137,25 @@ function ChannelsEdit(): JSX.Element {
const target = prepChannelConfig();
return (
<div className="edit-alert-channels-container">
<EditAlertChannels
{...{
initialValue: {
...target.channel,
type: target.type,
name: value.name,
},
}}
<>
<AlertBreadcrumb
items={[
{ title: 'Channels', route: ROUTES.ALL_CHANNELS },
{ title: value.name || 'Edit Channel', isLast: true },
]}
/>
</div>
<div className="edit-alert-channels-container">
<EditAlertChannels
{...{
initialValue: {
...target.channel,
type: target.type,
name: value.name,
},
}}
/>
</div>
</>
);
}

View File

@@ -0,0 +1,23 @@
import AlertBreadcrumb from 'components/AlertBreadcrumb';
import ROUTES from 'constants/routes';
import CreateAlertChannels from 'container/CreateAlertChannels';
import { ChannelType } from 'container/CreateAlertChannels/config';
import styles from './styles.module.scss';
function ChannelsNew(): JSX.Element {
return (
<>
<AlertBreadcrumb
items={[
{ title: 'Channels', route: ROUTES.ALL_CHANNELS },
{ title: 'New Channel', isLast: true },
]}
/>
<div className={styles.content}>
<CreateAlertChannels preType={ChannelType.Slack} />
</div>
</>
);
}
export default ChannelsNew;

View File

@@ -0,0 +1,4 @@
.content {
padding: var(--spacing-8);
padding-top: 0px;
}

View File

@@ -20,6 +20,7 @@ import APIError from 'types/api/error';
import DashboardActions from './DashboardActions/DashboardActions';
import DashboardInfo from './DashboardInfo/DashboardInfo';
import { useEditableTitle } from './DashboardInfo/useEditableTitle';
import VariablesBar from '../VariablesBar/VariablesBar';
import styles from './DashboardPageToolbar.module.scss';
@@ -137,6 +138,8 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
onOpenRename={startEdit}
/>
</div>
<VariablesBar dashboard={dashboard} />
</section>
);
}

View File

@@ -1,106 +1,15 @@
// settings card wrapper — mirrors the V1 public dashboard treatment
.publicDashboardCard {
// Publish tab — "status strip" direction (Claude Design: Publish Drawer Final).
// Fills the drawer height so the actions anchor a footer instead of floating.
.publishTab {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
border-radius: 3px;
border: 1px solid var(--l2-border);
height: 100%;
min-height: 100%;
}
.statusTitle {
margin-bottom: 16px;
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.checkbox {
margin-bottom: 8px;
}
.timeRangeSelectGroup {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 8px;
}
.timeRangeSelectLabel {
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-weight: 500;
line-height: 18px;
}
.timeRangeSelect {
width: 200px;
}
.urlGroup {
display: flex;
flex-direction: column;
gap: 4px;
}
.urlLabel {
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-weight: 500;
line-height: 18px;
}
.urlContainer {
display: flex;
align-items: center;
gap: 8px;
padding: 0 4px;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
.urlText {
.content {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--l2-foreground);
font-family: Inter;
font-size: 13px;
line-height: 32px;
}
.callout {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
padding: 12px 8px;
border-radius: 3px;
background: color-mix(in srgb, var(--primary-background) 10%, transparent);
}
.calloutIcon {
flex-shrink: 0;
color: var(--text-robin-300);
}
.calloutText {
color: var(--text-robin-300);
font-family: Inter;
font-size: 11px;
font-weight: 400;
line-height: 16px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 32px;
flex-direction: column;
gap: 20px;
}

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