Compare commits

...

23 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
221 changed files with 9988 additions and 12795 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

@@ -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,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,7 +20,7 @@ import APIError from 'types/api/error';
import DashboardActions from './DashboardActions/DashboardActions';
import DashboardInfo from './DashboardInfo/DashboardInfo';
import { useEditableTitle } from './DashboardInfo/useEditableTitle';
import { usePublicDashboardMeta } from '../DashboardSettings/PublicDashboard/usePublicDashboardMeta';
import VariablesBar from '../VariablesBar/VariablesBar';
import styles from './DashboardPageToolbar.module.scss';
@@ -53,10 +53,6 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
(s) => s.setIsPanelTypeSelectionModalOpen,
);
// Single global fetch of the public-sharing meta (the drawer reuses this cache);
// drives the public-access badge.
const { isPublic: isPublicDashboard } = usePublicDashboardMeta(id);
const isAuthor =
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
@@ -122,7 +118,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
image={image}
tags={tags}
description={description}
isPublicDashboard={isPublicDashboard}
isPublicDashboard={false}
isDashboardLocked={isDashboardLocked}
isEditing={isEditing}
draft={draft}
@@ -142,6 +138,8 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
onOpenRename={startEdit}
/>
</div>
<VariablesBar dashboard={dashboard} />
</section>
);
}

View File

@@ -1,3 +1,4 @@
import { sortBy } from 'lodash-es';
import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
/**
@@ -76,3 +77,26 @@ export function emptyVariableFormModel(): VariableFormModel {
dynamicSignal: 'traces',
};
}
/** Maps the dynamic-variable signal to the field-values API signal. */
export function signalForApi(
signal: TelemetrySignal,
): TelemetrySignal | undefined {
return signal;
}
type SortableValues = (string | number | boolean)[];
/** Sorts option/preview values by the variable's chosen order (no-op when disabled). */
export function sortValuesByOrder(
values: SortableValues,
sort: VariableSort,
): SortableValues {
if (sort === 'ASC') {
return sortBy(values);
}
if (sort === 'DESC') {
return sortBy(values).reverse();
}
return values;
}

View File

@@ -0,0 +1,114 @@
import { useMemo } from 'react';
import { SolidInfoCircle } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
// eslint-disable-next-line signoz/no-antd-components -- lightweight description tooltip, matches V1
import { Tooltip } from 'antd';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import { sortValuesByOrder } from '../DashboardSettings/Variables/variableModel';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
import type { VariableSelection, VariableSelectionMap } from './selectionTypes';
import DynamicSelector from './selectors/DynamicSelector';
import QuerySelector from './selectors/QuerySelector';
import TextSelector from './selectors/TextSelector';
import ValueSelector from './selectors/ValueSelector';
import styles from './VariablesBar.module.scss';
interface VariableSelectorProps {
variable: VariableFormModel;
/** All variables (Dynamic uses them to scope options by sibling selections). */
variables: VariableFormModel[];
/** Names this variable depends on (for Query gating). */
parents: string[];
/** All current selections (Query passes them as the request payload). */
selections: VariableSelectionMap;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
}
/** One labelled variable control; dispatches on the variable type. */
function VariableSelector({
variable,
variables,
parents,
selections,
selection,
onChange,
}: VariableSelectorProps): JSX.Element {
const customOptions = useMemo(
() =>
variable.type === 'CUSTOM'
? sortValuesByOrder(
commaValuesParser(variable.customValue),
variable.sort,
).map(String)
: [],
[variable],
);
const renderControl = (): JSX.Element => {
switch (variable.type) {
case 'TEXT':
return (
<TextSelector
selection={selection}
defaultValue={variable.textValue}
onChange={onChange}
testId={`variable-input-${variable.name}`}
/>
);
case 'QUERY':
return (
<QuerySelector
variable={variable}
parents={parents}
selections={selections}
selection={selection}
onChange={onChange}
/>
);
case 'DYNAMIC':
return (
<DynamicSelector
variable={variable}
variables={variables}
selections={selections}
selection={selection}
onChange={onChange}
/>
);
case 'CUSTOM':
default:
return (
<ValueSelector
options={customOptions}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
};
return (
<div
className={styles.variableItem}
data-testid={`variable-${variable.name}`}
>
<Typography.Text className={styles.variableName}>
${variable.name}
{variable.description ? (
<Tooltip title={variable.description}>
<SolidInfoCircle className={styles.infoIcon} size="md" />
</Tooltip>
) : null}
</Typography.Text>
<div className={styles.variableValue}>{renderControl()}</div>
</div>
);
}
export default VariableSelector;

View File

@@ -0,0 +1,71 @@
/* Mirrors the V1 dashboard variable bar: each variable is a connected pill —
a robin `$name` segment joined to a value segment. */
/* Sits inside the already-padded sticky toolbar section, so it only needs a top
gap from the tags — horizontal/bottom padding comes from the toolbar. */
.bar {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding-top: 12px;
}
.variableItem {
display: flex;
align-items: center;
}
.variableName {
display: flex;
min-width: 56px;
height: 32px;
align-items: center;
gap: 4px;
padding: 6px 6px 6px 8px;
border: 1px solid var(--l1-border);
border-radius: 2px 0 0 2px;
background: var(--l3-background);
color: var(--bg-robin-300);
font-family: Inter;
font-size: 12px;
font-weight: 400;
line-height: 16px;
white-space: nowrap;
}
.infoIcon {
margin-left: 4px;
color: var(--l2-foreground);
}
.variableValue {
display: flex;
min-width: 120px;
height: 32px;
align-items: center;
border: 1px solid var(--l1-border);
border-left: none;
border-radius: 0 2px 2px 0;
background: var(--l2-background);
color: var(--l2-foreground);
font-size: 12px;
&:hover,
&:focus-within {
outline: 1px solid var(--bg-robin-400);
}
}
/* Inner control fills the value segment; the segment provides the frame, so the
control itself is borderless/transparent. */
.control {
width: 100%;
min-width: 120px;
:global(.ant-select-selector),
:global(.ant-input),
&:global(.ant-input) {
border: none !important;
background: transparent !important;
box-shadow: none !important;
}
}

View File

@@ -0,0 +1,45 @@
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useVariableSelection } from './useVariableSelection';
import VariableSelector from './VariableSelector';
import styles from './VariablesBar.module.scss';
interface VariablesBarProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
}
/**
* Runtime variable selector bar shown above the panels. Renders one control per
* dashboard variable; selections live in the store + URL (never the spec).
*/
function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
const { variables, dependencyData, selection, setSelection } =
useVariableSelection(dashboard);
if (variables.length === 0) {
return null;
}
return (
<div className={styles.bar} data-testid="dashboard-variables-bar">
{variables.map((variable) => (
<VariableSelector
key={variable.name}
variable={variable}
variables={variables}
parents={dependencyData.parentGraph[variable.name] ?? []}
selections={selection}
selection={
selection[variable.name] ?? {
value: variable.multiSelect ? [] : '',
allSelected: false,
}
}
onChange={(next): void => setSelection(variable.name, next)}
/>
))}
</div>
);
}
export default VariablesBar;

View File

@@ -0,0 +1,56 @@
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
import type { VariableSelectionMap } from './selectionTypes';
function formatQueryValue(val: string): string {
const num = Number(val);
if (!Number.isNaN(num) && Number.isFinite(num)) {
return val;
}
return `'${val.replace(/'/g, "\\'")}'`;
}
function buildQueryPart(attribute: string, values: string[]): string {
const formatted = values.map(formatQueryValue);
if (formatted.length === 1) {
return `${attribute} = ${formatted[0]}`;
}
return `${attribute} IN [${formatted.join(', ')}]`;
}
/**
* Builds a filter expression from the OTHER dynamic variables' current
* selections (e.g. `k8s.namespace.name IN ['prod'] AND service = 'api'`), so a
* dynamic variable's option list is scoped by its sibling selections. Variables
* in the ALL state, with no selection, or non-dynamic are skipped. Ported from
* the V1 dynamic-variable runtime.
*/
export function buildExistingDynamicVariableQuery(
variables: VariableFormModel[],
selections: VariableSelectionMap,
currentName: string,
): string {
const parts: string[] = [];
variables.forEach((variable) => {
if (
variable.name === currentName ||
variable.type !== 'DYNAMIC' ||
!variable.dynamicAttribute
) {
return;
}
const selection = selections[variable.name];
if (!selection || selection.allSelected) {
return;
}
const raw = Array.isArray(selection.value)
? selection.value
: [selection.value];
const valid = raw
.filter((v) => v !== null && v !== undefined && v !== '')
.map((v) => String(v));
if (valid.length > 0) {
parts.push(buildQueryPart(variable.dynamicAttribute, valid));
}
});
return parts.join(' AND ');
}

View File

@@ -0,0 +1,16 @@
/** A user-selected variable value at runtime (not persisted to the spec). */
export type SelectedVariableValue =
| string
| number
| boolean
| (string | number | boolean)[]
| null;
export interface VariableSelection {
value: SelectedVariableValue;
/** True when every option is selected ("ALL"); for dynamic vars value may be null. */
allSelected: boolean;
}
/** Selected values for a dashboard's variables, keyed by variable name. */
export type VariableSelectionMap = Record<string, VariableSelection>;

View File

@@ -0,0 +1,31 @@
import type {
SelectedVariableValue,
VariableSelection,
VariableSelectionMap,
} from './selectionTypes';
/** A selection counts as resolved (usable as a parent value) when it's non-empty. */
export function isResolved(selection?: VariableSelection): boolean {
if (!selection) {
return false;
}
if (selection.allSelected) {
return true;
}
const { value } = selection;
if (Array.isArray(value)) {
return value.length > 0;
}
return value !== '' && value !== null && value !== undefined;
}
/** Flatten the selection map into the `{ name: value }` payload a query expects. */
export function selectionToPayload(
selection: VariableSelectionMap,
): Record<string, SelectedVariableValue> {
const payload: Record<string, SelectedVariableValue> = {};
Object.entries(selection).forEach(([name, sel]) => {
payload[name] = sel.value;
});
return payload;
}

View File

@@ -0,0 +1,82 @@
import { useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import {
signalForApi,
sortValuesByOrder,
} from '../../DashboardSettings/Variables/variableModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableModel';
import { buildExistingDynamicVariableQuery } from '../dynamicFilter';
import type {
VariableSelection,
VariableSelectionMap,
} from '../selectionTypes';
import { useAutoSelect } from '../useAutoSelect';
import ValueSelector from './ValueSelector';
interface DynamicSelectorProps {
variable: VariableFormModel;
/** All variables + current selections, to scope options by sibling dynamics. */
variables: VariableFormModel[];
selections: VariableSelectionMap;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
}
/**
* Dynamic-variable options sourced from live telemetry field values for the
* chosen signal + attribute, scoped by the other dynamic variables' selections
* (so e.g. `pod` narrows to the chosen `namespace`).
*/
function DynamicSelector({
variable,
variables,
selections,
selection,
onChange,
}: DynamicSelectorProps): JSX.Element {
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const existingQuery = useMemo(
() => buildExistingDynamicVariableQuery(variables, selections, variable.name),
[variables, selections, variable.name],
);
const { data, isFetching } = useGetFieldValues({
signal: signalForApi(variable.dynamicSignal),
name: variable.dynamicAttribute,
startUnixMilli: minTime,
endUnixMilli: maxTime,
existingQuery: existingQuery || undefined,
enabled: !!variable.dynamicAttribute,
});
const options = useMemo(() => {
const payload = data?.data;
const values =
payload?.normalizedValues ?? payload?.values?.StringValues ?? [];
return sortValuesByOrder(values, variable.sort).map(String);
}, [data, variable.sort]);
useAutoSelect(variable, options, selection, onChange);
return (
<ValueSelector
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={isFetching}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
export default DynamicSelector;

View File

@@ -0,0 +1,90 @@
import { useMemo } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import { sortValuesByOrder } from '../../DashboardSettings/Variables/variableModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableModel';
import type {
VariableSelection,
VariableSelectionMap,
} from '../selectionTypes';
import { isResolved, selectionToPayload } from '../selectionUtils';
import { useAutoSelect } from '../useAutoSelect';
import ValueSelector from './ValueSelector';
interface QuerySelectorProps {
variable: VariableFormModel;
/** Names this variable's query references; it waits until they're resolved. */
parents: string[];
/** All current selections, fed to the query as `{ name: value }`. */
selections: VariableSelectionMap;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
}
/**
* Query-driven options. Dependency orchestration is declarative: the query is
* `enabled` only once every parent is resolved, and the parent values are in the
* query key — so it refetches automatically when a parent changes (and a cyclic
* dependency is simply never enabled).
*/
function QuerySelector({
variable,
parents,
selections,
selection,
onChange,
}: QuerySelectorProps): JSX.Element {
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const payload = useMemo(() => selectionToPayload(selections), [selections]);
const enabled = parents.every((parent) => isResolved(selections[parent]));
const { data, isFetching } = useQuery(
[
'dashboard-variable',
variable.name,
variable.queryValue,
payload,
minTime,
maxTime,
],
() =>
dashboardVariablesQuery({
query: variable.queryValue,
variables: payload,
}),
{ enabled, refetchOnWindowFocus: false },
);
const options = useMemo(() => {
if (!data || data.statusCode !== 200 || !data.payload) {
return [] as string[];
}
return sortValuesByOrder(
data.payload.variableValues ?? [],
variable.sort,
).map(String);
}, [data, variable.sort]);
useAutoSelect(variable, options, selection, onChange);
return (
<ValueSelector
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={isFetching}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
export default QuerySelector;

View File

@@ -0,0 +1,70 @@
import { useCallback, useRef, useState } from 'react';
import type { InputRef } from 'antd';
// eslint-disable-next-line signoz/no-antd-components -- match V1 textbox behaviour (commit on blur/Enter, borderless)
import { Input } from 'antd';
import type { VariableSelection } from '../selectionTypes';
import styles from '../VariablesBar.module.scss';
interface TextSelectorProps {
selection: VariableSelection;
/** Configured default; an emptied input falls back to it (V1 behaviour). */
defaultValue?: string;
onChange: (selection: VariableSelection) => void;
testId?: string;
}
/**
* Free-text variable input. Mirrors V1: edits are local and only committed on
* blur / Enter (not per keystroke), and clearing the field restores the default.
*/
function TextSelector({
selection,
defaultValue,
onChange,
testId,
}: TextSelectorProps): JSX.Element {
const inputRef = useRef<InputRef>(null);
const [value, setValue] = useState<string>(
typeof selection.value === 'string' ? selection.value : (defaultValue ?? ''),
);
const commit = useCallback(
(next: string): void => onChange({ value: next, allSelected: false }),
[onChange],
);
const handleBlur = useCallback(
(event: React.FocusEvent<HTMLInputElement>): void => {
const trimmed = event.target.value.trim();
if (!trimmed && defaultValue) {
setValue(defaultValue);
commit(defaultValue);
} else {
commit(trimmed);
}
},
[commit, defaultValue],
);
return (
<Input
ref={inputRef}
className={styles.control}
bordered={false}
placeholder="Enter value"
value={value}
title={value}
onChange={(e): void => setValue(e.target.value)}
onBlur={handleBlur}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
inputRef.current?.blur();
}
}}
data-testid={testId}
/>
);
}
export default TextSelector;

View File

@@ -0,0 +1,94 @@
import { useMemo } from 'react';
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
import type { OptionData } from 'components/NewSelect/types';
import { ALL_SELECT_VALUE } from 'container/DashboardContainer/utils';
import type { VariableSelection } from '../selectionTypes';
import styles from '../VariablesBar.module.scss';
interface ValueSelectorProps {
options: string[];
multiSelect: boolean;
showAllOption: boolean;
loading?: boolean;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
testId?: string;
}
/**
* Single/multi value picker for Custom/Query/Dynamic variables. Reuses the
* shared NewSelect components, which provide search, the "ALL" option and
* apply-on-close batching (so multi-select edits don't cascade per toggle).
*/
function ValueSelector({
options,
multiSelect,
showAllOption,
loading,
selection,
onChange,
testId,
}: ValueSelectorProps): JSX.Element {
const optionData = useMemo<OptionData[]>(
() => options.map((option) => ({ label: option, value: option })),
[options],
);
if (multiSelect) {
const value = selection.allSelected
? ALL_SELECT_VALUE
: (Array.isArray(selection.value) ? selection.value : []).map(String);
return (
<CustomMultiSelect
className={styles.control}
data-testid={testId}
options={optionData}
value={value}
loading={loading}
showSearch
placeholder="Select value"
enableAllSelection={showAllOption}
onChange={(next): void => {
const values = Array.isArray(next)
? next.map(String)
: next
? [String(next)]
: [];
if (values.length === 0) {
onChange({ value: [], allSelected: false });
return;
}
// CustomMultiSelect emits the full value set when ALL is picked.
const isAll =
showAllOption &&
options.length > 0 &&
options.every((option) => values.includes(option));
onChange({ value: values, allSelected: isAll });
}}
onClear={(): void => onChange({ value: [], allSelected: false })}
/>
);
}
return (
<CustomSelect
className={styles.select}
data-testid={testId}
options={optionData}
value={
selection.value == null || Array.isArray(selection.value)
? undefined
: String(selection.value)
}
loading={loading}
showSearch
placeholder="Select value"
onChange={(next): void =>
onChange({ value: next == null ? '' : String(next), allSelected: false })
}
/>
);
}
export default ValueSelector;

View File

@@ -0,0 +1,41 @@
import { useEffect } from 'react';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
import type { VariableSelection } from './selectionTypes';
/**
* When fetched options arrive and the current selection isn't one of them,
* auto-pick the variable's default (if present in the options) or the first
* option — so dependent children always have a usable parent value.
*/
export function useAutoSelect(
variable: VariableFormModel,
options: string[],
selection: VariableSelection,
onChange: (selection: VariableSelection) => void,
): void {
useEffect(() => {
if (options.length === 0 || selection.allSelected) {
return;
}
const current = selection.value;
const isValid = Array.isArray(current)
? current.length > 0 && current.every((c) => options.includes(String(c)))
: current !== '' &&
current !== null &&
current !== undefined &&
options.includes(String(current));
if (isValid) {
return;
}
const fallback = (variable.defaultValue as { value?: string } | undefined)
?.value;
const initial =
fallback && options.includes(fallback) ? fallback : options[0];
onChange({
value: variable.multiSelect ? [initial] : initial,
allSelected: false,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options]);
}

View File

@@ -0,0 +1,116 @@
import { useCallback, useEffect, useMemo } from 'react';
import { parseAsJson, useQueryState } from 'nuqs';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
import { selectVariableValues } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
import type {
SelectedVariableValue,
VariableSelection,
VariableSelectionMap,
} from './selectionTypes';
import {
computeVariableDependencies,
type VariableDependencyData,
} from './variableDependencies';
/** URL sentinel for an "ALL values selected" state (matches V1). */
export const ALL_SELECTED = '__ALL__';
/** `?variables=` holds `{ [name]: value }` (ALL encoded as the sentinel). */
const variablesUrlParser = parseAsJson<Record<string, SelectedVariableValue>>(
(v) =>
typeof v === 'object' && v !== null
? (v as Record<string, SelectedVariableValue>)
: null,
);
function defaultSelection(model: VariableFormModel): VariableSelection {
const def = (
model.defaultValue as { value?: SelectedVariableValue } | undefined
)?.value;
if (def !== undefined && def !== null && def !== '') {
return { value: def, allSelected: false };
}
return { value: model.multiSelect ? [] : '', allSelected: false };
}
function fromUrlValue(raw: SelectedVariableValue): VariableSelection {
return raw === ALL_SELECTED
? { value: null, allSelected: true }
: { value: raw, allSelected: false };
}
interface UseVariableSelection {
variables: VariableFormModel[];
dependencyData: VariableDependencyData;
selection: VariableSelectionMap;
setSelection: (name: string, selection: VariableSelection) => void;
}
/**
* Runtime variable selection: derives the variable list from the spec, seeds
* each value from URL → localStorage(store) → default, and persists changes to
* both the store and the URL. Never writes to the dashboard spec.
*/
export function useVariableSelection(
dashboard: DashboardtypesGettableDashboardV2DTO,
): UseVariableSelection {
const dashboardId = dashboard.id ?? '';
const variables = useMemo(
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
[dashboard.spec?.variables],
);
const dependencyData = useMemo(
() => computeVariableDependencies(variables),
[variables],
);
const selection = useDashboardStore(selectVariableValues(dashboardId));
const setVariableValue = useDashboardStore((s) => s.setVariableValue);
const setVariableValues = useDashboardStore((s) => s.setVariableValues);
const [urlValues, setUrlValues] = useQueryState(
'variables',
variablesUrlParser.withOptions({ history: 'replace' }),
);
// Seed selections for this dashboard: URL wins, then persisted store, then default.
useEffect(() => {
if (!dashboardId || variables.length === 0) {
return;
}
// `selection` here is the persisted (localStorage) map on mount — the
// effect deliberately doesn't depend on it, so seeding runs once per set.
const stored = selection;
const seeded: VariableSelectionMap = {};
variables.forEach((variable) => {
const urlValue = urlValues?.[variable.name];
if (urlValue !== undefined) {
seeded[variable.name] = fromUrlValue(urlValue);
} else if (stored[variable.name]) {
seeded[variable.name] = stored[variable.name];
} else {
seeded[variable.name] = defaultSelection(variable);
}
});
setVariableValues(dashboardId, seeded);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboardId, variables]);
const setSelection = useCallback(
(name: string, next: VariableSelection): void => {
setVariableValue(dashboardId, name, next);
void setUrlValues((prev) => ({
...(prev ?? {}),
[name]: next.allSelected ? ALL_SELECTED : next.value,
}));
},
[dashboardId, setVariableValue, setUrlValues],
);
return { variables, dependencyData, selection, setSelection };
}

View File

@@ -0,0 +1,199 @@
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
/**
* Inter-variable dependency graph for runtime selection. A QUERY variable
* "depends on" another variable when its query text references that variable
* (`{{.name}}`, `{{name}}`, `$name`, `[[name]]`). When a variable's value
* changes, its dependent QUERY variables must refetch. Ported from the V1
* dashboard-variables runtime; operates on the V2 flat variable model.
*/
export type VariableGraph = Record<string, string[]>;
export interface VariableDependencyData {
/** Topological order of variables (parents before children). */
order: string[];
/** Direct children (dependents) of each variable. */
graph: VariableGraph;
/** Direct parents of each variable. */
parentGraph: VariableGraph;
/** All transitive descendants of each variable (precomputed). */
transitiveDescendants: VariableGraph;
hasCycle: boolean;
cycleNodes?: string[];
}
/** Names of QUERY variables whose query references `variableName`. */
function getDependents(
variableName: string,
variables: VariableFormModel[],
): string[] {
return variables
.filter(
(v) =>
v.type === 'QUERY' &&
!!v.name &&
textContainsVariableReference(v.queryValue || '', variableName),
)
.map((v) => v.name);
}
/** variable name → its direct dependents (children). */
export function buildDependencies(
variables: VariableFormModel[],
): VariableGraph {
const graph: VariableGraph = {};
variables.forEach((v) => {
if (v.name) {
graph[v.name] = getDependents(v.name, variables);
}
});
return graph;
}
/** Invert a child graph into a parent graph. */
export function buildParentGraph(graph: VariableGraph): VariableGraph {
const parents: VariableGraph = {};
Object.keys(graph).forEach((node) => {
parents[node] = parents[node] ?? [];
});
Object.entries(graph).forEach(([node, children]) => {
children.forEach((child) => {
parents[child] = parents[child] ?? [];
parents[child].push(node);
});
});
return parents;
}
function collectCyclePath(
graph: VariableGraph,
start: string,
end: string,
): string[] {
const path: string[] = [];
let current = start;
const findParent = (node: string): string | undefined =>
Object.keys(graph).find((key) => graph[key]?.includes(node));
while (current !== end) {
const parent = findParent(current);
if (!parent) {
break;
}
path.push(parent);
current = parent;
}
return [start, ...path];
}
function detectCycle(
graph: VariableGraph,
node: string,
visited: Set<string>,
recStack: Set<string>,
): string[] | null {
if (!visited.has(node)) {
visited.add(node);
recStack.add(node);
let cycleNodes: string[] | null = null;
(graph[node] || []).some((neighbor) => {
if (!visited.has(neighbor)) {
const found = detectCycle(graph, neighbor, visited, recStack);
if (found) {
cycleNodes = found;
return true;
}
} else if (recStack.has(neighbor)) {
cycleNodes = collectCyclePath(graph, node, neighbor);
return true;
}
return false;
});
if (cycleNodes) {
return cycleNodes;
}
}
recStack.delete(node);
return null;
}
/** Build the full dependency data (topo order, parents, transitive descendants, cycle info). */
export function buildDependencyData(
dependencies: VariableGraph,
): VariableDependencyData {
const inDegree: Record<string, number> = {};
const adjList: VariableGraph = {};
Object.keys(dependencies).forEach((node) => {
inDegree[node] = inDegree[node] ?? 0;
adjList[node] = adjList[node] ?? [];
(dependencies[node] || []).forEach((child) => {
inDegree[child] = inDegree[child] ?? 0;
inDegree[child] += 1;
adjList[node].push(child);
});
});
const visited = new Set<string>();
const recStack = new Set<string>();
let cycleNodes: string[] | undefined;
Object.keys(dependencies).some((node) => {
if (!visited.has(node)) {
const found = detectCycle(dependencies, node, visited, recStack);
if (found) {
cycleNodes = found;
return true;
}
}
return false;
});
// Topological sort (Kahn's algorithm).
const queue = Object.keys(inDegree).filter((n) => inDegree[n] === 0);
const order: string[] = [];
while (queue.length > 0) {
const current = queue.shift();
if (current === undefined) {
break;
}
order.push(current);
(adjList[current] || []).forEach((neighbor) => {
inDegree[neighbor] -= 1;
if (inDegree[neighbor] === 0) {
queue.push(neighbor);
}
});
}
const hasCycle = order.length !== Object.keys(dependencies).length;
// Transitive descendants: walk topo order in reverse.
const transitiveDescendants: VariableGraph = {};
for (let i = order.length - 1; i >= 0; i--) {
const node = order[i];
const desc = new Set<string>();
(adjList[node] || []).forEach((child) => {
desc.add(child);
(transitiveDescendants[child] || []).forEach((d) => desc.add(d));
});
transitiveDescendants[node] = Array.from(desc);
}
return {
order,
graph: adjList,
parentGraph: buildParentGraph(adjList),
transitiveDescendants,
hasCycle,
cycleNodes,
};
}
/** Compute the full dependency data straight from the variable list. */
export function computeVariableDependencies(
variables: VariableFormModel[],
): VariableDependencyData {
return buildDependencyData(buildDependencies(variables));
}

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