Compare commits

..

57 Commits

Author SHA1 Message Date
Manika Malhotra
f282ae6734 fix(popover): drop nativeButton from areas where handle is passed 2026-10-16 13:48:19 +05:30
Manika Malhotra
4133182b59 fix: tooltip mock and simplify variables popover 2026-10-15 20:09:54 +05:30
Manika Malhotra
0751c4d294 feat(popover): integrate popover component 2026-10-15 18:15:47 +05:30
Manika Malhotra
d61c7643c7 fix: cleanup conflicting css 2026-10-14 21:22:03 +05:30
Manika Malhotra
e8fba3e0b6 fix: remove sonner shim and use toast 2026-10-13 13:44:10 +05:30
Manika Malhotra
fd0f5cddb3 chore: bump packages, integrate alert-strip and resizable 2026-10-12 17:26:05 +05:30
Manika Malhotra
693401dbbb feat(frontend): adopt reworked @signozhq/ui components across remaining areas 2026-10-08 18:12:17 +05:30
Manika Malhotra
c9f0cc18d2 fix: restore onboarding 2 column layout 2026-10-07 18:44:51 +05:30
Vinícius Lourenço
0b1e8aad6e fixup! feat(integrations): adopt reworked @signozhq/ui components 2026-10-06 15:06:37 -03:00
Vinícius Lourenço
5685de5494 fixup! feat(llm-observability): adopt reworked @signozhq/ui components 2026-10-06 15:06:32 -03:00
Vinícius Lourenço
b21742a128 fixup! feat(settings): adopt reworked @signozhq/ui components 2026-10-01 15:00:58 -03:00
Vinícius Lourenço
843feef61d fix(alerts): use Pill.Closeable for classic alert form labels 2026-10-01 12:02:15 -03:00
Vinícius Lourenço
6984683fd9 test(integrations): raise timeout for GCP setup drawer tests 2026-10-01 12:02:15 -03:00
Vinícius Lourenço
1407b26fbb fix(planned-downtime): use Pill.Closeable for selected alert rules 2026-10-01 12:02:15 -03:00
Vinícius Lourenço
aed715be5c fix(pipelines): submit the pipeline form from the Create and Update button 2026-10-01 12:02:14 -03:00
Vinícius Lourenço
8ef90b84b6 test(e2e): update selectors for reworked @signozhq/ui components 2026-10-01 12:02:14 -03:00
Vinícius Lourenço
2315a18fa4 feat(events-exceptions-apis-queues): adopt reworked @signozhq/ui components
Co-authored-by: Manika Malhotra <manika@signoz.io>
2026-10-01 12:02:14 -03:00
Vinícius Lourenço
77834e2b22 feat(events-traces): adopt reworked @signozhq/ui components
Co-authored-by: Manika Malhotra <manika@signoz.io>
2026-10-01 12:02:14 -03:00
Vinícius Lourenço
a37ee50c7d feat(events-logs): adopt reworked @signozhq/ui components
Co-authored-by: Manika Malhotra <manika@signoz.io>
2026-10-01 12:02:14 -03:00
Vinícius Lourenço
a05baddab0 feat(pulse-infra-monitoring): adopt reworked @signozhq/ui components
Co-authored-by: Manika Malhotra <manika@signoz.io>
2026-10-01 12:02:14 -03:00
Vinícius Lourenço
099c5135db feat(pulse-alerts): adopt reworked @signozhq/ui components
Co-authored-by: Manika Malhotra <manika@signoz.io>
2026-10-01 12:02:13 -03:00
Vinícius Lourenço
268af75098 feat(pulse-dashboards-list): adopt reworked @signozhq/ui components
Co-authored-by: Manika Malhotra <manika@signoz.io>
2026-10-01 12:02:13 -03:00
Vinícius Lourenço
a5e395120f feat(pulse-dashboards): adopt reworked @signozhq/ui components
Co-authored-by: Manika Malhotra <manika@signoz.io>
2026-10-01 12:02:13 -03:00
Vinícius Lourenço
c4be50c5c2 feat(metrics-apm): adopt reworked @signozhq/ui components
Co-authored-by: Manika Malhotra <manika@signoz.io>
2026-10-01 12:02:13 -03:00
Vinícius Lourenço
8dd0f79f70 feat(llm-observability): adopt reworked @signozhq/ui components
Co-authored-by: Manika Malhotra <manika@signoz.io>
2026-10-01 12:02:13 -03:00
Vinícius Lourenço
86212a1b1c feat(ai-assistant): adopt reworked @signozhq/ui components
Co-authored-by: Manika Malhotra <manika@signoz.io>
2026-10-01 12:02:13 -03:00
Vinícius Lourenço
9f580456ba feat(integrations): adopt reworked @signozhq/ui components
Co-authored-by: Manika Malhotra <manika@signoz.io>
2026-10-01 12:02:12 -03:00
Vinícius Lourenço
6e07d9827f feat(auth-onboarding): adopt reworked @signozhq/ui components
Co-authored-by: Manika Malhotra <manika@signoz.io>
2026-10-01 12:02:12 -03:00
Vinícius Lourenço
b146c15a97 feat(settings): adopt reworked @signozhq/ui components
Co-authored-by: Manika Malhotra <manika@signoz.io>
2026-10-01 12:02:12 -03:00
Vinícius Lourenço
738f341131 feat(navigation): adopt reworked @signozhq/ui components
Co-authored-by: Manika Malhotra <manika@signoz.io>
2026-10-01 12:02:12 -03:00
Vinícius Lourenço
fc1d81dda8 feat(query-builder): adopt reworked @signozhq/ui components
Co-authored-by: Manika Malhotra <manika@signoz.io>
2026-10-01 12:02:12 -03:00
Vinícius Lourenço
c52e41afbf feat(shared-components): adopt reworked @signozhq/ui components
Co-authored-by: Manika Malhotra <manika@signoz.io>
2026-10-01 12:02:11 -03:00
Vinícius Lourenço
e0dc068af3 feat(members-roles-service-accounts): adopt reworked @signozhq/ui components
Co-authored-by: Manika Malhotra <manika@signoz.io>
2026-10-01 12:02:11 -03:00
Vinícius Lourenço
ee4043f42d feat(authz): build AuthZ components on reworked @signozhq/ui
Co-authored-by: Manika Malhotra <manika@signoz.io>
2026-10-01 12:02:11 -03:00
Vinícius Lourenço
f3c4c8685a fix(api): treat requests cancelled before dispatch as cancellations
Co-authored-by: Manika Malhotra <manika@signoz.io>
2026-10-01 12:02:11 -03:00
Vinícius Lourenço
4e7fbdbfeb test(storybook): adopt reworked @signozhq/ui in fixtures and mocks
Co-authored-by: Manika Malhotra <manika@signoz.io>
2026-10-01 12:02:11 -03:00
Vinícius Lourenço
21e2a0bda8 chore(deps): bump @signozhq/ui to 0.2.0 and design-tokens to 2.2.1
Co-authored-by: Manika Malhotra <manika@signoz.io>
2026-10-01 12:02:11 -03:00
Vinícius Lourenço
867b958318 test(storybook): cover classic alert form label states 2026-10-01 12:00:26 -03:00
Vinícius Lourenço
088fe04a20 test(storybook): add planned downtime edit story with specific alert rules 2026-10-01 12:00:26 -03:00
Vinícius Lourenço
0002d9c5f8 test(storybook): hold Noz click-opened state past the thread remount 2026-10-01 12:00:26 -03:00
Vinícius Lourenço
c8b6e83474 test(storybook): drop the fixed pause between filter keystrokes 2026-10-01 12:00:25 -03:00
Vinícius Lourenço
5607edb302 test(storybook): find dashboard settings and panel editor controls by test id 2026-10-01 12:00:25 -03:00
Vinícius Lourenço
680ac5e88f fix(tanstack-table): keep cells mounted when inline row callbacks change 2026-10-01 12:00:25 -03:00
Vinícius Lourenço
09f9d2f3c8 test(storybook): cover panel editor chart options, thresholds and links 2026-10-01 12:00:25 -03:00
Vinícius Lourenço
203d7a0753 test(storybook): cover dashboard settings, variables and authz states 2026-10-01 12:00:25 -03:00
Vinícius Lourenço
3ba8c9390c fix(storybook): attribute failed responses to the story that sent them 2026-10-01 11:59:59 -03:00
Vinícius Lourenço
6cc07a4d75 test(storybook): cover Noz voice recording states 2026-10-01 11:59:58 -03:00
Vinícius Lourenço
856cd91491 test(storybook): assert the in-flight create key button is disabled 2026-10-01 11:59:58 -03:00
Vinícius Lourenço
f22196a2c0 test(storybook): cover ingestion create key validation, expiry and submit states 2026-10-01 11:59:58 -03:00
Vinícius Lourenço
0438850c45 test(storybook): cover ingestion key tag adding and added states 2026-10-01 11:59:58 -03:00
Vinícius Lourenço
2cc9421350 test(storybook): click exceptions filter suggestions as they are found 2026-10-01 11:59:58 -03:00
Vinícius Lourenço
c692297ca6 test(storybook): hold the pod filter stories at their opening height 2026-10-01 11:59:58 -03:00
Vinícius Lourenço
cc6ddd5233 test(storybook): wait for the filter's scrollbars before focusing it 2026-10-01 11:59:57 -03:00
Vinícius Lourenço
d72424d9d0 test(storybook): keep filter focused while scrollbars initialise 2026-10-01 11:59:57 -03:00
Vinícius Lourenço
bbec5691e0 test(storybook): add query builder filter interaction stories 2026-10-01 11:59:57 -03:00
Vinícius Lourenço
cf61f7b31a test(storybook): fix interaction stories against current main 2026-10-01 11:59:57 -03:00
Vinícius Lourenço
02055934ba test(storybook): add interaction stories for menus, drawers and selects 2026-10-01 11:59:57 -03:00
1205 changed files with 17406 additions and 23365 deletions

View File

@@ -66,8 +66,8 @@ jobs:
uses: actions/setup-go@v5
with:
go-version: "1.24"
- name: check-semconv-generated-files-and-product-literals
run: make semconv-check
- name: check-semconv-generated-files
run: go run ./scripts/semconv -check
build:
if: |
github.event_name == 'merge_group' ||

View File

@@ -262,10 +262,6 @@ py-clean: ## Clear all pycache and pytest cache from tests directory recursively
semconv-generate: ## Regenerate semantic-convention families for Go and TypeScript
@go run ./scripts/semconv
.PHONY: semconv-check
semconv-check: ## Verify generated semantic-convention files and reject old-name product literals
@go run ./scripts/semconv -check -lint
.PHONY: gen-mocks
gen-mocks:
@echo ">> Generating mocks"

View File

@@ -7792,8 +7792,6 @@ components:
$ref: '#/components/schemas/TelemetrytypesFieldContext'
fieldDataType:
$ref: '#/components/schemas/TelemetrytypesFieldDataType'
fieldResolution:
$ref: '#/components/schemas/TelemetrytypesFieldResolution'
meta:
properties:
unit:
@@ -7838,10 +7836,6 @@ components:
rowsScanned:
minimum: 0
type: integer
semconvResolutions:
items:
$ref: '#/components/schemas/Querybuildertypesv5SemconvResolution'
type: array
stepIntervals:
additionalProperties:
minimum: 0
@@ -7908,8 +7902,6 @@ components:
$ref: '#/components/schemas/TelemetrytypesFieldContext'
fieldDataType:
$ref: '#/components/schemas/TelemetrytypesFieldDataType'
fieldResolution:
$ref: '#/components/schemas/TelemetrytypesFieldResolution'
name:
type: string
signal:
@@ -7997,8 +7989,6 @@ components:
$ref: '#/components/schemas/TelemetrytypesFieldContext'
fieldDataType:
$ref: '#/components/schemas/TelemetrytypesFieldDataType'
fieldResolution:
$ref: '#/components/schemas/TelemetrytypesFieldResolution'
name:
type: string
signal:
@@ -8575,20 +8565,6 @@ components:
stepInterval:
$ref: '#/components/schemas/Querybuildertypesv5Step'
type: object
Querybuildertypesv5SemconvResolution:
properties:
current:
type: string
kind:
type: string
members:
items:
type: string
nullable: true
type: array
requested:
type: string
type: object
Querybuildertypesv5Step:
description: Step interval. Accepts a Go duration string (e.g., "60s", "1m",
"1h") or a number representing seconds (e.g., 60).
@@ -10371,11 +10347,6 @@ components:
- number
- ""
type: string
TelemetrytypesFieldResolution:
enum:
- exact
- ""
type: string
TelemetrytypesGettableFieldKeys:
properties:
complete:
@@ -10401,42 +10372,6 @@ components:
- values
- complete
type: object
TelemetrytypesGettableSemconvMigrationReport:
properties:
endUnixMilli:
format: int64
type: integer
entries:
items:
$ref: '#/components/schemas/TelemetrytypesSemconvMigrationReportEntry'
nullable: true
type: array
startUnixMilli:
format: int64
type: integer
required:
- entries
type: object
TelemetrytypesSemconvMigrationReportEntry:
properties:
current:
type: string
lastSeenUnixMilli:
format: int64
type: integer
old:
type: string
resourceSets:
minimum: 0
type: integer
services:
items:
type: string
nullable: true
type: array
signal:
type: string
type: object
TelemetrytypesSignal:
enum:
- traces
@@ -10457,8 +10392,6 @@ components:
$ref: '#/components/schemas/TelemetrytypesFieldContext'
fieldDataType:
$ref: '#/components/schemas/TelemetrytypesFieldDataType'
fieldResolution:
$ref: '#/components/schemas/TelemetrytypesFieldResolution'
name:
type: string
signal:
@@ -12806,65 +12739,6 @@ paths:
tags:
- fields
x-signoz-stability: alpha
/api/v1/fields/semconv-migration:
get:
deprecated: false
description: Returns services that still emit old semantic-convention names
without the current family name
operationId: GetSemconvMigrationReport
parameters:
- in: query
name: startUnixMilli
schema:
format: int64
type: integer
- in: query
name: endUnixMilli
schema:
format: int64
type: integer
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TelemetrytypesGettableSemconvMigrationReport'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get semantic-convention migration report
tags:
- fields
x-signoz-stability: alpha
/api/v1/fields/values:
get:
deprecated: false
@@ -21387,8 +21261,8 @@ paths:
alert: Payments-api error log rate above 1%
alertType: LOGS_BASED_ALERT
annotations:
description: Error log rate in {{$deployment.environment.name}}
is {{$value}}%
description: Error log rate in {{$deployment.environment}} is
{{$value}}%
summary: Payments-api error rate above {{$threshold}}%
condition:
compositeQuery:
@@ -21404,7 +21278,7 @@ paths:
groupBy:
- fieldContext: resource
fieldDataType: string
name: deployment.environment.name
name: deployment.environment
name: A
signal: logs
stepInterval: 60
@@ -21418,14 +21292,14 @@ paths:
groupBy:
- fieldContext: resource
fieldDataType: string
name: deployment.environment.name
name: deployment.environment
name: B
signal: logs
stepInterval: 60
type: builder_query
- spec:
expression: (A / B) * 100
legend: '{{deployment.environment.name}}'
legend: '{{deployment.environment}}'
name: F1
type: builder_formula
queryType: builder
@@ -21451,7 +21325,7 @@ paths:
team: payments
notificationSettings:
groupBy:
- deployment.environment.name
- deployment.environment
renotify:
alertStates:
- firing
@@ -21470,7 +21344,7 @@ paths:
alertType: LOGS_BASED_ALERT
annotations:
description: '{{$k8s.pod.name}} emitted {{$value}} panic log(s)
in {{$deployment.environment.name}}.'
in {{$deployment.environment}}.'
summary: Payments service panic
condition:
compositeQuery:
@@ -21488,8 +21362,8 @@ paths:
name: k8s.pod.name
- fieldContext: resource
fieldDataType: string
name: deployment.environment.name
legend: '{{k8s.pod.name}} ({{deployment.environment.name}})'
name: deployment.environment
legend: '{{k8s.pod.name}} ({{deployment.environment}})'
name: A
signal: logs
stepInterval: 60
@@ -21518,7 +21392,7 @@ paths:
notificationSettings:
groupBy:
- k8s.pod.name
- deployment.environment.name
- deployment.environment
renotify:
alertStates:
- firing
@@ -21588,9 +21462,8 @@ paths:
version: v5
metric_promql:
description: PromQL expression instead of the builder. Dotted OTEL
resource attributes are quoted ("deployment.environment.name").
Useful for queries that combine series with group_right or other
Prom operators.
resource attributes are quoted ("deployment.environment"). Useful
for queries that combine series with group_right or other Prom operators.
summary: Metric threshold PromQL rule
value:
alert: Kafka consumer group lag above 1000
@@ -21606,9 +21479,9 @@ paths:
- spec:
legend: '{{topic}}/{{partition}} ({{group}})'
name: A
query: (max by(topic, partition, "deployment.environment.name")(kafka_log_end_offset)
- on(topic, partition, "deployment.environment.name")
group_right max by(group, topic, partition, "deployment.environment.name")(kafka_consumer_committed_offset))
query: (max by(topic, partition, "deployment.environment")(kafka_log_end_offset)
- on(topic, partition, "deployment.environment") group_right
max by(group, topic, partition, "deployment.environment")(kafka_consumer_committed_offset))
> 0
type: promql
queryType: promql
@@ -21744,7 +21617,7 @@ paths:
alertType: METRIC_BASED_ALERT
annotations:
description: Pod {{$k8s.pod.name}} CPU is at {{$value}} of request
in {{$deployment.environment.name}}.
in {{$deployment.environment}}.
summary: Pod CPU above {{$threshold}} of request
condition:
compositeQuery:
@@ -21763,8 +21636,8 @@ paths:
name: k8s.pod.name
- fieldContext: resource
fieldDataType: string
name: deployment.environment.name
legend: '{{k8s.pod.name}} ({{deployment.environment.name}})'
name: deployment.environment
legend: '{{k8s.pod.name}} ({{deployment.environment}})'
name: A
signal: metrics
stepInterval: 60
@@ -21795,7 +21668,7 @@ paths:
notificationSettings:
groupBy:
- k8s.pod.name
- deployment.environment.name
- deployment.environment
renotify:
alertStates:
- firing
@@ -21819,7 +21692,7 @@ paths:
alert: API 5xx error rate above 1%
alertType: TRACES_BASED_ALERT
annotations:
description: '{{$service.name}} 5xx rate in {{$deployment.environment.name}}
description: '{{$service.name}} 5xx rate in {{$deployment.environment}}
is {{$value}}%.'
summary: API service error rate elevated
condition:
@@ -21831,7 +21704,7 @@ paths:
- expression: count()
disabled: true
filter:
expression: service.name CONTAINS 'api' AND http.response.status_code
expression: service.name CONTAINS 'api' AND http.status_code
>= 500
groupBy:
- fieldContext: resource
@@ -21839,7 +21712,7 @@ paths:
name: service.name
- fieldContext: resource
fieldDataType: string
name: deployment.environment.name
name: deployment.environment
name: A
signal: traces
stepInterval: 60
@@ -21856,14 +21729,14 @@ paths:
name: service.name
- fieldContext: resource
fieldDataType: string
name: deployment.environment.name
name: deployment.environment
name: B
signal: traces
stepInterval: 60
type: builder_query
- spec:
expression: (A / B) * 100
legend: '{{service.name}} ({{deployment.environment.name}})'
legend: '{{service.name}} ({{deployment.environment}})'
name: F1
type: builder_formula
queryType: builder
@@ -21891,7 +21764,7 @@ paths:
notificationSettings:
groupBy:
- service.name
- deployment.environment.name
- deployment.environment
newGroupEvalDelay: 2m
renotify:
alertStates:
@@ -22340,8 +22213,8 @@ paths:
alert: Payments-api error log rate above 1%
alertType: LOGS_BASED_ALERT
annotations:
description: Error log rate in {{$deployment.environment.name}}
is {{$value}}%
description: Error log rate in {{$deployment.environment}} is
{{$value}}%
summary: Payments-api error rate above {{$threshold}}%
condition:
compositeQuery:
@@ -22357,7 +22230,7 @@ paths:
groupBy:
- fieldContext: resource
fieldDataType: string
name: deployment.environment.name
name: deployment.environment
name: A
signal: logs
stepInterval: 60
@@ -22371,14 +22244,14 @@ paths:
groupBy:
- fieldContext: resource
fieldDataType: string
name: deployment.environment.name
name: deployment.environment
name: B
signal: logs
stepInterval: 60
type: builder_query
- spec:
expression: (A / B) * 100
legend: '{{deployment.environment.name}}'
legend: '{{deployment.environment}}'
name: F1
type: builder_formula
queryType: builder
@@ -22404,7 +22277,7 @@ paths:
team: payments
notificationSettings:
groupBy:
- deployment.environment.name
- deployment.environment
renotify:
alertStates:
- firing
@@ -22423,7 +22296,7 @@ paths:
alertType: LOGS_BASED_ALERT
annotations:
description: '{{$k8s.pod.name}} emitted {{$value}} panic log(s)
in {{$deployment.environment.name}}.'
in {{$deployment.environment}}.'
summary: Payments service panic
condition:
compositeQuery:
@@ -22441,8 +22314,8 @@ paths:
name: k8s.pod.name
- fieldContext: resource
fieldDataType: string
name: deployment.environment.name
legend: '{{k8s.pod.name}} ({{deployment.environment.name}})'
name: deployment.environment
legend: '{{k8s.pod.name}} ({{deployment.environment}})'
name: A
signal: logs
stepInterval: 60
@@ -22471,7 +22344,7 @@ paths:
notificationSettings:
groupBy:
- k8s.pod.name
- deployment.environment.name
- deployment.environment
renotify:
alertStates:
- firing
@@ -22541,9 +22414,8 @@ paths:
version: v5
metric_promql:
description: PromQL expression instead of the builder. Dotted OTEL
resource attributes are quoted ("deployment.environment.name").
Useful for queries that combine series with group_right or other
Prom operators.
resource attributes are quoted ("deployment.environment"). Useful
for queries that combine series with group_right or other Prom operators.
summary: Metric threshold PromQL rule
value:
alert: Kafka consumer group lag above 1000
@@ -22559,9 +22431,9 @@ paths:
- spec:
legend: '{{topic}}/{{partition}} ({{group}})'
name: A
query: (max by(topic, partition, "deployment.environment.name")(kafka_log_end_offset)
- on(topic, partition, "deployment.environment.name")
group_right max by(group, topic, partition, "deployment.environment.name")(kafka_consumer_committed_offset))
query: (max by(topic, partition, "deployment.environment")(kafka_log_end_offset)
- on(topic, partition, "deployment.environment") group_right
max by(group, topic, partition, "deployment.environment")(kafka_consumer_committed_offset))
> 0
type: promql
queryType: promql
@@ -22697,7 +22569,7 @@ paths:
alertType: METRIC_BASED_ALERT
annotations:
description: Pod {{$k8s.pod.name}} CPU is at {{$value}} of request
in {{$deployment.environment.name}}.
in {{$deployment.environment}}.
summary: Pod CPU above {{$threshold}} of request
condition:
compositeQuery:
@@ -22716,8 +22588,8 @@ paths:
name: k8s.pod.name
- fieldContext: resource
fieldDataType: string
name: deployment.environment.name
legend: '{{k8s.pod.name}} ({{deployment.environment.name}})'
name: deployment.environment
legend: '{{k8s.pod.name}} ({{deployment.environment}})'
name: A
signal: metrics
stepInterval: 60
@@ -22748,7 +22620,7 @@ paths:
notificationSettings:
groupBy:
- k8s.pod.name
- deployment.environment.name
- deployment.environment
renotify:
alertStates:
- firing
@@ -22772,7 +22644,7 @@ paths:
alert: API 5xx error rate above 1%
alertType: TRACES_BASED_ALERT
annotations:
description: '{{$service.name}} 5xx rate in {{$deployment.environment.name}}
description: '{{$service.name}} 5xx rate in {{$deployment.environment}}
is {{$value}}%.'
summary: API service error rate elevated
condition:
@@ -22784,7 +22656,7 @@ paths:
- expression: count()
disabled: true
filter:
expression: service.name CONTAINS 'api' AND http.response.status_code
expression: service.name CONTAINS 'api' AND http.status_code
>= 500
groupBy:
- fieldContext: resource
@@ -22792,7 +22664,7 @@ paths:
name: service.name
- fieldContext: resource
fieldDataType: string
name: deployment.environment.name
name: deployment.environment
name: A
signal: traces
stepInterval: 60
@@ -22809,14 +22681,14 @@ paths:
name: service.name
- fieldContext: resource
fieldDataType: string
name: deployment.environment.name
name: deployment.environment
name: B
signal: traces
stepInterval: 60
type: builder_query
- spec:
expression: (A / B) * 100
legend: '{{service.name}} ({{deployment.environment.name}})'
legend: '{{service.name}} ({{deployment.environment}})'
name: F1
type: builder_formula
queryType: builder
@@ -22844,7 +22716,7 @@ paths:
notificationSettings:
groupBy:
- service.name
- deployment.environment.name
- deployment.environment
newGroupEvalDelay: 2m
renotify:
alertStates:
@@ -23194,8 +23066,8 @@ paths:
alert: Payments-api error log rate above 1%
alertType: LOGS_BASED_ALERT
annotations:
description: Error log rate in {{$deployment.environment.name}}
is {{$value}}%
description: Error log rate in {{$deployment.environment}} is
{{$value}}%
summary: Payments-api error rate above {{$threshold}}%
condition:
compositeQuery:
@@ -23211,7 +23083,7 @@ paths:
groupBy:
- fieldContext: resource
fieldDataType: string
name: deployment.environment.name
name: deployment.environment
name: A
signal: logs
stepInterval: 60
@@ -23225,14 +23097,14 @@ paths:
groupBy:
- fieldContext: resource
fieldDataType: string
name: deployment.environment.name
name: deployment.environment
name: B
signal: logs
stepInterval: 60
type: builder_query
- spec:
expression: (A / B) * 100
legend: '{{deployment.environment.name}}'
legend: '{{deployment.environment}}'
name: F1
type: builder_formula
queryType: builder
@@ -23258,7 +23130,7 @@ paths:
team: payments
notificationSettings:
groupBy:
- deployment.environment.name
- deployment.environment
renotify:
alertStates:
- firing
@@ -23277,7 +23149,7 @@ paths:
alertType: LOGS_BASED_ALERT
annotations:
description: '{{$k8s.pod.name}} emitted {{$value}} panic log(s)
in {{$deployment.environment.name}}.'
in {{$deployment.environment}}.'
summary: Payments service panic
condition:
compositeQuery:
@@ -23295,8 +23167,8 @@ paths:
name: k8s.pod.name
- fieldContext: resource
fieldDataType: string
name: deployment.environment.name
legend: '{{k8s.pod.name}} ({{deployment.environment.name}})'
name: deployment.environment
legend: '{{k8s.pod.name}} ({{deployment.environment}})'
name: A
signal: logs
stepInterval: 60
@@ -23325,7 +23197,7 @@ paths:
notificationSettings:
groupBy:
- k8s.pod.name
- deployment.environment.name
- deployment.environment
renotify:
alertStates:
- firing
@@ -23395,9 +23267,8 @@ paths:
version: v5
metric_promql:
description: PromQL expression instead of the builder. Dotted OTEL
resource attributes are quoted ("deployment.environment.name").
Useful for queries that combine series with group_right or other
Prom operators.
resource attributes are quoted ("deployment.environment"). Useful
for queries that combine series with group_right or other Prom operators.
summary: Metric threshold PromQL rule
value:
alert: Kafka consumer group lag above 1000
@@ -23413,9 +23284,9 @@ paths:
- spec:
legend: '{{topic}}/{{partition}} ({{group}})'
name: A
query: (max by(topic, partition, "deployment.environment.name")(kafka_log_end_offset)
- on(topic, partition, "deployment.environment.name")
group_right max by(group, topic, partition, "deployment.environment.name")(kafka_consumer_committed_offset))
query: (max by(topic, partition, "deployment.environment")(kafka_log_end_offset)
- on(topic, partition, "deployment.environment") group_right
max by(group, topic, partition, "deployment.environment")(kafka_consumer_committed_offset))
> 0
type: promql
queryType: promql
@@ -23551,7 +23422,7 @@ paths:
alertType: METRIC_BASED_ALERT
annotations:
description: Pod {{$k8s.pod.name}} CPU is at {{$value}} of request
in {{$deployment.environment.name}}.
in {{$deployment.environment}}.
summary: Pod CPU above {{$threshold}} of request
condition:
compositeQuery:
@@ -23570,8 +23441,8 @@ paths:
name: k8s.pod.name
- fieldContext: resource
fieldDataType: string
name: deployment.environment.name
legend: '{{k8s.pod.name}} ({{deployment.environment.name}})'
name: deployment.environment
legend: '{{k8s.pod.name}} ({{deployment.environment}})'
name: A
signal: metrics
stepInterval: 60
@@ -23602,7 +23473,7 @@ paths:
notificationSettings:
groupBy:
- k8s.pod.name
- deployment.environment.name
- deployment.environment
renotify:
alertStates:
- firing
@@ -23626,7 +23497,7 @@ paths:
alert: API 5xx error rate above 1%
alertType: TRACES_BASED_ALERT
annotations:
description: '{{$service.name}} 5xx rate in {{$deployment.environment.name}}
description: '{{$service.name}} 5xx rate in {{$deployment.environment}}
is {{$value}}%.'
summary: API service error rate elevated
condition:
@@ -23638,7 +23509,7 @@ paths:
- expression: count()
disabled: true
filter:
expression: service.name CONTAINS 'api' AND http.response.status_code
expression: service.name CONTAINS 'api' AND http.status_code
>= 500
groupBy:
- fieldContext: resource
@@ -23646,7 +23517,7 @@ paths:
name: service.name
- fieldContext: resource
fieldDataType: string
name: deployment.environment.name
name: deployment.environment
name: A
signal: traces
stepInterval: 60
@@ -23663,14 +23534,14 @@ paths:
name: service.name
- fieldContext: resource
fieldDataType: string
name: deployment.environment.name
name: deployment.environment
name: B
signal: traces
stepInterval: 60
type: builder_query
- spec:
expression: (A / B) * 100
legend: '{{service.name}} ({{deployment.environment.name}})'
legend: '{{service.name}} ({{deployment.environment}})'
name: F1
type: builder_formula
queryType: builder
@@ -23698,7 +23569,7 @@ paths:
notificationSettings:
groupBy:
- service.name
- deployment.environment.name
- deployment.environment
newGroupEvalDelay: 2m
renotify:
alertStates:
@@ -24557,8 +24428,8 @@ paths:
alert: Payments-api error log rate above 1%
alertType: LOGS_BASED_ALERT
annotations:
description: Error log rate in {{$deployment.environment.name}}
is {{$value}}%
description: Error log rate in {{$deployment.environment}} is
{{$value}}%
summary: Payments-api error rate above {{$threshold}}%
condition:
compositeQuery:
@@ -24574,7 +24445,7 @@ paths:
groupBy:
- fieldContext: resource
fieldDataType: string
name: deployment.environment.name
name: deployment.environment
name: A
signal: logs
stepInterval: 60
@@ -24588,14 +24459,14 @@ paths:
groupBy:
- fieldContext: resource
fieldDataType: string
name: deployment.environment.name
name: deployment.environment
name: B
signal: logs
stepInterval: 60
type: builder_query
- spec:
expression: (A / B) * 100
legend: '{{deployment.environment.name}}'
legend: '{{deployment.environment}}'
name: F1
type: builder_formula
queryType: builder
@@ -24621,7 +24492,7 @@ paths:
team: payments
notificationSettings:
groupBy:
- deployment.environment.name
- deployment.environment
renotify:
alertStates:
- firing
@@ -24640,7 +24511,7 @@ paths:
alertType: LOGS_BASED_ALERT
annotations:
description: '{{$k8s.pod.name}} emitted {{$value}} panic log(s)
in {{$deployment.environment.name}}.'
in {{$deployment.environment}}.'
summary: Payments service panic
condition:
compositeQuery:
@@ -24658,8 +24529,8 @@ paths:
name: k8s.pod.name
- fieldContext: resource
fieldDataType: string
name: deployment.environment.name
legend: '{{k8s.pod.name}} ({{deployment.environment.name}})'
name: deployment.environment
legend: '{{k8s.pod.name}} ({{deployment.environment}})'
name: A
signal: logs
stepInterval: 60
@@ -24688,7 +24559,7 @@ paths:
notificationSettings:
groupBy:
- k8s.pod.name
- deployment.environment.name
- deployment.environment
renotify:
alertStates:
- firing
@@ -24758,9 +24629,8 @@ paths:
version: v5
metric_promql:
description: PromQL expression instead of the builder. Dotted OTEL
resource attributes are quoted ("deployment.environment.name").
Useful for queries that combine series with group_right or other
Prom operators.
resource attributes are quoted ("deployment.environment"). Useful
for queries that combine series with group_right or other Prom operators.
summary: Metric threshold PromQL rule
value:
alert: Kafka consumer group lag above 1000
@@ -24776,9 +24646,9 @@ paths:
- spec:
legend: '{{topic}}/{{partition}} ({{group}})'
name: A
query: (max by(topic, partition, "deployment.environment.name")(kafka_log_end_offset)
- on(topic, partition, "deployment.environment.name")
group_right max by(group, topic, partition, "deployment.environment.name")(kafka_consumer_committed_offset))
query: (max by(topic, partition, "deployment.environment")(kafka_log_end_offset)
- on(topic, partition, "deployment.environment") group_right
max by(group, topic, partition, "deployment.environment")(kafka_consumer_committed_offset))
> 0
type: promql
queryType: promql
@@ -24914,7 +24784,7 @@ paths:
alertType: METRIC_BASED_ALERT
annotations:
description: Pod {{$k8s.pod.name}} CPU is at {{$value}} of request
in {{$deployment.environment.name}}.
in {{$deployment.environment}}.
summary: Pod CPU above {{$threshold}} of request
condition:
compositeQuery:
@@ -24933,8 +24803,8 @@ paths:
name: k8s.pod.name
- fieldContext: resource
fieldDataType: string
name: deployment.environment.name
legend: '{{k8s.pod.name}} ({{deployment.environment.name}})'
name: deployment.environment
legend: '{{k8s.pod.name}} ({{deployment.environment}})'
name: A
signal: metrics
stepInterval: 60
@@ -24965,7 +24835,7 @@ paths:
notificationSettings:
groupBy:
- k8s.pod.name
- deployment.environment.name
- deployment.environment
renotify:
alertStates:
- firing
@@ -24989,7 +24859,7 @@ paths:
alert: API 5xx error rate above 1%
alertType: TRACES_BASED_ALERT
annotations:
description: '{{$service.name}} 5xx rate in {{$deployment.environment.name}}
description: '{{$service.name}} 5xx rate in {{$deployment.environment}}
is {{$value}}%.'
summary: API service error rate elevated
condition:
@@ -25001,7 +24871,7 @@ paths:
- expression: count()
disabled: true
filter:
expression: service.name CONTAINS 'api' AND http.response.status_code
expression: service.name CONTAINS 'api' AND http.status_code
>= 500
groupBy:
- fieldContext: resource
@@ -25009,7 +24879,7 @@ paths:
name: service.name
- fieldContext: resource
fieldDataType: string
name: deployment.environment.name
name: deployment.environment
name: A
signal: traces
stepInterval: 60
@@ -25026,14 +24896,14 @@ paths:
name: service.name
- fieldContext: resource
fieldDataType: string
name: deployment.environment.name
name: deployment.environment
name: B
signal: traces
stepInterval: 60
type: builder_query
- spec:
expression: (A / B) * 100
legend: '{{service.name}} ({{deployment.environment.name}})'
legend: '{{service.name}} ({{deployment.environment}})'
name: F1
type: builder_formula
queryType: builder
@@ -25061,7 +24931,7 @@ paths:
notificationSettings:
groupBy:
- service.name
- deployment.environment.name
- deployment.environment
newGroupEvalDelay: 2m
renotify:
alertStates:

View File

@@ -84,9 +84,9 @@ A storage answers four questions and nothing else:
| WhenAbsent | Absent row reads | Positive filter | Raw select | Multi-candidate column | Field keys |
|---|---|---|---|---|---|
| `AlwaysPresent` | a real value | no guard | no guard | no branch, ends the candidate list | table columns |
| `AbsentIsSentinel` | `''`, 0, false, and that is not a value | exists guard | exists guard | presence branch | map attributes, cast JSON paths, string families of such members |
| `AbsentIsSentinel` | `''`, 0, false, and that is not a value | exists guard | exists guard | presence branch | map attributes, cast JSON paths, string families |
| `AbsentIsNull` | NULL | no guard | no guard | presence branch | multi-era folds, body JSON paths, numeric families |
| `AbsentIsValue` | `''`, and that is the keyless contract | no guard | no guard | no presence branch | metrics labels, rule state history labels, and families of such members |
| `AbsentIsValue` | `''`, and that is the keyless contract | no guard | no guard | no presence branch | metrics labels, rule state history labels |
### The generic layer
@@ -109,7 +109,7 @@ The functions, from the outside in:
| `RejectsBodyFunction(traits, operator)` | Runs before resolution. A storage without body functions (`has`, `hasAny`, `hasAll`, `hasToken`, `search`) errors. The fingerprint side of a split skips the term, because the main query evaluates it. After resolution, `Condition` errors when `has`, `hasAny`, `hasAll`, or `hasToken` lands on a map-backed key (resource, attribute, scope), before the split can drop it. |
| `SharedCondition(...)` | The `Compile` of every storage without its own condition language: `LogicalRead`, the shared data-type collision cast, `OperatorCondition`, then the guard rule. |
| `OperatorCondition(...)` | The operator switch over an already cast read. A storage with its own cast policy composes with it. |
| `LogicalRead(...)` | The only place family expressions are built. A single-member field reads through its member. A family merges the member reads, current member first: `COALESCE(NULLIF(m1, ''), NULLIF(m2, ''), '')` for strings, `multiIf` with a NULL tail for numbers. It ORs the member presence tests. A row without any member reads what the tail of the merge reads. When every member reads its sentinel as a value, so does the family. A member with a value map reads through `TransformRead`. `NOT EXISTS` is the read's `Absence`, the storage's own negated form. |
| `LogicalRead(...)` | The only place family expressions are built. A single-member field reads through its member. A family merges the member reads, current member first: `COALESCE(NULLIF(m1, ''), NULLIF(m2, ''), '')` for strings, `multiIf` with a NULL tail for numbers. It ORs the member presence tests. A row without any member reads what the tail of the merge reads. A member with a value map reads through `TransformRead`. `NOT EXISTS` is the read's `Absence`, the storage's own negated form. |
### A resolved key
@@ -131,7 +131,7 @@ A `LogicalField` is one meaning: one name, context, and data type, backed by one
The resolution order is the same for every storage, in the filter and in every select field, group by, order by, and aggregation:
1. **Own context.** A key under one of the storage's own contexts (`span.x`, `log.x`) matches its own context first, then a column the storage knows under that context. Only when both miss does it look up as if it had no context. `span.http.method` then corrects to the attribute. Strict contexts (`resource.`, `attribute.`, `scope.`, `body.`) are kept as written.
2. **Matches.** The metadata keys under the key's spellings, grouped into families when the flag is on. Each combination of context and data type is one interpretation. A key written as `exact(key)` in an expression, or a structured key with `fieldResolution: exact`, reads its own spelling alone.
2. **Matches.** The metadata keys under the key's spellings, grouped into families when the flag is on. Each combination of context and data type is one interpretation.
3. **Ambiguity.** A filter settles several interpretations by resource over attribute, with a warning. A select field, group by, order by, or aggregation keeps every interpretation in metadata order and folds them, so it shows the value wherever it is.
4. **Intrinsic column first**, bare keys only. A column every row has leads the list. Metadata can report the column, or the storage's `Fallback` can. When only `Fallback` knows the column, its key carries the data type the column reads as (`querybuilder.ColumnDataType`). A same-named metadata key of a contradicting type then drops, and a time column merges none. When metadata reports the column too, every match reads. A metadata gap degrades to the correct column, never to a corrupt metadata key.
5. **Fallback.** With no match, the storage's fallback keys for the key. When the storage ignores unknown keys (a side query whose main query owns the error), the key is `Skipped`. Otherwise a key nothing can serve is an error with suggestions. The not-found warning fires only when every fallback key is a guess, that is, none of them is always present.

View File

@@ -70,3 +70,49 @@
Object.defineProperty(window, 'Date', { value: FrozenDate, writable: true });
})();
</script>
<script>
// Storybook has no microphone. Chromium's recognizer asks for one and for
// Google's speech service, and the `not-allowed` or `network` it reports makes
// the assistant hide its mic for the rest of the tab. This one starts
// listening at once and hears only what a story passes to `hear`.
(() => {
class StorySpeechRecognition extends EventTarget {
static listening = null;
lang = 'en-US';
continuous = false;
interimResults = false;
onstart = null;
onresult = null;
onerror = null;
onend = null;
start() {
StorySpeechRecognition.listening = this;
setTimeout(() => this.onstart?.());
}
stop() {
if (StorySpeechRecognition.listening === this) {
StorySpeechRecognition.listening = null;
}
setTimeout(() => this.onend?.());
}
abort() {
this.stop();
}
/** An interim result: the words so far, still listening. */
hear(transcript) {
const result = Object.assign([{ transcript, confidence: 1 }], {
isFinal: false,
});
this.onresult?.({ resultIndex: 0, results: [result] });
}
}
window.SpeechRecognition = StorySpeechRecognition;
window.webkitSpeechRecognition = StorySpeechRecognition;
})();
</script>

View File

@@ -122,7 +122,21 @@ const preview: Preview = {
['List', 'New', 'Edit'],
],
'Dashboards',
['List', 'Detail', 'Panel Editor', 'Public'],
[
'List',
'Detail',
[
'Overview',
'Settings',
'Variables',
'New Variable',
'Edit Variable',
'Authz',
],
'Panel Editor',
['Overview', 'Chart Options', 'Thresholds and Links'],
'Public',
],
'Services',
['List', 'Detail', 'Top Level Operations', 'Service Map'],
'Logs',

View File

@@ -23,6 +23,13 @@ const IGNORED_MESSAGES = [
// (YouTube embeds, the docs pane) so they hit the real network instead of
// an unanswered msw request; the block is the point, not a bug.
/violates the following Content Security Policy directive/,
// The filter editor's ANTLR parser reports every syntax error through
// `console.error` (`line 1:14 missing ...`), so each partial expression
// typed into it logs one; the editor shows the same errors on screen.
/^line \d+:\d+ /,
// Chromium's echo of a 4xx/5xx response, which the `[response]` entry below
// already reports with its URL and the time its request left.
/^Failed to load resource: the server responded with a status of /,
];
interface CapturedMessage {
@@ -86,7 +93,9 @@ const config: TestRunnerConfig = {
page.on('response', (response) => {
if (response.status() >= 400) {
messages.push({
at: Date.now(),
// The browser's clock, when the request left: a busy runner receives
// the outgoing story's responses after the next story has started.
at: response.request().timing().startTime,
text: `[response] ${response.status()} ${response.url()}`,
});
}

View File

@@ -25,6 +25,7 @@ You are operating within a constrained context window and strict system prompts.
- Never create barrel files.
- When writing new css, prefer CSS Modules
- Use ./docs/css-modules-guide.md as reference on how to write good CSS Modules.
- Before styling a `@signozhq/ui` component, check its props in `node_modules/@signozhq/ui/dist/<component>/*.d.ts` for `className`/`style` support; some omit them. Avoid overriding the component's CSS vars unless explicitly needed.
- When writing code that could need authorization checks, read ./src/lib/authz/README.md
3. FORCED VERIFICATION: Your internal tools mark file writes as successful even if the code does not compile. You are FORBIDDEN from reporting a task as complete until you have:

View File

@@ -0,0 +1,191 @@
# Popover migration analysis
The new `@signozhq/ui` Popover is click-only. This note maps the current app onto that API so a later migration does not break open state, focus, or the global `.ant-popover` overrides. It does not change any call site.
The component this describes lives in the design-system repo (`packages/ui/src/popover`). Storybook stories for it are `Playground`, `SaveViewForm`, `ControlledOpen`, `InsideDialog`, `MaxHeightScrolling`, and `AnchoredTypeahead`.
## The API to migrate onto
```tsx
<Popover
nativeButton
side="bottom"
align="start"
trigger={<button type="button">Open</button>}
open={open}
onOpenChange={setOpen}
>
{content}
</Popover>
```
| Today | New Popover |
| --- | --- |
| antd `children` (the trigger) | `trigger` (required) |
| antd `content`, or Radix `<PopoverContent>` | `children` |
| antd `trigger="click"` | the only mode; there is no `openOn` |
| antd `trigger="hover"` | not this component; a Tooltip, or a click popover when the body is interactive |
| `placement="bottomRight"` | `side="bottom"` `align="end"` |
| `placement="bottomLeft"` | `side="bottom"` `align="start"` |
| `open` / `onOpenChange` | kept; `onOpenChange` receives Base UI's reason and `details.cancel()` |
| `defaultOpen` | removed; nothing in the app starts open |
| `overlayClassName`, `overlayStyle`, `overlayInnerStyle`, `className` | rejected on the shell; size is `width` / `maxWidth` / `maxHeight` |
| `getPopupContainer` | `container` |
| `destroyTooltipOnHide` | the only behavior; the popup unmounts on close |
| `arrow` / `showArrow` | rejected; the shell has no arrow |
| Radix `PopoverAnchor` | `anchor`, and the trigger is still required |
| Radix `withPortal={false}` | `container` (inside a Dialog the popup already portals into the panel) |
| Radix `onInteractOutside` | `onOpenChange` + `details.cancel()` when `reason === 'outside-press'` |
| `sideOffset` / `alignOffset` | not props; fixed at 4px and 0 |
`nativeButton` is required. `true` when `trigger` renders a `<button>` (including `Button`). `false` for anything else.
A node that renders nothing (`null`, `false`, `''`) renders the trigger alone, with no listeners and no portal.
## What is in the app today
Counted from `frontend/src` on this branch:
- **29 files** import `Popover` from `antd`.
- **7 files** import `Popover` from `@signozhq/ui` (the Radix wrapper this rework replaces). Those seven break as soon as the package ships, because `children` stops being the trigger.
- **18 SCSS files** override `.ant-popover*`.
antd `Dropdown` is already on `@signozhq/ui/dropdown`. antd `Tooltip` (on the order of 100 importers) is a separate migration.
### antd files
`src/components/CustomTimePicker/CustomTimePicker.tsx`
`src/components/DownloadOptionsMenu/DownloadOptionsMenu.tsx`
`src/components/ErrorPopover/ErrorPopover.tsx`
`src/components/ExplorerCard/ExplorerCard.tsx`
`src/components/HeaderRightSection/HeaderRightSection.tsx`
`src/components/Logs/AddToQueryHOC.tsx`
`src/components/LogsFormatOptionsMenu/LogsFormatOptionsMenu.tsx`
`src/components/QueryBuilderV2/QueryV2/QueryAggregation/QueryAggregationSelect.tsx`
`src/components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch.tsx`
`src/components/QueryBuilderV2/QueryV2/TraceOperator/TraceOperatorEditor.tsx`
`src/components/TableRenderer/LabelColumn.tsx`
`src/components/WarningPopover/WarningPopover.tsx`
`src/container/AlertHistory/AlertPopover/AlertPopover.tsx`
`src/container/CreateAlertV2/EvaluationSettings/EvaluationSettings.tsx`
`src/container/Home/Home.tsx`
`src/container/MetricsExplorer/MetricDetails/AllAttributes.tsx`
`src/container/MetricsExplorer/MetricDetails/AllAttributesValue.tsx`
`src/container/MetricsExplorer/Summary/MetricNameSearch.tsx`
`src/container/OptionsMenu/index.tsx`
`src/container/TopNav/AutoRefreshV2/index.tsx`
`src/pages/DashboardsListPage/components/ListHeader/ListHeader.tsx`
`src/pages/MetricsApplication/ApDex/ApDexApplication.tsx`
`src/pages/PublicDashboard/PublicDashboardView/PublicAutoRefresh/PublicAutoRefresh.tsx`
`src/pages/TraceDetailsV3/SpanDetailsPanel/Events/components/AttributeWithExpandablePopover.tsx`
`src/pages/TracesFunnelDetails/components/FunnelConfiguration/FunnelStepPopover.tsx`
`src/pages/TracesFunnels/components/FunnelsList/FunnelItemPopover.tsx`
`src/pages/TracesFunnels/components/SearchBar/SearchBar.tsx`
`src/periscope/components/ContextMenu/index.tsx`
`src/periscope/components/SeeMore/SeeMore.tsx`
### Existing `@signozhq/ui` popovers
`src/components/ExportMenu/ExportMenu.tsx`
`src/components/MarkdownEditor/MarkdownHelp.tsx`
`src/components/TanStackTableView/TanStackHeaderRow.tsx`
`src/container/AIAssistant/components/ChatInput/ChatInput.tsx`
`src/container/ListAlertRules/components/ColumnSelector.tsx`
`src/pages/DashboardPage/DashboardContainer/PanelEditor/ConfigPane/sections/ContextLinksSection/VariablesPopover.tsx`
`src/pages/DashboardsListPage/components/ViewsRail/ViewNamePopover.tsx`
### SCSS that overrides `.ant-popover`
`src/components/DownloadOptionsMenu/DownloadOptionsMenu.styles.scss`
`src/components/LogsFormatOptionsMenu/LogsFormatOptionsMenu.styles.scss`
`src/components/QueryBuilderV2/QueryV2/QueryAggregation/QueryAggregation.styles.scss`
`src/components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch.styles.scss`
`src/container/AlertHistory/AlertPopover/AlertPopover.styles.scss`
`src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.styles.scss`
`src/container/CreateAlertV2/EvaluationSettings/styles.scss`
`src/container/Home/Home.styles.scss`
`src/container/MetricsExplorer/MetricDetails/MetricDetails.styles.scss`
`src/container/MetricsExplorer/Summary/Summary.styles.scss`
`src/container/TopNav/AutoRefreshV2/AutoRefreshV2.styles.scss`
`src/container/TopNav/DateTimeSelectionV2/DateTimeSelectionV2.styles.scss`
`src/container/WidgetCard/WidgetCard.styles.scss`
`src/pages/DashboardsListPage/components/ListHeader/ListHeader.module.scss`
`src/pages/TracesFunnels/TracesFunnels.styles.scss`
`src/pages/TracesFunnels/components/FunnelsList/FunnelsList.styles.scss`
`src/pages/TracesFunnels/components/SearchBar/SearchBar.styles.scss`
`src/periscope/components/ContextMenu/styles.scss`
Three of these are unscoped and restyle every antd popover on the page, not just their own:
- `src/container/MetricsExplorer/Summary/Summary.styles.scss`
- `src/container/CreateAlertV2/EvaluationSettings/styles.scss`
- `src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.styles.scss`
Scope or delete those three before moving any nearby popover, or a migrated popup and a leftover antd popup will share rules that only one of them understands.
Most of the other files repeat one recipe: padding 0, 4px radius, a 1px border, a translucent background, the arrow hidden. The new shell already has zero padding, `--radius-2`, and no arrow. Per-site size (fixed widths around 187–500px, max-width 600px, max-height 254px) becomes `width` / `maxWidth` / `maxHeight`. Do not re-apply the recipe as a `className`; the component rejects it.
## What should not become this Popover
**Hover descriptions.** `AddToQueryHOC`, `LabelColumn`, the three Query Builder validation popovers, `SeeMore`, and `AttributeWithExpandablePopover` open on hover. Those are Tooltips. `AttributeWithExpandablePopover` shows a value the user copies; if Tooltip cannot hold that, make it a click popover instead of keeping hover.
**WarningPopover** (imported from about 10 files) is hover today and contains docs links. A tooltip cannot host those links. Either the links move out and the summary becomes a Tooltip, or the wrapper becomes a click Popover with `maxWidth` around 600.
**Menus.** Funnel item and funnel step action lists, the sort lists in `ListHeader` and Traces Funnels `SearchBar`, `AutoRefreshV2` / `PublicAutoRefresh` interval lists, and `AlertPopover`'s two links are rows that close when one is picked. Those are Dropdowns. `AllAttributes` per-row actions are the same shape.
**ContextMenu** (`src/periscope/components/ContextMenu/index.tsx`, imported across the dashboard drilldown) opens at chart-click coordinates. There is no trigger element. The new Popover requires `trigger`, so this stays a context menu. Do not fold the phantom span, `position: fixed` overlay, `zIndex: 10000`, or `calculatePopoverPosition.ts` into `anchor`. `anchor` is for a popover that already has a trigger and should position against something else (the typeahead input row).
**Inspect charts.** `MetricsExplorer/Inspect` `GraphPopover` and `HoverPopover` are absolutely positioned cards, not antd Popover. Leave them until there is a context-menu or anchor design that matches them.
**PanelStatusPopover** is a Tooltip despite the name.
## What should become this Popover
Click panels whose content is a form, a picker, or a composed layout:
- `CustomTimePicker` — the most visible one. Controlled `open`, `container` for `getPopupContainer`, and it already unmounts on hide. It opens from both input focus and click; that logic stays in the caller, with `onOpenChange`.
- `EvaluationSettings` / `EvaluationWindowPopover` — controlled, wide (`width` / `maxWidth`), and sitting on a global `.ant-popover` override. Migrate the override in the same change.
- `HeaderRightSection` — three controlled forms (feedback, announcements, share).
- `ExplorerCard` save-view form and `ViewNamePopover` — close only after the save succeeds, so `open` stays controlled. `ViewNamePopover` is already on the library and will not compile against the new props.
- `ApDex` threshold form.
- `Home` welcome checklist — drop the transparent-shell override; the shell is the component's.
- `OptionsMenu`, `DownloadOptionsMenu`, `LogsFormatOptionsMenu` — click panels. `LogsFormatOptionsMenu` nests a submenu; the inner popup must be portalled, and the outer `onOpenChange` must `cancel()` an outside press that lands in the inner popup.
- `MetricNameSearch` — controlled search panel. Scope the global padding override in `Summary.styles.scss` in the same change.
- `AllAttributesValue` — one popover open at a time in a list. That is controlled `open` keyed by row, which the API still allows.
- `ChatInput` context picker — already on the library; `side="top"` `align="end"` plus `width`.
- `VariablesPopover` — already on the library. Keep `anchor` on the input row, `initialFocus={false}` so the caret stays, and `details.cancel()` when the outside press is the anchor. `withPortal={false}` becomes the default dialog `container`, not an unportalled popup.
- `ColumnSelector`, `MarkdownHelp`, `TanStackHeaderRow`, `ExportMenu` — Radix `asChild` trigger plus content. The trigger moves to the `trigger` prop, content moves to `children`, and `className` on the shell becomes `width` / `maxWidth`.
`ErrorPopover` is a thin wrapper. Re-point it at the new props and its consumer follows.
## Order
Before any change: screenshot every current popover, in both themes, so each call site's pattern is on record and a component swap is reviewable against it. Flag the misusages to design as they turn up — links inside hover surfaces above all. Swapping the component during migration is expected where the old call site was on the wrong one; antd call sites stay untouched until their turn.
1. Scope the three global `.ant-popover` files so later migrations cannot restyle leftover antd popovers.
2. Move the seven existing `@signozhq/ui` call sites first. They break at the package bump; the 29 antd files do not, until each one is switched.
3. Migrate shared wrappers that are actually popovers (`ErrorPopover`, then `WarningPopover` once the links decision is made). Leave `ContextMenu` on antd until a context menu exists.
4. Migrate click forms (`CustomTimePicker`, evaluation window, header menus, save-view, ApDex).
5. Send hover descriptions to Tooltip and row menus to Dropdown, instead of forcing them through Popover.
6. Delete the matching `.ant-popover` rules in the same change as each call site, so a half-migrated page is not styled twice.
## Risks
- **Controlled `open` is load-bearing.** Save forms close from inside the popup only when the mutation succeeds. A list keeps one popover open at a time. Dropping `open` and driving everything from `onOpenChange` alone cannot express either. Keep the controlled prop at those sites.
- **Focus moves in.** Click popovers focus the first tabbable child. Typeaheads (`VariablesPopover`, and any filter that must keep the caret) need `initialFocus={false}`.
- **Outside press and anchors.** A press on the anchor looks outside. Call `details.cancel()` or the typeahead closes when the user clicks the input it is anchored to.
- **Dialogs.** Inside a Dialog or Drawer the popup portals into the panel so the focus trap can reach it. Do not set `container={document.body}` there.
- **Z-index.** The shell starts at `--popover-z-index` (50) and lifts above the layer its trigger sits in. A Tooltip inside a row must still paint above the popup. The app ladder today is tooltip 1070, dropdown 1050, popover 1030, modal 1000. Check a truncated row tooltip inside a popover after the first real migration.
- **Scroll locking.** Chart popovers measure the wrong box when a modal has locked scroll. This popover does not lock scroll (`modal` is rejected). Re-check Infrastructure Monitoring charts if a surrounding modal is open.
- **`nativeButton` mismatches.** Base UI logs an error when the flag does not match the element. Every non-button trigger (a badge, a custom row, an input) needs `nativeButton={false}`.
## `--popover-foreground` audit
The shell reads `--popover-background`, `--popover-foreground`, `--popover-border`, and `--popover-shadow`. Those semantic values belong to the token package, not to call sites.
Two legacy shadcn variables predate those hooks:
- `--popover-foreground` is the foreground hook the shell uses. It should stay pointed at the same foreground as the rest of the light and dark themes (`--l1-foreground` today).
- `--popover` (no suffix) is the old background. `--calendar-select-background` still reads it. Do not repoint `--popover` at `--popover-background` or at `surface-3` until the calendar token is audited, or date selection changes color with the popover shell.
Call sites that pass antd's `color` (a gradient string on `AlertPopover`, a raw token on `SeeMore`) do not map to a prop. The shell color comes from the tokens. Content that needs its own background paints it inside `children`.

View File

@@ -41,6 +41,20 @@ if (!HTMLElement.prototype.releasePointerCapture) {
HTMLElement.prototype.releasePointerCapture = function (): void {};
}
// jsdom has no PointerEvent; Base UI Switch constructs one on click.
if (typeof window.PointerEvent === 'undefined') {
class PointerEventMock extends MouseEvent {
pointerId: number;
pointerType: string;
constructor(type: string, init: PointerEventInit = {}) {
super(type, init);
this.pointerId = init.pointerId ?? 0;
this.pointerType = init.pointerType ?? '';
}
}
(window as any).PointerEvent = PointerEventMock;
}
if (typeof window.IntersectionObserver === 'undefined') {
class IntersectionObserverMock {
observe(): void {}

View File

@@ -52,9 +52,9 @@
"@monaco-editor/react": "^4.7.0",
"@sentry/react": "10.57.0",
"@sentry/vite-plugin": "5.3.0",
"@signozhq/design-tokens": "2.1.6",
"@signozhq/design-tokens": "2.3.0",
"@signozhq/icons": "0.4.0",
"@signozhq/ui": "0.1.0",
"@signozhq/ui": "0.3.0",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.22",
"@uiw/codemirror-theme-copilot": "4.23.11",

862
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -41,17 +41,16 @@ export function ErrorResponseHandler(error: AxiosError): ErrorResponse {
message: data?.error,
};
}
// Avoid logging error when the request was just cancelled for whatever reason
if (isCancel(error)) {
return {
statusCode: 500,
payload: null,
error: 'Something went wrong',
message: null,
};
}
if (request) {
// Avoid logging error when the request was just cancelled for whatever reason
if (isCancel(error)) {
return {
statusCode: 500,
payload: null,
error: 'Something went wrong',
message: null,
};
}
// client never received a response, or request never left
console.error('client never received a response, or request never left');

View File

@@ -0,0 +1,22 @@
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError, CanceledError } from 'axios';
describe('ErrorResponseHandler', () => {
// axios aborts a request whose signal fired before it was sent without ever
// creating one, so the error carries neither `response` nor `request`.
it('treats a request cancelled before it was sent as a cancellation', () => {
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
expect(ErrorResponseHandler(new CanceledError() as AxiosError)).toEqual({
statusCode: 500,
payload: null,
error: 'Something went wrong',
message: null,
});
expect(consoleError).not.toHaveBeenCalled();
consoleError.mockRestore();
});
});

View File

@@ -1,9 +0,0 @@
import axios from 'api';
import { SemconvMigrationReport } from 'types/api/semconvMigration';
async function getSemconvMigrationReport(): Promise<SemconvMigrationReport> {
const response = await axios.get('/fields/semconv-migration');
return response.data.data;
}
export default getSemconvMigrationReport;

View File

@@ -64,12 +64,12 @@ export const COMMON_FILTERS = {
SERVER_SPANS: "kind_string = 'Server'",
CLIENT_SPANS: "kind_string = 'Client'",
INTERNAL_SPANS: "kind_string = 'Internal'",
ERROR_SPANS: 'http.response.status_code >= 400',
SUCCESS_SPANS: 'http.response.status_code < 400',
ERROR_SPANS: 'http.status_code >= 400',
SUCCESS_SPANS: 'http.status_code < 400',
// Common service filters
EXCLUDE_HEALTH_CHECKS: "http.route != '/health' AND http.route != '/ping'",
HTTP_REQUESTS: "http.request.method != ''",
HTTP_REQUESTS: "http.method != ''",
// Log filters
ERROR_LOGS: "severity_text = 'ERROR'",
@@ -87,7 +87,7 @@ export const COMMON_GROUP_BY_FIELDS = {
fieldContext: 'resource' as const,
},
HTTP_METHOD: {
name: 'http.request.method',
name: 'http.method',
fieldDataType: 'string' as const,
fieldContext: 'attribute' as const,
},
@@ -97,7 +97,7 @@ export const COMMON_GROUP_BY_FIELDS = {
fieldContext: 'attribute' as const,
},
HTTP_STATUS_CODE: {
name: 'http.response.status_code',
name: 'http.status_code',
fieldDataType: 'int64' as const,
fieldContext: 'attribute' as const,
},

View File

@@ -17,7 +17,10 @@ function ModalFixture(): JSX.Element {
return (
<>
<Button
data-testid="open-add-credit-card"
variant="solid"
color="primary"
size="md"
testId="open-add-credit-card"
onClick={(): void => setOpen(true)}
>
Contact support

View File

@@ -1,6 +1,9 @@
.breadcrumb {
padding-left: 16px;
// The antd breadcrumb provides the item spacing.
--button-padding: 0;
ol {
align-items: center;
}
@@ -9,8 +12,3 @@
color: var(--muted-foreground);
}
}
.divider {
--divider-color: var(--l1-border);
--divider-margin: 10px 0 16px 0;
}

View File

@@ -28,7 +28,7 @@ function AlertBreadcrumb({
items={breadcrumbItems}
data-testid={testId}
/>
{showDivider && <Divider className={styles.divider} />}
{showDivider && <Divider spacing="10px 16px" />}
</>
);
}

View File

@@ -1,9 +1,5 @@
.item {
--button-padding: 0;
--button-font-size: var(--periscope-font-size-base);
}
.itemLast {
color: var(--muted-foreground);
font-size: var(--periscope-font-size-base);
color: var(--button-secondary-label);
font-size: var(--button-font-size, var(--periscope-font-size-base));
line-height: var(--button-size-line-height, 16px);
}

View File

@@ -26,9 +26,9 @@ function BreadcrumbItem({
return (
<Button
variant="ghost"
size="md"
variant="link"
color="secondary"
className={styles.item}
onClick={(e: React.MouseEvent): void => {
if (!('route' in props) || !props.route) {
return;

View File

@@ -34,21 +34,23 @@ function ErrorEmptyState({
</div>
<div className={styles.actions}>
<Button
size="md"
variant="solid"
color="secondary"
prefix={<LifeBuoy size={14} />}
onClick={onContactSupport}
data-testid="error-contact-support-button"
testId="error-contact-support-button"
>
Contact Support
</Button>
{onRefresh && (
<Button
size="md"
variant="outlined"
color="secondary"
prefix={<RefreshCw size={14} />}
onClick={onRefresh}
data-testid="error-refresh-button"
testId="error-refresh-button"
>
Refresh
</Button>

View File

@@ -1,20 +1,32 @@
.labelColumn {
// Badges fill their wrapper; the wrapper, as the flex item, owns the cap.
--badge-max-width: 100%;
position: relative;
display: flex;
gap: 4px;
align-items: center;
overflow: hidden;
max-width: 100%;
width: 100%;
> * {
flex-shrink: 0;
max-width: 180px;
}
}
.labelBadge {
cursor: default;
font-size: 12px;
// Kept mounted so its width can still be measured.
.overflowed {
position: absolute;
visibility: hidden;
pointer-events: none;
}
--badge-display: inline;
max-width: 180px;
text-overflow: ellipsis;
// The only visible label truncates instead of pushing `+N` out of view.
.labelColumn > .shrinkable {
flex-shrink: 1;
min-width: 0;
}
.overflowTrigger {
@@ -22,11 +34,6 @@
cursor: pointer;
}
.overflowBadge {
cursor: pointer;
font-size: 12px;
}
.labelPopover {
display: flex;
flex-direction: column;

View File

@@ -1,13 +1,14 @@
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { act, render, screen } from '@testing-library/react';
import LabelColumn from './LabelColumn';
let resizeCallback: ResizeObserverCallback | null = null;
const BADGE_WIDTH = 60;
let resizeCallbacks: ResizeObserverCallback[] = [];
class MockResizeObserver {
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback;
resizeCallbacks.push(callback);
}
observe = jest.fn();
@@ -15,15 +16,23 @@ class MockResizeObserver {
disconnect = jest.fn();
}
function triggerResize(width: number): void {
if (resizeCallback) {
act(() => {
resizeCallback?.(
[{ contentRect: { width } } as ResizeObserverEntry],
{} as ResizeObserver,
);
// jsdom has no layout: every badge measures BADGE_WIDTH, the column `containerWidth`.
function mockLayout(containerWidth: number): void {
jest
.spyOn(HTMLElement.prototype, 'clientWidth', 'get')
.mockImplementation(function getClientWidth(this: HTMLElement) {
return this.dataset.testid === 'label-column' ? containerWidth : 0;
});
}
jest
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockReturnValue({ width: BADGE_WIDTH } as DOMRect);
}
function triggerResize(containerWidth: number): void {
mockLayout(containerWidth);
act(() => {
resizeCallbacks.forEach((callback) => callback([], {} as ResizeObserver));
});
}
beforeAll(() => {
@@ -31,13 +40,14 @@ beforeAll(() => {
});
afterEach(() => {
resizeCallback = null;
resizeCallbacks = [];
jest.restoreAllMocks();
});
function renderWithProviders(
ui: React.ReactElement,
): ReturnType<typeof render> {
return render(<TooltipProvider>{ui}</TooltipProvider>);
return render(ui);
}
describe('LabelColumn', () => {
@@ -54,13 +64,10 @@ describe('LabelColumn', () => {
it('should truncate labels and show +N badge when container is narrow', () => {
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
// 3 badges (180px) + overflow reserve (40px)
mockLayout(220);
renderWithProviders(<LabelColumn labels={labels} />);
// Simulate narrow container that fits ~3 badges
// Badge widths: env=37, service=65, region=58, team=44, owner=51, version=65
// 220px available = 3 badges (160px) + gaps (8px) + overflow (44px)
triggerResize(220);
// First 3 visible
expect(screen.getByTestId('label-tag-env')).toBeInTheDocument();
expect(screen.getByTestId('label-tag-service')).toBeInTheDocument();
@@ -93,19 +100,37 @@ describe('LabelColumn', () => {
expect(screen.getByTestId('label-tag-service')).toHaveTextContent('service');
});
it('should show overflow badge with remaining count when container is narrow', () => {
it('should update the overflow count when the container resizes', () => {
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
mockLayout(220);
renderWithProviders(<LabelColumn labels={labels} />);
expect(screen.getByTestId('label-overflow-badge')).toHaveTextContent('+3');
// 2 badges (120px) + overflow reserve (40px)
triggerResize(160);
expect(screen.getByTestId('label-overflow-badge')).toHaveTextContent('+4');
triggerResize(1000);
expect(screen.queryByTestId('label-overflow-badge')).not.toBeInTheDocument();
});
it('should skip the overflow reserve when every label fits', () => {
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
mockLayout(labels.length * BADGE_WIDTH);
renderWithProviders(<LabelColumn labels={labels} />);
// Simulate narrow container to trigger overflow (shows 3 labels)
// 220px fits first 3 badges before overflow
triggerResize(220);
expect(screen.queryByTestId('label-overflow-badge')).not.toBeInTheDocument();
});
// Overflow badge shows +3 (remaining labels)
const overflowBadge = screen.getByTestId('label-overflow-badge');
expect(overflowBadge).toBeInTheDocument();
expect(overflowBadge).toHaveTextContent('+3');
it('should keep one label visible when even that one does not fit', () => {
const labels = ['env', 'service'];
mockLayout(50);
renderWithProviders(<LabelColumn labels={labels} />);
expect(screen.getByTestId('label-overflow-badge')).toHaveTextContent('+1');
});
it('should render empty when no labels provided', () => {
@@ -126,11 +151,9 @@ describe('LabelColumn', () => {
it('should show all labels when container is wide enough', () => {
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
mockLayout(1000);
renderWithProviders(<LabelColumn labels={labels} />);
// Simulate wide container
triggerResize(1000);
// All labels visible
labels.forEach((label) => {
expect(screen.getByTestId(`label-tag-${label}`)).toBeInTheDocument();

View File

@@ -1,35 +1,19 @@
import { Copy } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { toast } from '@signozhq/ui/sonner';
import {
TooltipContent,
TooltipRoot,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Badge, type BadgeColorType } from '@signozhq/ui/badge';
import { toast } from '@signozhq/ui/toast';
import { Tooltip } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import { useLayoutEffect, useRef, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import LabelTag from './LabelTag';
import styles from './LabelColumn.module.scss';
import { BADGE_GAP, estimateBadgeWidth, OVERFLOW_BADGE_WIDTH } from './utils';
import { getVisibleCount } from './utils';
export interface LabelColumnProps {
labels: string[];
color?:
| 'primary'
| 'secondary'
| 'success'
| 'error'
| 'warning'
| 'robin'
| 'forest'
| 'amber'
| 'sienna'
| 'cherry'
| 'sakura'
| 'aqua'
| 'vanilla';
color?: BadgeColorType;
value?: { [key: string]: string };
}
@@ -39,60 +23,37 @@ function LabelColumn({
color = 'primary',
}: LabelColumnProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null);
const [maxVisibleCount, setMaxVisibleCount] = useState(labels.length);
const [visibleCount, setVisibleCount] = useState(labels.length);
const [, copyToClipboard] = useCopyToClipboard();
const calculateMaxVisible = useCallback(
(width: number): number => {
if (width <= 0) {
return 1;
}
const availableWidth = width - OVERFLOW_BADGE_WIDTH - BADGE_GAP;
let usedWidth = 0;
let count = 0;
for (const label of labels) {
const badgeWidth = estimateBadgeWidth(label, value?.[label]) + BADGE_GAP;
if (usedWidth + badgeWidth > availableWidth && count > 0) {
break;
}
usedWidth += badgeWidth;
count++;
}
return Math.max(1, count);
},
[labels, value],
);
useEffect(() => {
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry && entry.contentRect.width > 0) {
setMaxVisibleCount(calculateMaxVisible(entry.contentRect.width));
const measure = (): void => {
const available = container.clientWidth;
if (available <= 0) {
return;
}
});
const widths = Array.from(container.children)
.slice(0, labels.length)
.map((item) => item.getBoundingClientRect().width);
const gap = parseFloat(getComputedStyle(container).columnGap) || 0;
setVisibleCount(getVisibleCount(widths, available, gap));
};
const observer = new ResizeObserver(measure);
observer.observe(container);
if (container.clientWidth > 0) {
setMaxVisibleCount(calculateMaxVisible(container.clientWidth));
}
measure();
return (): void => observer.disconnect();
}, [calculateMaxVisible]);
}, [labels, value]);
const needsOverflow = labels.length > maxVisibleCount;
const visibleLabels = needsOverflow
? labels.slice(0, maxVisibleCount)
: labels;
const remainingLabels = needsOverflow ? labels.slice(maxVisibleCount) : [];
const remainingLabels = labels.slice(visibleCount);
return (
<div
@@ -100,24 +61,23 @@ function LabelColumn({
className={styles.labelColumn}
data-testid="label-column"
>
{visibleLabels.map((label) => (
<LabelTag key={label} label={label} color={color} value={value?.[label]} />
{labels.map((label, index) => (
<LabelTag
key={label}
label={label}
color={color}
value={value?.[label]}
className={cx({
[styles.overflowed]: index >= visibleCount,
[styles.shrinkable]: index === 0 && visibleCount === 1,
})}
/>
))}
{remainingLabels.length > 0 && (
<TooltipRoot>
<TooltipTrigger asChild>
<span>
<Badge
color={color}
className={styles.overflowBadge}
variant="outline"
data-testid="label-overflow-badge"
>
+{remainingLabels.length}
</Badge>
</span>
</TooltipTrigger>
<TooltipContent side="bottom" align="end">
<Tooltip
side="bottom"
align="end"
title={
<div className={styles.tooltipContent}>
<span>
{remainingLabels
@@ -140,8 +100,14 @@ function LabelColumn({
<Copy size={12} />
</button>
</div>
</TooltipContent>
</TooltipRoot>
}
>
<span>
<Badge color={color} variant="outlined" testId="label-overflow-badge">
+{remainingLabels.length}
</Badge>
</span>
</Tooltip>
)}
</div>
);

View File

@@ -1,11 +1,3 @@
.labelBadge {
cursor: default;
font-size: 12px;
max-width: 180px;
text-overflow: ellipsis;
}
.labelValue {
text-overflow: ellipsis;
overflow: hidden;

View File

@@ -1,35 +1,24 @@
import { Copy } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { toast } from '@signozhq/ui/sonner';
import {
TooltipContent,
TooltipRoot,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { Badge, type BadgeColorType } from '@signozhq/ui/badge';
import { toast } from '@signozhq/ui/toast';
import { Tooltip } from '@signozhq/ui/tooltip';
import { useCopyToClipboard } from 'react-use';
import styles from './LabelTag.module.scss';
export interface LabelTagProps {
label: string;
color?:
| 'primary'
| 'secondary'
| 'success'
| 'error'
| 'warning'
| 'robin'
| 'forest'
| 'amber'
| 'sienna'
| 'cherry'
| 'sakura'
| 'aqua'
| 'vanilla';
color?: BadgeColorType;
value?: string;
className?: string;
}
function LabelTag({ label, value, color }: LabelTagProps): JSX.Element {
function LabelTag({
label,
value,
color,
className,
}: LabelTagProps): JSX.Element {
const [, copyToClipboard] = useCopyToClipboard();
const displayText = value ? `${label}: ${value}` : label;
const searchFormat = value ? `${label} ${value}` : label;
@@ -41,20 +30,8 @@ function LabelTag({ label, value, color }: LabelTagProps): JSX.Element {
};
return (
<TooltipRoot>
<TooltipTrigger asChild>
<span>
<Badge
color={color}
className={styles.labelBadge}
variant="outline"
data-testid={`label-tag-${label}`}
>
<span className={styles.labelValue}>{displayText}</span>
</Badge>
</span>
</TooltipTrigger>
<TooltipContent>
<Tooltip
title={
<div className={styles.tooltipContent}>
<span>{displayText}</span>
<button
@@ -66,8 +43,19 @@ function LabelTag({ label, value, color }: LabelTagProps): JSX.Element {
<Copy size={12} />
</button>
</div>
</TooltipContent>
</TooltipRoot>
}
>
<span className={className}>
<Badge
textTransform="none"
color={color ?? 'secondary'}
variant="outlined"
testId={`label-tag-${label}`}
>
<span className={styles.labelValue}>{displayText}</span>
</Badge>
</span>
</Tooltip>
);
}

View File

@@ -1,14 +1,33 @@
export const BADGE_GAP = 4;
export const OVERFLOW_BADGE_WIDTH = 40;
export const BADGE_MAX_WIDTH = 180;
export const BADGE_PADDING = 16;
export const CHAR_WIDTH = 7;
export function estimateBadgeWidth(label: string, value?: string): number {
const displayText = value ? `${label}: ${value}` : label;
return Math.min(
displayText.length * CHAR_WIDTH + BADGE_PADDING,
BADGE_MAX_WIDTH,
/**
* How many leading labels fit in `available` px, reserving room for the `+N` badge
* whenever some are left out. At least one label is always shown.
*/
export function getVisibleCount(
widths: number[],
available: number,
gap: number,
): number {
const total = widths.reduce(
(sum, width, index) => sum + width + (index > 0 ? gap : 0),
0,
);
if (total <= available) {
return widths.length;
}
const limit = available - OVERFLOW_BADGE_WIDTH - gap;
let used = 0;
let count = 0;
for (const width of widths) {
used += width + (count > 0 ? gap : 0);
if (used > limit) {
break;
}
count++;
}
return Math.max(1, count);
}

View File

@@ -30,21 +30,23 @@ function NoResultsEmptyState({
<div className={styles.actions}>
{onClear && (
<Button
size="md"
variant="outlined"
color="secondary"
onClick={onClear}
data-testid="no-results-clear-button"
testId="no-results-clear-button"
>
{clearButtonText}
</Button>
)}
{onRefresh && (
<Button
size="md"
variant="outlined"
color="secondary"
prefix={<RefreshCw size={14} />}
onClick={onRefresh}
data-testid="no-results-refresh-button"
testId="no-results-refresh-button"
>
Refresh
</Button>

View File

@@ -1,4 +1,4 @@
import type { BadgeColor } from '@signozhq/ui/badge';
import type { BadgeColorType } from '@signozhq/ui/badge';
export const STATE_ORDER = ['firing', 'pending', 'inactive', 'disabled'];
export const SEVERITY_ORDER = ['critical', 'error', 'warning', 'info'];
@@ -24,9 +24,9 @@ export const SEVERITY_COLORS: Record<string, string> = {
info: 'var(--bg-robin-500)',
};
export const SEVERITY_BADGE_COLORS: Record<string, BadgeColor> = {
critical: 'error',
error: 'error',
export const SEVERITY_BADGE_COLORS: Record<string, BadgeColorType> = {
critical: 'danger',
error: 'danger',
warning: 'warning',
info: 'primary',
};

View File

@@ -33,36 +33,3 @@
color: var(--l1-foreground);
white-space: nowrap;
}
.auth-header-help-button {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 32px;
padding: 10px 16px;
background: var(--l2-background);
color: var(--l2-foreground);
border: none;
border-radius: 2px;
cursor: pointer;
transition: opacity 0.2s ease;
span {
font-family: var(--font-family-inter, Inter, sans-serif);
font-size: 11px;
font-weight: 500;
line-height: 1;
color: var(--l2-foreground);
text-align: center;
}
svg {
flex-shrink: 0;
color: var(--l2-foreground);
}
&:hover {
opacity: 0.8;
}
}

View File

@@ -22,11 +22,11 @@ function AuthHeader(): JSX.Element {
<span className="auth-header-logo-text">SigNoz</span>
</div>
<Button
className="auth-header-help-button"
size="md"
prefix={<LifeBuoy size={12} />}
onClick={handleGetHelp}
variant="solid"
color="none"
color="secondary"
>
Get Help
</Button>

View File

@@ -1,7 +1,7 @@
import React, { Dispatch, SetStateAction, useState } from 'react';
import { Check, Plus, X } from '@signozhq/icons';
import { Button, Flex } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Pill } from '@signozhq/ui/pill';
import Input from 'components/Input';
import './Badges.styles.scss';
@@ -47,18 +47,13 @@ function Badges({ tags, setTags }: AddTagsProps): JSX.Element {
return (
<div className="tags-container">
{tags.map<React.ReactNode>((tag) => (
<Badge
<Pill.Closeable
textTransform="none"
key={tag}
color="vanilla"
style={{ userSelect: 'none' }}
closable
onClose={(e): void => {
e.preventDefault();
handleClose(tag);
}}
onClose={() => handleClose(tag)}
>
{tag}
</Badge>
</Pill.Closeable>
))}
{inputVisible && (

View File

@@ -145,7 +145,7 @@ function CeleryOverviewConfigOptions(): JSX.Element {
{
placeholder: 'Destination',
queryParam: QueryParams.destination,
filterType: ['messaging.destination.name'],
filterType: ['messaging.destination.name', 'messaging.destination'],
},
{
placeholder: 'Kind',

View File

@@ -56,22 +56,15 @@ function ProgressRender(item: string | number): JSX.Element {
const percent = Number(Number(item).toFixed(1));
return (
<div className="progress-container">
<Progress
percent={percent}
strokeLinecap="butt"
showInfo
strokeColor={((): string => {
const cpuPercent = percent;
if (cpuPercent >= 90) {
return Color.BG_SAKURA_500;
<div className="progress-bar">
<Progress
percent={percent}
showInfo
color={
percent >= 90 ? 'highlight-danger' : percent >= 60 ? 'warning' : 'success'
}
if (cpuPercent >= 60) {
return Color.BG_AMBER_500;
}
return Color.BG_FOREST_500;
})()}
className="progress-bar"
/>
/>
</div>
</div>
);
}

View File

@@ -72,7 +72,7 @@ export default function CeleryTaskDetail({
endTime ? `- ${formatTimestamp(endTime)}` : ''
}`}
</Typography.Text>
<Divider type="vertical" />
<Divider orientation="vertical" />
<Typography.Text className="subtitle">{taskData.value}</Typography.Text>
</div>
</div>

View File

@@ -39,9 +39,3 @@
}
}
}
.cloud-service-data-collected-table-tooltip {
max-width: 280px;
white-space: normal;
word-break: break-word;
}

View File

@@ -4,7 +4,7 @@ import {
CloudintegrationtypesCollectedMetricDTO,
} from 'api/generated/services/sigNoz.schemas';
import { BarChart, Info, ScrollText } from '@signozhq/icons';
import { TooltipProvider, TooltipSimple } from '@signozhq/ui/tooltip';
import { Tooltip } from '@signozhq/ui/tooltip';
import './CloudServiceDataCollected.styles.scss';
@@ -88,23 +88,15 @@ function CloudServiceDataCollected({
<BarChart size={14} />
Metrics
{metricsInfoTooltip && (
<TooltipProvider>
<TooltipSimple
title={metricsInfoTooltip}
side="top"
tooltipContentProps={{
className: 'cloud-service-data-collected-table-tooltip',
}}
<Tooltip title={metricsInfoTooltip} side="top">
<span
className="cloud-service-data-collected-table-heading-info"
aria-label="About the metrics listed below"
data-testid="data-collected-metrics-info"
>
<span
className="cloud-service-data-collected-table-heading-info"
aria-label="About the metrics listed below"
data-testid="data-collected-metrics-info"
>
<Info size={12} />
</span>
</TooltipSimple>
</TooltipProvider>
<Info size={12} />
</span>
</Tooltip>
)}
</div>
<Table

View File

@@ -2,6 +2,13 @@
position: relative;
}
.copyButton {
position: absolute;
right: 8px;
top: 8px;
z-index: 1;
}
.codeBlockSyntaxHighlighter {
background-color: var(--l2-background) !important;
border-radius: 4px !important;

View File

@@ -2,6 +2,7 @@ import { useMemo, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { Check, Copy } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import SyntaxHighlighter, {
a11yDark,
} from 'components/MarkdownRenderer/syntaxHighlighter';
@@ -52,16 +53,20 @@ function CodeBlock({
data-testid="code-block-container"
>
{showCopyButton ? (
<Button
variant="ghost"
color="secondary"
size="sm"
onClick={handleCopy}
prefix={isCopied ? <Check size={14} /> : <Copy size={14} />}
aria-label="Copy code"
title={isCopied ? 'Copied' : 'Copy'}
style={{ position: 'absolute', right: 8, top: 8, zIndex: 1 }}
/>
<div className={styles.copyButton}>
<Tooltip title={isCopied ? 'Copied' : 'Copy'}>
<Button
variant="ghost"
color="secondary"
size="sm"
icon
onClick={handleCopy}
aria-label="Copy code"
>
{isCopied ? <Check size={14} /> : <Copy size={14} />}
</Button>
</Tooltip>
</div>
) : null}
<SyntaxHighlighter
style={a11yDark}

View File

@@ -1,36 +1,3 @@
.create-sa-modal {
max-width: 530px;
background: var(--popover);
border: 1px solid var(--secondary);
border-radius: 4px;
box-shadow: 0 4px 9px 0 rgba(0, 0, 0, 0.04);
[data-slot='dialog-header'] {
padding: var(--padding-4);
border-bottom: 1px solid var(--secondary);
flex-shrink: 0;
background: transparent;
margin: 0;
}
[data-slot='dialog-title'] {
font-size: var(--label-base-400-font-size);
font-weight: var(--label-base-400-font-weight);
line-height: var(--label-base-400-line-height);
letter-spacing: -0.065px;
color: var(--l1-foreground);
margin: 0;
}
[data-slot='dialog-description'] {
padding: 0;
.create-sa-modal__content {
padding: var(--padding-4);
}
}
}
.create-sa-form {
display: flex;
flex-direction: column;
@@ -50,25 +17,6 @@
}
}
&__input {
height: 32px;
color: var(--l1-foreground);
background-color: var(--l2-background);
border-color: var(--l1-border);
font-size: var(--paragraph-base-400-font-size);
border-radius: 2px;
width: 100%;
&::placeholder {
color: var(--l3-foreground);
}
&:focus {
border-color: var(--primary);
box-shadow: none;
}
}
&__error {
font-size: var(--paragraph-small-400-font-size);
color: var(--destructive);
@@ -83,16 +31,3 @@
line-height: var(--paragraph-small-400-line-height);
}
}
.create-sa-modal__footer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
padding: 0 var(--padding-4);
height: 56px;
min-height: 56px;
border-top: 1px solid var(--secondary);
gap: var(--spacing-4);
flex-shrink: 0;
}

View File

@@ -7,7 +7,7 @@ import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardCon
import { SACreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { DialogFooter, DialogWrapper } from '@signozhq/ui/dialog';
import { Input } from '@signozhq/ui/input';
import { toast } from '@signozhq/ui/sonner';
import { toast } from '@signozhq/ui/toast';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
invalidateListServiceAccounts,
@@ -116,11 +116,10 @@ function CreateServiceAccountModal(): JSX.Element {
<Input
id="sa-name"
placeholder="Enter a name"
className="create-sa-form__input"
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
data-testid="create-sa-name-input"
testId="create-sa-name-input"
/>
)}
/>
@@ -134,26 +133,27 @@ function CreateServiceAccountModal(): JSX.Element {
<DialogFooter className="create-sa-modal__footer">
<Button
size="md"
type="button"
variant="solid"
color="secondary"
onClick={handleClose}
data-testid="create-sa-cancel-btn"
testId="create-sa-cancel-btn"
prefix={<X size={12} />}
>
<X size={12} />
Cancel
</Button>
<AuthZButton
size="md"
checks={[SACreatePermission]}
withPortal={false}
type="submit"
form="create-sa-form"
variant="solid"
color="primary"
loading={isSubmitting}
disabled={!isValid}
data-testid="create-sa-submit-btn"
testId="create-sa-submit-btn"
>
Create Service Account
</AuthZButton>

View File

@@ -1,17 +1,18 @@
import { toast } from '@signozhq/ui/sonner';
import { toast } from '@signozhq/ui/toast';
import {
setupAuthzAdmin,
setupAuthzDenyAll,
} from 'lib/authz/utils/authz-test-utils';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { submitImplicitly } from 'tests/submitImplicitly';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import CreateServiceAccountModal from '../CreateServiceAccountModal';
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
jest.mock('@signozhq/ui/toast', () => ({
...jest.requireActual('@signozhq/ui/toast'),
toast: { success: jest.fn(), danger: jest.fn() },
}));
const mockToast = jest.mocked(toast);
@@ -57,10 +58,13 @@ describe('CreateServiceAccountModal', () => {
// The form only renders once the create check resolves, and the name field
// registers its `required` rule on mount, so the empty-form invalid state
// settles a tick later.
await screen.findByTestId('create-sa-name-input');
await screen.findByTestId('create-sa-name-input-field');
await waitFor(() =>
expect(screen.getByTestId('create-sa-submit-btn')).toBeDisabled(),
expect(screen.getByTestId('create-sa-submit-btn')).toHaveAttribute(
'aria-disabled',
'true',
),
);
});
@@ -68,25 +72,31 @@ describe('CreateServiceAccountModal', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
const nameInput = await screen.findByTestId('create-sa-name-input');
const nameInput = await screen.findByTestId('create-sa-name-input-field');
const submitBtn = await screen.findByTestId('create-sa-submit-btn');
await user.type(nameInput, 'test');
await waitFor(() => expect(submitBtn).not.toBeDisabled());
await waitFor(() =>
expect(submitBtn).not.toHaveAttribute('aria-disabled', 'true'),
);
await user.clear(nameInput);
await waitFor(() => expect(submitBtn).toBeDisabled());
await waitFor(() =>
expect(submitBtn).toHaveAttribute('aria-disabled', 'true'),
);
});
it('successful submit shows toast.success and closes modal', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
const nameInput = await screen.findByTestId('create-sa-name-input');
const nameInput = await screen.findByTestId('create-sa-name-input-field');
await user.type(nameInput, 'Deploy Bot');
const submitBtn = screen.getByTestId('create-sa-submit-btn');
await waitFor(() => expect(submitBtn).not.toBeDisabled());
await waitFor(() =>
expect(submitBtn).not.toHaveAttribute('aria-disabled', 'true'),
);
await user.click(submitBtn);
await waitFor(() => {
@@ -102,6 +112,40 @@ describe('CreateServiceAccountModal', () => {
});
});
it('pressing Enter again while creating sends one request', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let requests = 0;
server.use(
rest.post(SERVICE_ACCOUNTS_ENDPOINT, (_, res, ctx) => {
requests += 1;
return res(
ctx.delay(100),
ctx.status(201),
ctx.json({ status: 'success', data: {} }),
);
}),
);
renderModal();
const nameInput = await screen.findByTestId<HTMLInputElement>(
'create-sa-name-input-field',
);
await user.type(nameInput, 'Deploy Bot');
const submitBtn = screen.getByTestId('create-sa-submit-btn');
await waitFor(() =>
expect(submitBtn).not.toHaveAttribute('aria-disabled', 'true'),
);
submitImplicitly(nameInput);
await waitFor(() => expect(submitBtn).toHaveAttribute('aria-busy', 'true'));
submitImplicitly(nameInput);
await waitFor(() => {
expect(mockToast.success).toHaveBeenCalled();
});
expect(requests).toBe(1);
});
it('shows toast.error on API error and keeps modal open', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
@@ -116,11 +160,13 @@ describe('CreateServiceAccountModal', () => {
renderModal();
const nameInput = await screen.findByTestId('create-sa-name-input');
const nameInput = await screen.findByTestId('create-sa-name-input-field');
await user.type(nameInput, 'Dupe Bot');
const submitBtn = screen.getByTestId('create-sa-submit-btn');
await waitFor(() => expect(submitBtn).not.toBeDisabled());
await waitFor(() =>
expect(submitBtn).not.toHaveAttribute('aria-disabled', 'true'),
);
await user.click(submitBtn);
await waitFor(() => {
@@ -163,7 +209,9 @@ describe('CreateServiceAccountModal', () => {
screen.findByText(/is not authorized to perform/i),
).resolves.toBeInTheDocument();
expect(screen.queryByTestId('create-sa-name-input')).not.toBeInTheDocument();
expect(
screen.queryByTestId('create-sa-name-input-field'),
).not.toBeInTheDocument();
expect(
screen.getByTestId('create-service-account-modal'),
).toBeInTheDocument();
@@ -180,7 +228,10 @@ describe('CreateServiceAccountModal', () => {
).resolves.toBeInTheDocument();
// The footer lives outside the guard: submit is gated, Cancel still works.
expect(screen.getByTestId('create-sa-submit-btn')).toBeDisabled();
expect(screen.getByTestId('create-sa-submit-btn')).toHaveAttribute(
'aria-disabled',
'true',
);
await user.click(screen.getByTestId('create-sa-cancel-btn'));
@@ -195,7 +246,7 @@ describe('CreateServiceAccountModal', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
const nameInput = await screen.findByTestId('create-sa-name-input');
const nameInput = await screen.findByTestId('create-sa-name-input-field');
await user.type(nameInput, 'Bot');
await user.clear(nameInput);

View File

@@ -4,30 +4,6 @@
align-items: center;
gap: 4px;
.zoom-out-btn {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--secondary-foreground);
background-color: var(--secondary-background);
border: 1px solid var(--secondary-border);
border-radius: 2px;
box-shadow: none;
padding: 10px;
height: 33px;
&:hover:not(:disabled) {
color: var(--primary-foreground);
background: var(--primary-background);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.timeSelection-input {
&:hover {
border-color: var(--l1-border) !important;

View File

@@ -650,20 +650,20 @@ function CustomTimePicker({
</Popover>
</Tooltip>
{!showLiveLogs && !isModalTimeSelection && (
<Tooltip
title={
zoomOutDisabled ? 'Zoom out time range is limited to 1 month' : 'Zoom out'
}
>
<Tooltip title={zoomOutDisabled ? undefined : 'Zoom out'}>
<Button
className="zoom-out-btn"
disabledTooltip="Zoom out time range is limited to 1 month"
size="md"
onClick={handleZoomOut}
disabled={zoomOutDisabled}
data-testid="zoom-out-btn"
prefix={<ZoomOut size={14} />}
testId="zoom-out-btn"
icon
aria-label="Zoom out"
variant="solid"
color="none"
/>
color="secondary"
>
<ZoomOut size={14} />
</Button>
</Tooltip>
)}
</div>

View File

@@ -34,34 +34,10 @@ $item-spacing: 8px;
@include flex-center;
gap: 6px;
width: -webkit-fill-available;
}
&__input {
@include text-style-base;
width: 100%;
background: transparent;
border: none;
border-radius: 0;
box-shadow: none;
outline: none;
height: auto;
color: var(--l1-foreground);
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
padding: 0;
&:focus,
&:focus-visible,
&:hover {
border: none;
box-shadow: none;
outline: none;
}
&::placeholder {
color: var(--l2-foreground);
}
// The search row draws its own padding; the inline Input hugs the text.
--input-height: auto;
--input-padding: 0;
}
&__esc-key {

View File

@@ -65,7 +65,8 @@ function SearchBar({
/>
<Input
type="text"
className="timezone-picker__input"
variant="unstyled"
noFocusRing
placeholder="Search timezones..."
value={value}
onChange={(e): void => onChange(e.target.value)}

View File

@@ -164,6 +164,6 @@ describe('CustomTimePicker - zoom out button', () => {
);
const zoomOutBtn = screen.getByTestId('zoom-out-btn');
expect(zoomOutBtn).toBeDisabled();
expect(zoomOutBtn).toHaveAttribute('aria-disabled', 'true');
});
});

View File

@@ -1,7 +1,4 @@
.details-header {
// ghost + secondary missing hover bg token in @signozhq/button
--button-ghost-hover-background: var(--l3-background);
box-sizing: border-box;
display: flex;
align-items: center;

View File

@@ -27,12 +27,14 @@ function DetailsHeader({
const closeButton = (
<Button
variant="ghost"
size="icon"
size="sm"
icon
color="secondary"
onClick={onClose}
aria-label="Close"
prefix={<X size={14} />}
></Button>
>
<X size={14} />
</Button>
);
return (

View File

@@ -42,12 +42,5 @@
height: 1px;
background: var(--l1-border);
}
.export-button {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
}
}

View File

@@ -308,13 +308,16 @@ describe.each([
fireEvent.click(screen.getByText('Export'));
expect(screen.getByTestId(testId)).toBeDisabled();
expect(screen.getByTestId(testId)).toHaveAttribute('aria-disabled', 'true');
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
resolveDownload!();
await waitFor(() => {
expect(screen.getByTestId(testId)).not.toBeDisabled();
expect(screen.getByTestId(testId)).not.toHaveAttribute(
'aria-disabled',
'true',
);
});
});
});

View File

@@ -1,7 +1,7 @@
import { useCallback, useMemo, useState } from 'react';
import { Popover, Tooltip } from 'antd';
import { Button } from '@signozhq/ui/button';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { RadioGroup } from '@signozhq/ui/radio-group';
import { Typography } from '@signozhq/ui/typography';
import { TelemetryFieldKey } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
@@ -69,10 +69,15 @@ export default function DownloadOptionsMenu({
>
<div className="export-format">
<Typography.Text className="title">FORMAT</Typography.Text>
<RadioGroup value={exportFormat} onChange={setExportFormat}>
<RadioGroupItem value={DownloadFormats.CSV}>csv</RadioGroupItem>
<RadioGroupItem value={DownloadFormats.JSONL}>jsonl</RadioGroupItem>
</RadioGroup>
<RadioGroup
color="primary"
value={exportFormat}
onChange={setExportFormat}
items={[
{ value: DownloadFormats.CSV, label: 'csv' },
{ value: DownloadFormats.JSONL, label: 'jsonl' },
]}
/>
</div>
<div className="horizontal-line" />
@@ -80,19 +85,15 @@ export default function DownloadOptionsMenu({
<div className="row-limit">
<Typography.Text className="title">Number of Rows</Typography.Text>
<RadioGroup
color="primary"
value={String(rowLimit)}
onChange={(value): void => setRowLimit(Number(value))}
>
<RadioGroupItem value={String(DownloadRowCounts.TEN_K)}>
10k
</RadioGroupItem>
<RadioGroupItem value={String(DownloadRowCounts.THIRTY_K)}>
30k
</RadioGroupItem>
<RadioGroupItem value={String(DownloadRowCounts.FIFTY_K)}>
50k
</RadioGroupItem>
</RadioGroup>
items={[
{ value: String(DownloadRowCounts.TEN_K), label: '10k' },
{ value: String(DownloadRowCounts.THIRTY_K), label: '30k' },
{ value: String(DownloadRowCounts.FIFTY_K), label: '50k' },
]}
/>
</div>
{dataSource !== DataSource.TRACES && (
@@ -101,12 +102,15 @@ export default function DownloadOptionsMenu({
<div className="columns-scope">
<Typography.Text className="title">Columns</Typography.Text>
<RadioGroup value={columnsScope} onChange={setColumnsScope}>
<RadioGroupItem value={DownloadColumnsScopes.ALL}>All</RadioGroupItem>
<RadioGroupItem value={DownloadColumnsScopes.SELECTED}>
Selected
</RadioGroupItem>
</RadioGroup>
<RadioGroup
color="primary"
value={columnsScope}
onChange={setColumnsScope}
items={[
{ value: DownloadColumnsScopes.ALL, label: 'All' },
{ value: DownloadColumnsScopes.SELECTED, label: 'Selected' },
]}
/>
</div>
</>
)}
@@ -115,9 +119,9 @@ export default function DownloadOptionsMenu({
variant="solid"
color="primary"
prefix={<Download size={16} />}
size="md"
width="100%"
onClick={handleExport}
className="export-button"
disabled={isDownloading}
loading={isDownloading}
>
Export
@@ -148,13 +152,14 @@ export default function DownloadOptionsMenu({
<Button
variant="ghost"
color="secondary"
size="icon"
prefix={<Download size={14} />}
size="sm"
icon
aria-label="Download"
data-testid={`periscope-btn-download-${dataSource}`}
disabled={isDownloading}
testId={`periscope-btn-download-${dataSource}`}
loading={isDownloading}
/>
>
<Download size={14} />
</Button>
</Tooltip>
</Popover>
);

View File

@@ -38,18 +38,23 @@ function DeleteMemberDialog({
const footer = (
<>
<Button variant="solid" color="secondary" onClick={onClose}>
<X size={12} />
<Button
size="md"
variant="solid"
color="secondary"
onClick={onClose}
prefix={<X size={12} />}
>
Cancel
</Button>
<Button
size="md"
variant="solid"
color="destructive"
disabled={isDeleting}
color="danger"
onClick={onConfirm}
loading={isDeleting}
prefix={<Trash2 size={12} />}
>
<Trash2 size={12} />
{isDeleting ? 'Processing...' : title}
</Button>
</>

View File

@@ -90,7 +90,6 @@
font-weight: var(--uppercase-small-500-font-weight);
line-height: 100%;
letter-spacing: 0.44px;
text-transform: uppercase;
}
}
@@ -113,6 +112,7 @@
&__footer-left {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
&__footer-right {
@@ -128,10 +128,6 @@
flex-shrink: 0;
}
&__tooltip-wrapper {
display: inline-flex;
}
&__footer-btn {
display: inline-flex;
align-items: center;
@@ -200,6 +196,7 @@
background: var(--l2-background);
border: 1px solid var(--l1-border);
border-radius: 2px;
padding-right: var(--spacing-4);
}
&__link-text-wrap {
@@ -220,8 +217,4 @@
line-height: var(--line-height-18);
letter-spacing: -0.07px;
}
&__copy-btn {
border-left: 1px solid var(--l1-border);
}
}

View File

@@ -5,7 +5,7 @@ import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Input } from '@signozhq/ui/input';
import { toast } from '@signozhq/ui/sonner';
import { toast } from '@signozhq/ui/toast';
import { Skeleton, Tooltip } from 'antd';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
@@ -409,7 +409,8 @@ function EditMemberDrawer({
setShowResetLinkDialog(true);
onClose();
} else {
toast.error('Failed to generate password reset link', {
toast.danger('Failed to generate password reset link', {
action: { label: 'Dismiss' },
position: 'top-right',
});
}
@@ -438,7 +439,7 @@ function EditMemberDrawer({
useEffect(() => {
if (copyState.error) {
toast.error('Failed to copy link');
toast.danger('Failed to copy link', { action: { label: 'Dismiss' } });
}
}, [copyState.error]);
@@ -471,20 +472,19 @@ function EditMemberDrawer({
<label className="edit-member-drawer__label" htmlFor="member-name">
Name
</label>
<Tooltip title={isRootUser ? ROOT_USER_TOOLTIP : undefined}>
<Input
id="member-name"
value={localDisplayName}
onChange={(e): void => {
setLocalDisplayName(e.target.value);
setSaveErrors((prev) =>
prev.filter((err) => err.context !== 'Name update'),
);
}}
placeholder="Enter name"
disabled={isRootUser || isDeleted}
/>
</Tooltip>
<Input
id="member-name"
value={localDisplayName}
onChange={(e): void => {
setLocalDisplayName(e.target.value);
setSaveErrors((prev) =>
prev.filter((err) => err.context !== 'Name update'),
);
}}
placeholder="Enter name"
disabled={isRootUser || isDeleted}
disabledTooltip={isRootUser ? ROOT_USER_TOOLTIP : undefined}
/>
</div>
<div className="edit-member-drawer__field">
@@ -519,7 +519,12 @@ function EditMemberDrawer({
localRoles.map((roleId) => {
const role = availableRoles.find((r) => r.id === roleId);
return (
<Badge key={roleId} color="vanilla">
<Badge
textTransform="none"
variant="solid"
key={roleId}
color="secondary"
>
{role?.name ?? roleId}
</Badge>
);
@@ -559,15 +564,15 @@ function EditMemberDrawer({
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">Status</span>
{member?.status === MemberStatus.Active ? (
<Badge color="forest" variant="outline">
<Badge color="success" variant="outlined">
ACTIVE
</Badge>
) : member?.status === MemberStatus.Deleted ? (
<Badge color="cherry" variant="outline">
<Badge color="danger" variant="outlined">
DELETED
</Badge>
) : (
<Badge color="amber" variant="outline">
<Badge color="warning" variant="outlined">
INVITED
</Badge>
)}
@@ -575,12 +580,16 @@ function EditMemberDrawer({
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">{joinedOnLabel}</span>
<Badge color="vanilla">{formatTimestamp(member?.joinedOn)}</Badge>
<Badge textTransform="none" variant="solid" color="secondary">
{formatTimestamp(member?.joinedOn)}
</Badge>
</div>
{!isInvited && (
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">Last Modified</span>
<Badge color="vanilla">{formatTimestamp(member?.updatedAt)}</Badge>
<Badge textTransform="none" variant="solid" color="secondary">
{formatTimestamp(member?.updatedAt)}
</Badge>
</div>
)}
</div>
@@ -611,55 +620,59 @@ function EditMemberDrawer({
{!isDeleted && (
<>
<div className="edit-member-drawer__footer-left">
<Tooltip title={getDeleteTooltip(isRootUser, isSelf)}>
<span className="edit-member-drawer__tooltip-wrapper">
<Button
onClick={(): void => setShowDeleteConfirm(true)}
disabled={isRootUser || isSelf}
variant="link"
color="destructive"
>
<Trash2 size={12} />
{isInvited ? 'Revoke Invite' : 'Delete Member'}
</Button>
</span>
</Tooltip>
<Button
disabledTooltip={getDeleteTooltip(isRootUser, isSelf)}
size="md"
onClick={(): void => setShowDeleteConfirm(true)}
disabled={isRootUser || isSelf}
variant="link"
color="danger"
prefix={<Trash2 size={12} />}
>
{isInvited ? 'Revoke Invite' : 'Delete Member'}
</Button>
<div className="edit-member-drawer__footer-divider" />
<Tooltip title={isRootUser ? ROOT_USER_TOOLTIP : undefined}>
<span className="edit-member-drawer__tooltip-wrapper">
<Button
onClick={handleGenerateResetLink}
disabled={isGeneratingLink || isRootUser || isLoadingTokenStatus}
variant="link"
color="warning"
>
<RefreshCw size={12} />
{isGeneratingLink
? 'Generating...'
: isInvited
? getInviteButtonLabel(
isLoadingTokenStatus,
existingToken,
isTokenExpired,
tokenNotFound,
)
: 'Generate Password Reset Link'}
</Button>
</span>
</Tooltip>
<Button
disabledTooltip={ROOT_USER_TOOLTIP}
size="md"
onClick={handleGenerateResetLink}
disabled={isRootUser}
loading={isGeneratingLink || isLoadingTokenStatus}
variant="link"
color="warning"
prefix={<RefreshCw size={12} />}
>
{isGeneratingLink
? 'Generating...'
: isInvited
? getInviteButtonLabel(
isLoadingTokenStatus,
existingToken,
isTokenExpired,
tokenNotFound,
)
: 'Generate Password Reset Link'}
</Button>
</div>
<div className="edit-member-drawer__footer-right">
<Button variant="outlined" color="secondary" onClick={handleClose}>
<X size={14} />
<Button
size="md"
variant="outlined"
color="secondary"
onClick={handleClose}
prefix={<X size={14} />}
>
Cancel
</Button>
<Button
disabledTooltip={isRootUser ? ROOT_USER_TOOLTIP : 'No changes to save'}
size="md"
variant="solid"
color="primary"
disabled={!isDirty || isSaving || isRootUser}
disabled={!isDirty || isRootUser}
onClick={handleSave}
loading={isSaving}
>

View File

@@ -45,11 +45,11 @@ function ResetLinkDialog({
<span className="reset-link-dialog__link-text">{resetLink}</span>
</div>
<Button
size="md"
variant="link"
color="secondary"
onClick={onCopy}
prefix={hasCopied ? <Check size={12} /> : <Copy size={12} />}
className="reset-link-dialog__copy-btn"
>
{hasCopied ? 'Copied!' : 'Copy'}
</Button>

View File

@@ -1,5 +1,5 @@
import type { ReactNode } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { toast } from '@signozhq/ui/toast';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
useCreateResetPasswordToken,
@@ -82,11 +82,11 @@ jest.mock('@signozhq/ui/dialog', () => ({
),
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
jest.mock('@signozhq/ui/toast', () => ({
...jest.requireActual('@signozhq/ui/toast'),
toast: {
success: jest.fn(),
error: jest.fn(),
danger: jest.fn(),
},
}));
@@ -251,7 +251,7 @@ describe('EditMemberDrawer', () => {
expect(screen.getByText('ACTIVE')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /save member details/i }),
).toBeDisabled();
).toHaveAttribute('aria-disabled', 'true');
});
it('enables Save after editing name and calls updateUser on confirm', async () => {
@@ -271,7 +271,9 @@ describe('EditMemberDrawer', () => {
await user.type(nameInput, 'Alice Updated');
const saveBtn = screen.getByRole('button', { name: /save member details/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await waitFor(() =>
expect(saveBtn).not.toHaveAttribute('aria-disabled', 'true'),
);
await user.click(saveBtn);
@@ -295,7 +297,9 @@ describe('EditMemberDrawer', () => {
await user.type(nameInput, 'Alice Updated');
const saveBtn = screen.getByRole('button', { name: /save member details/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await waitFor(() =>
expect(saveBtn).not.toHaveAttribute('aria-disabled', 'true'),
);
await user.click(saveBtn);
await waitFor(() => {
@@ -323,7 +327,9 @@ describe('EditMemberDrawer', () => {
await user.click(await screen.findByTitle('signoz-editor'));
const saveBtn = screen.getByRole('button', { name: /save member details/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await waitFor(() =>
expect(saveBtn).not.toHaveAttribute('aria-disabled', 'true'),
);
await user.click(saveBtn);
await waitFor(() => {
@@ -349,7 +355,9 @@ describe('EditMemberDrawer', () => {
await user.click(removeBtn);
const saveBtn = screen.getByRole('button', { name: /save member details/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await waitFor(() =>
expect(saveBtn).not.toHaveAttribute('aria-disabled', 'true'),
);
await user.click(saveBtn);
await waitFor(() => {
@@ -505,7 +513,9 @@ describe('EditMemberDrawer', () => {
await user.type(nameInput, 'Bob Updated');
const saveBtn = screen.getByRole('button', { name: /save member details/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await waitFor(() =>
expect(saveBtn).not.toHaveAttribute('aria-disabled', 'true'),
);
await user.click(saveBtn);
await waitFor(() => {
@@ -541,7 +551,9 @@ describe('EditMemberDrawer', () => {
await user.type(nameInput, 'Alice Updated');
const saveBtn = screen.getByRole('button', { name: /save member details/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await waitFor(() =>
expect(saveBtn).not.toHaveAttribute('aria-disabled', 'true'),
);
await user.click(saveBtn);
await waitFor(() => {
@@ -619,7 +631,7 @@ describe('EditMemberDrawer', () => {
renderDrawer({ member: selfMember });
expect(
screen.getByRole('button', { name: /delete member/i }),
).toBeDisabled();
).toHaveAttribute('aria-disabled', 'true');
});
it('does not open delete confirm dialog when Delete is clicked while disabled (isSelf)', async () => {
@@ -642,7 +654,7 @@ describe('EditMemberDrawer', () => {
renderDrawer({ member: selfMember });
expect(
screen.getByRole('button', { name: /generate password reset link/i }),
).not.toBeDisabled();
).not.toHaveAttribute('aria-disabled', 'true');
});
});
@@ -664,21 +676,21 @@ describe('EditMemberDrawer', () => {
renderDrawer();
expect(
screen.getByRole('button', { name: /delete member/i }),
).toBeDisabled();
).toHaveAttribute('aria-disabled', 'true');
});
it('disables Reset Link button for root user', () => {
renderDrawer();
expect(
screen.getByRole('button', { name: /generate password reset link/i }),
).toBeDisabled();
).toHaveAttribute('aria-disabled', 'true');
});
it('disables Save button for root user', () => {
renderDrawer();
expect(
screen.getByRole('button', { name: /save member details/i }),
).toBeDisabled();
).toHaveAttribute('aria-disabled', 'true');
});
it('does not open delete confirm dialog when Delete is clicked while disabled (root)', async () => {

View File

@@ -3,20 +3,7 @@
width: fit-content;
display: flex;
align-items: center;
gap: 4px;
border-radius: 20px;
background: color-mix(in srgb, var(--danger-background) 20%, transparent);
padding-left: 3px;
padding-right: 8px;
cursor: pointer;
span {
color: var(--danger-background);
font-size: 10px;
font-weight: 500;
line-height: 20px; /* 200% */
letter-spacing: 0.4px;
text-transform: uppercase;
}
}
&__wrap {
background: linear-gradient(

View File

@@ -53,8 +53,13 @@ function ErrorModal({
onClick={(): void => setVisible(true)}
onKeyDown={undefined}
>
<Badge color="error">
<CircleAlert size={14} color={Color.BG_CHERRY_500} /> error
<Badge
variant="solid"
color="danger"
textTransform="uppercase"
prefix={<CircleAlert size={14} />}
>
error
</Badge>
</span>
) : (

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { Button, Col, Popover, Row, Select, Space } from 'antd';
import { DropdownMenuSimple, type MenuProps } from '@signozhq/ui/dropdown-menu';
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
import { Typography } from '@signozhq/ui/typography';
import axios from 'axios';
import TextToolTip from 'components/TextToolTip';
@@ -137,16 +137,15 @@ function ExplorerCard({
);
};
const moreOptionMenu: MenuProps = {
items: [
{
key: 'delete',
label: <Typography.Text strong>Delete</Typography.Text>,
onClick: onDeleteHandler,
icon: <Trash2 size="md" />,
},
],
};
const moreOptionItems: DropdownItemType[] = [
{
type: 'item',
value: 'delete',
label: <Typography.Text strong>Delete</Typography.Text>,
onClick: onDeleteHandler,
prefix: <Trash2 size="md" />,
},
];
const saveButtonType = isQueryUpdated ? 'default' : 'primary';
const saveButtonIcon = isQueryUpdated ? null : <Save size="md" />;
@@ -230,9 +229,14 @@ function ExplorerCard({
</Popover>
<Share2 onClick={onCopyUrlHandler} size="md" />
{viewKey && (
<DropdownMenuSimple menu={moreOptionMenu}>
<Dropdown
items={moreOptionItems}
nativeButton
align="end"
side="bottom"
>
<Button type="text" size="small" icon={<Ellipsis size="md" />} />
</DropdownMenuSimple>
</Dropdown>
)}
</Space>
</OffSetCol>

View File

@@ -1,6 +1,6 @@
.export-menu-popover {
width: 240px;
padding: 0 12px 12px 12px;
// The popover shell carries no padding of its own, so the content owns it.
padding: var(--spacing-8);
.title {
display: flex;
@@ -26,8 +26,4 @@
font-size: var(--periscope-font-size-base);
}
}
.export-button {
width: 100%;
}
}

View File

@@ -1,8 +1,8 @@
import { Download } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Popover, usePopoverHandle } from '@signozhq/ui/popover';
import { RadioGroup } from '@signozhq/ui/radio-group';
import { Tooltip } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import {
ClientExportData,
@@ -36,6 +36,10 @@ export default function ExportMenu({
}: ExportMenuProps): JSX.Element {
const [exportFormat, setExportFormat] = useState<string>(ExportFormat.Csv);
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
// Driven by a handle rather than `trigger`: the button carries a Tooltip, and a Tooltip
// routes every prop it does not recognise to its own popup, so a popover's press handlers
// would never reach the button underneath.
const menu = usePopoverHandle(setIsPopoverOpen);
const { isExporting, handleExport: handleClientExport } = useClientExport({
data,
@@ -45,48 +49,62 @@ export default function ExportMenu({
});
const handleExport = useCallback((): void => {
setIsPopoverOpen(false);
menu.close();
handleClientExport({ format: exportFormat as ExportFormat });
}, [exportFormat, handleClientExport]);
}, [menu, exportFormat, handleClientExport]);
return (
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<TooltipSimple title="Download">
<PopoverTrigger asChild>
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Download"
data-testid={`export-menu-${dataSource}`}
disabled={isExporting}
loading={isExporting}
>
<Download size={14} />
</Button>
</PopoverTrigger>
</TooltipSimple>
<PopoverContent align="end" className="export-menu-popover">
<div className="export-format">
<Typography.Text className="title">FORMAT</Typography.Text>
<RadioGroup value={exportFormat} onChange={setExportFormat}>
<RadioGroupItem value={ExportFormat.Csv}>csv</RadioGroupItem>
<RadioGroupItem value={ExportFormat.Jsonl}>jsonl</RadioGroupItem>
</RadioGroup>
</div>
<>
<Tooltip title="Download">
<Button
variant="solid"
color="primary"
className="export-button"
onClick={handleExport}
disabled={isExporting}
variant="ghost"
color="secondary"
size="sm"
icon
aria-label="Download"
aria-haspopup="dialog"
aria-expanded={isPopoverOpen}
testId={`export-menu-${dataSource}`}
loading={isExporting}
prefix={<Download size={16} />}
onClick={(event): void => menu.showAt(event.currentTarget)}
>
Export
<Download size={14} />
</Button>
</PopoverContent>
</Popover>
</Tooltip>
<Popover
side="bottom"
align="end"
width={240}
handle={menu}
aria-label="Export format"
>
<div className="export-menu-popover">
<div className="export-format">
<Typography.Text className="title">FORMAT</Typography.Text>
<RadioGroup
color="primary"
value={exportFormat}
onChange={setExportFormat}
items={[
{ value: ExportFormat.Csv, label: 'csv' },
{ value: ExportFormat.Jsonl, label: 'jsonl' },
]}
/>
</div>
<Button
size="md"
variant="solid"
color="primary"
width="100%"
onClick={handleExport}
loading={isExporting}
prefix={<Download size={16} />}
>
Export
</Button>
</div>
</Popover>
</>
);
}

View File

@@ -80,6 +80,6 @@ describe('ExportMenu', () => {
mockIsExporting = true;
renderMenu();
expect(screen.getByTestId(TEST_ID)).toBeDisabled();
expect(screen.getByTestId(TEST_ID)).toHaveAttribute('aria-disabled', 'true');
});
});

View File

@@ -57,9 +57,8 @@ function SortableField({
</div>
{!isRequired && (
<Button
className={cx(styles.removeBtn, 'periscope-btn')}
variant="outlined"
color="destructive"
variant="solid"
color="danger"
size="sm"
onClick={(): void => onRemove(field)}
>

View File

@@ -32,12 +32,15 @@
}
}
// Full-bleed search bar: only top/bottom borders, square corners.
// Per-side widths cannot be expressed via --input-border-width.
.searchInput {
background-color: var(--l1-background);
height: 40px;
border-radius: 0;
border-left: none;
border-right: none;
--input-border-radius: 0;
[data-slot='input'] {
border-left: none;
border-right: none;
}
}
.section {
@@ -98,11 +101,15 @@
user-select: none;
font-size: 13px;
> [data-slot='button'] {
opacity: 0;
transition: opacity 0.15s ease-in-out;
}
&:hover {
background-color: var(--l2-background);
.removeBtn,
.addBtn {
> [data-slot='button'] {
opacity: 1;
}
}
@@ -137,14 +144,6 @@
height: 32px;
}
.removeBtn,
.addBtn {
padding: 4px 10px;
opacity: 0;
transition: opacity 0.15s ease-in-out;
flex-shrink: 0;
}
.footer {
display: flex;
gap: 12px;

View File

@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { toast } from '@signozhq/ui/toast';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import useDebouncedFn from 'hooks/useDebouncedFunction';
@@ -140,10 +140,10 @@ function FieldsSelectorContent({
<X className={styles.closeIcon} size={16} onClick={onClose} />
</div>
<section>
<section className={styles.searchInput}>
<Input
className={styles.searchInput}
type="text"
size="large"
value={inputValue}
placeholder="Search for a field..."
onChange={handleInputChange}
@@ -173,6 +173,7 @@ function FieldsSelectorContent({
{hasUnsavedChanges && (
<div className={styles.footer}>
<Button
size="md"
variant="outlined"
color="secondary"
onClick={handleDiscard}
@@ -181,6 +182,7 @@ function FieldsSelectorContent({
Discard
</Button>
<Button
size="md"
variant="solid"
color="primary"
onClick={handleSave}

View File

@@ -138,7 +138,6 @@ function OtherFields({
<span className={styles.fieldKey}>{attr.name}</span>
{!isAtLimit && (
<Button
className={cx(styles.addBtn, 'periscope-btn')}
variant="outlined"
color="secondary"
size="sm"

View File

@@ -13,9 +13,9 @@ jest.mock('hooks/querySuggestions/useFieldKeysSuggestion', () => ({
})),
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
jest.mock('@signozhq/ui/toast', () => ({
...jest.requireActual('@signozhq/ui/toast'),
toast: { success: jest.fn(), danger: jest.fn() },
}));
// FloatingPanel is a react-rnd/portal shell — presentation only. Render its

View File

@@ -1,8 +1,8 @@
import { useCallback, useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { toast } from '@signozhq/ui/sonner';
import { toast } from '@signozhq/ui/toast';
import { Button, Input } from 'antd';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { ToggleGroup } from '@signozhq/ui/toggle-group';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { handleContactSupport } from 'container/Integrations/utils';
@@ -39,7 +39,8 @@ function FeedbackModal({ onClose }: { onClose: () => void }): JSX.Element {
})
.catch(() => {
console.error(`Failed to submit ${entityName}`);
toast.error(`Failed to submit ${entityName}`, {
toast.danger(`Failed to submit ${entityName}`, {
action: { label: 'Dismiss' },
position: 'top-right',
});
})
@@ -102,10 +103,12 @@ function FeedbackModal({ onClose }: { onClose: () => void }): JSX.Element {
return (
<div className="feedback-modal-container">
<div className="feedback-modal-header">
<ToggleGroupSimple
<ToggleGroup
variant="outlined"
color="secondary"
size="sm"
type="single"
value={activeTab}
className="feedback-modal-tabs"
onChange={setActiveTab}
items={items}
/>

View File

@@ -1,7 +1,8 @@
.header-right-section-container {
display: flex;
align-items: center;
gap: 8px;
gap: var(--spacing-8);
padding-right: var(--spacing-8);
}
.header-ai-assistant-btn-container {
@@ -121,78 +122,26 @@
}
.feedback-modal-container {
.feedback-modal-tabs {
width: 100%;
.feedback-modal-tab-label {
display: flex;
align-items: center;
gap: 8px;
.ant-radio-button-wrapper {
flex: 1;
margin: 0px !important;
border: 1px solid var(--l1-border);
&:before {
display: none;
}
.ant-radio-button-checked {
background-color: var(--l3-background);
}
.tab-icon {
width: 6px;
height: 6px;
}
.feedback-modal-tab-label {
display: flex;
align-items: center;
gap: 8px;
.tab-icon {
width: 6px;
height: 6px;
}
.feedback-tab {
background-color: var(--danger-background);
}
.bug-tab {
background-color: var(--warning-background);
}
.feature-tab {
background-color: var(--primary-background);
}
.feedback-tab {
background-color: var(--danger-background);
}
.ant-tabs-nav-list {
.ant-tabs-tab {
padding: 6px 16px;
.bug-tab {
background-color: var(--warning-background);
}
border-radius: 2px;
background: var(--l2-background);
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.1);
border: 1px solid var(--l1-border);
margin: 0 !important;
.ant-tabs-tab-btn {
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 166.667% */
letter-spacing: -0.06px;
}
&-active {
background: var(--l3-background);
color: var(--l1-foreground);
border-bottom: none !important;
.ant-tabs-tab-btn {
color: var(--l1-foreground);
}
}
}
.feature-tab {
background-color: var(--primary-background);
}
}

View File

@@ -2,7 +2,7 @@ import { useCallback, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { Dot } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Tooltip } from '@signozhq/ui/tooltip';
import Noz from 'components/Noz/Noz';
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
import { Popover } from 'antd';
@@ -113,24 +113,26 @@ function HeaderRightSection({
</span>
) : null}
<TooltipSimple title={NOZ_TOOLTIP_TITLE}>
<Button
variant="solid"
color="secondary"
className="noz-wave"
onClick={handleOpenAIAssistant}
aria-label={
showHeaderPendingBadge
? pendingUserInputCount === 1
? 'Open Noz, 1 action needs your response'
: `Open Noz, ${pendingUserInputCount} actions need your response`
: 'Open Noz'
}
prefix={<Noz size={20} />}
>
<Typography.Text>Noz</Typography.Text>
</Button>
</TooltipSimple>
<span className="noz-wave">
<Tooltip title={NOZ_TOOLTIP_TITLE}>
<Button
size="md"
variant="ghost"
color="secondary"
onClick={handleOpenAIAssistant}
aria-label={
showHeaderPendingBadge
? pendingUserInputCount === 1
? 'Open Noz, 1 action needs your response'
: `Open Noz, ${pendingUserInputCount} actions need your response`
: 'Open Noz'
}
prefix={<Noz size={20} />}
>
<Typography.Text>Noz</Typography.Text>
</Button>
</Tooltip>
</span>
</div>
)}
@@ -147,13 +149,15 @@ function HeaderRightSection({
onOpenChange={handleOpenFeedbackModalChange}
>
<Button
variant="ghost"
size="icon"
className="share-feedback-btn"
color="primary"
variant="link"
size="md"
icon
aria-label="Feedback"
prefix={<SquarePen size={14} />}
onClick={handleOpenFeedbackModal}
/>
>
<SquarePen size={14} />
</Button>
</Popover>
)}
@@ -170,16 +174,19 @@ function HeaderRightSection({
onOpenChange={handleOpenAnnouncementsModalChange}
>
<Button
variant="ghost"
size="icon"
color="primary"
variant="link"
size="md"
icon
aria-label="Announcements"
prefix={<Inbox size={14} />}
onClick={(): void => {
logEvent('Announcements: Clicked', {
page: location.pathname,
});
}}
/>
>
<Inbox size={14} />
</Button>
</Popover>
)}
@@ -196,12 +203,15 @@ function HeaderRightSection({
onOpenChange={handleOpenShareURLModalChange}
>
<Button
variant="ghost"
size="icon"
color="primary"
variant="link"
size="md"
icon
aria-label="Share"
prefix={<Globe size={14} />}
onClick={handleOpenShareURLModal}
/>
>
<Globe size={14} />
</Button>
</Popover>
)}
</div>

View File

@@ -149,6 +149,9 @@ function ShareURLModal({ extraOption }: ShareURLModalProps): JSX.Element {
<Info size={14} color={Color.BG_AMBER_600} />
)}
<Switch
color="primary"
textPlacement="right"
disabledTooltip="Please select / enter valid relative time to toggle."
value={enableAbsoluteTime}
disabled={!isValidateRelativeTime}
onChange={(): void => {
@@ -173,6 +176,8 @@ function ShareURLModal({ extraOption }: ShareURLModalProps): JSX.Element {
</Typography.Text>
<div className="absolute-relative-time-toggler">
<Switch
color="primary"
textPlacement="right"
value={enableExtraOption}
onChange={(): void => setEnableExtraOption((prev) => !prev)}
/>

View File

@@ -1,6 +1,6 @@
// Mock dependencies before imports
import { useLocation } from 'react-router-dom';
import { toast } from '@signozhq/ui/sonner';
import { toast } from '@signozhq/ui/toast';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { logEventMock } from '__tests__/logEventMock';
@@ -14,11 +14,11 @@ jest.mock('react-router-dom', () => ({
useLocation: jest.fn(),
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
jest.mock('@signozhq/ui/toast', () => ({
...jest.requireActual('@signozhq/ui/toast'),
toast: {
success: jest.fn(),
error: jest.fn(),
danger: jest.fn(),
},
}));
@@ -50,7 +50,7 @@ describe('FeedbackModal', () => {
isCloudUser: false,
});
mockToast.success.mockClear();
mockToast.error.mockClear();
mockToast.danger.mockClear();
});
it('should render feedback modal with all tabs', () => {
@@ -69,23 +69,23 @@ describe('FeedbackModal', () => {
const user = userEvent.setup();
render(<FeedbackModal onClose={mockOnClose} />);
// Initially, feedback radio should be active
const feedbackRadio = screen.getByRole('radio', { name: 'Feedback' });
expect(feedbackRadio).toBeChecked();
// Initially, feedback button should be active
const feedbackButton = screen.getByRole('button', { name: 'Feedback' });
expect(feedbackButton).toHaveAttribute('aria-pressed', 'true');
const bugTab = screen.getByText('Report a bug');
await user.click(bugTab);
// Bug radio should now be active
const bugRadio = screen.getByRole('radio', { name: 'Report a bug' });
expect(bugRadio).toBeChecked();
// Bug button should now be active
const bugButton = screen.getByRole('button', { name: 'Report a bug' });
expect(bugButton).toHaveAttribute('aria-pressed', 'true');
const featureTab = screen.getByText('Feature request');
await user.click(featureTab);
// Feature radio should now be active
const featureRadio = screen.getByRole('radio', { name: 'Feature request' });
expect(featureRadio).toBeChecked();
// Feature button should now be active
const featureButton = screen.getByRole('button', { name: 'Feature request' });
expect(featureButton).toHaveAttribute('aria-pressed', 'true');
});
it('should update feedback text when typing in textarea', async () => {
@@ -133,9 +133,9 @@ describe('FeedbackModal', () => {
const bugTab = screen.getByText('Report a bug');
await user.click(bugTab);
// Verify bug report radio is now active
const bugRadio = screen.getByRole('radio', { name: 'Report a bug' });
expect(bugRadio).toBeChecked();
// Verify bug report button is now active
const bugButton = screen.getByRole('button', { name: 'Report a bug' });
expect(bugButton).toHaveAttribute('aria-pressed', 'true');
const textarea = screen.getByPlaceholderText('Write your feedback here...');
const submitButton = screen.getByRole('button', { name: /submit/i });
@@ -166,9 +166,9 @@ describe('FeedbackModal', () => {
const featureTab = screen.getByText('Feature request');
await user.click(featureTab);
// Verify feature request radio is now active
const featureRadio = screen.getByRole('radio', { name: 'Feature request' });
expect(featureRadio).toBeChecked();
// Verify feature request button is now active
const featureButton = screen.getByRole('button', { name: 'Feature request' });
expect(featureButton).toHaveAttribute('aria-pressed', 'true');
const textarea = screen.getByPlaceholderText('Write your feedback here...');
const submitButton = screen.getByRole('button', { name: /submit/i });
@@ -262,8 +262,8 @@ describe('FeedbackModal', () => {
);
expect(newTextArea).toHaveValue(''); // Should be empty
// Verify active radio is reset to default (Feedback radio)
const feedbackRadio = screen.getByRole('radio', { name: 'Feedback' });
expect(feedbackRadio).toBeChecked();
// Verify active button is reset to default (Feedback button)
const feedbackButton = screen.getByRole('button', { name: 'Feedback' });
expect(feedbackButton).toHaveAttribute('aria-pressed', 'true');
});
});

View File

@@ -176,7 +176,7 @@ describe('ShareURLModal', () => {
expect(
screen.getByText('Please select / enter valid relative time to toggle.'),
).toBeInTheDocument();
expect(screen.getByRole('switch')).toBeDisabled();
expect(screen.getByRole('switch')).toHaveAttribute('aria-disabled', 'true');
});
it('should process URL with absolute time for non-custom time', async () => {

View File

@@ -1,44 +1,32 @@
import { Badge } from '@signozhq/ui/badge';
import { Badge, type BadgeColorType } from '@signozhq/ui/badge';
type BadgeColor =
| 'vanilla'
| 'robin'
| 'forest'
| 'amber'
| 'sienna'
| 'cherry'
| 'sakura'
| 'aqua';
function getStatusCodeColor(statusCode: number): BadgeColorType {
if (statusCode >= 200 && statusCode < 300) {
return 'success';
}
if (statusCode >= 300 && statusCode < 400) {
return 'primary';
}
if (statusCode >= 400 && statusCode < 500) {
return 'warning';
}
if (statusCode >= 500) {
return 'danger';
}
if (statusCode >= 100 && statusCode < 200) {
return 'secondary';
}
return 'primary';
}
interface HttpStatusBadgeProps {
statusCode: string | number;
testId?: string;
className?: string;
}
function getStatusCodeColor(statusCode: number): BadgeColor {
if (statusCode >= 200 && statusCode < 300) {
return 'forest'; // Success - green
}
if (statusCode >= 300 && statusCode < 400) {
return 'robin'; // Redirect - blue
}
if (statusCode >= 400 && statusCode < 500) {
return 'amber'; // Client error - amber
}
if (statusCode >= 500) {
return 'cherry'; // Server error - red
}
if (statusCode >= 100 && statusCode < 200) {
return 'vanilla'; // Informational - neutral
}
return 'robin'; // Default fallback
}
function HttpStatusBadge({
statusCode,
testId,
className,
}: HttpStatusBadgeProps): JSX.Element | null {
const numericStatusCode = Number(statusCode);
@@ -49,12 +37,7 @@ function HttpStatusBadge({
const color = getStatusCodeColor(numericStatusCode);
return (
<Badge
color={color}
variant="outline"
data-testid={testId}
className={className}
>
<Badge color={color} variant="outlined" testId={testId}>
{statusCode}
</Badge>
);

View File

@@ -4,13 +4,10 @@
border-radius: 2px 0px 0px 2px;
// The label chip and the Input share the --input-with-label-* theming
// contract (set by e.g. FormAlertRules, WidgetFullView) so the joined
// group recolors as one.
.label {
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 18px; /* 128.571% */
letter-spacing: 0.56px;
max-width: 150px;
min-width: 60px;
white-space: nowrap;
@@ -27,66 +24,40 @@
display: flex;
justify-content: flex-start;
align-items: center;
font-weight: var(--font-weight-light);
}
.input {
flex: 1;
min-width: 150px;
font-family: 'Space Mono', monospace !important;
--input-border-radius: 0px;
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
background: var(--input-with-label-background-color, var(--l2-background));
color: var(--input-with-label-color, var(--l2-foreground));
font-size: 12px !important;
line-height: 25px;
position: relative;
// Square corners: the input sits mid-group between label and close button.
--input-border-radius: 0px;
--input-border: var(--input-with-label-border-color, var(--l2-border));
--input-background: var(
--input-with-label-background-color,
var(--l2-background)
);
--input-foreground: var(--input-with-label-color, var(--l2-foreground));
&:hover,
&:focus {
z-index: 1;
}
.ant-select-selector {
position: relative;
border-radius: inherit;
}
.ant-select:hover .ant-select-selector,
.ant-select-focused .ant-select-selector {
&:focus-within {
z-index: 1;
}
&.input__has-label-after {
margin-left: -1px;
.ant-select-selector {
margin-left: -1px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
&.input__has-close-button {
.ant-select-selector {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
~ .close-btn {
margin-left: -1px;
}
}
&::placeholder {
color: var(--input-with-label-color, var(--l3-foreground)) !important;
font-size: 12px !important;
}
&[type='number']::-webkit-inner-spin-button,
&[type='number']::-webkit-outer-spin-button {
// The Input has no token for the native number spinners.
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
@@ -110,16 +81,7 @@
&.labelAfter {
.input {
border-radius: 2px;
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
background: var(--input-with-label-background-color, var(--l2-background));
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
.ant-select-selector {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
--input-border-radius: 2px 0 0 2px;
}
.label {

View File

@@ -46,19 +46,23 @@ function InputWithLabel({
})}
>
{!labelAfter && <Typography.Text className="label">{label}</Typography.Text>}
<Input
<div
className={cx('input', {
'input__has-label-after': !labelAfter,
'input__has-close-button': !!onClose,
})}
placeholder={placeholder}
type={type}
value={inputValue}
onChange={handleChange}
disabled={disabled}
name={label.toLowerCase()}
data-testid={`input-${label}`}
/>
>
<Input
placeholder={placeholder}
type={type}
value={inputValue}
onChange={handleChange}
disabled={disabled}
disabledTooltip={undefined}
name={label.toLowerCase()}
testId={`input-${label}`}
/>
</div>
{labelAfter && <Typography.Text className="label">{label}</Typography.Text>}
{onClose && (
<Button

View File

@@ -55,26 +55,12 @@
flex: 1 1 0;
min-width: 0;
// Matches the antd RolesSelect in the same row, which is forced to the
// same field background below.
--input-background: var(
--invite-members-field-background,
var(--l2-background)
);
--input-hover-background: var(
--invite-members-field-background,
var(--l2-background)
);
--input-focus-background: var(
--invite-members-field-background,
var(--l2-background)
);
--input-disabled-background: var(
--invite-members-field-background,
var(--l2-background)
);
input::placeholder {
color: var(--l3-foreground);
}
}
.cellRole {

View File

@@ -1,4 +1,4 @@
import { CircleAlert, Plus, Trash2 } from '@signozhq/icons';
import { CircleAlert, Plus, SolidCheckCircle2, Trash2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Callout } from '@signozhq/ui/callout';
import { Input } from '@signozhq/ui/input';
@@ -97,7 +97,7 @@ function InviteMembers({
onChange={(e): void => updateEmail(row.id, e.target.value)}
name={`invite-email-${row.id}`}
autoComplete="email"
data-testid={`invite-email-${row.id}`}
testId={`invite-email-${row.id}`}
/>
{emailValidity[row.id] === false && row.email.trim() !== '' && (
<Typography.Text size="small" className={styles.errorText}>
@@ -119,11 +119,13 @@ function InviteMembers({
<div className={styles.cellAction}>
{canRemoveRow && (
<Button
variant="ghost"
color="destructive"
size="md"
variant="solid"
color="danger"
onClick={(): void => removeRow(row.id)}
aria-label="Remove row"
data-testid={`invite-remove-${row.id}`}
testId={`invite-remove-${row.id}`}
icon
>
<Trash2 size={12} />
</Button>
@@ -136,11 +138,12 @@ function InviteMembers({
{showAddButton && (
<div className={styles.addRow}>
<Button
size="md"
variant="dashed"
color="secondary"
prefix={<Plus size={12} />}
onClick={addRow}
data-testid="invite-add-row"
testId="invite-add-row"
>
Add another
</Button>
@@ -149,61 +152,62 @@ function InviteMembers({
</div>
{hasValidationErrors && (
<Callout
type="error"
size="small"
showIcon
icon={<CircleAlert size={12} />}
className={styles.callout}
data-testid="invite-validation-error"
>
{getValidationErrorMessage()}
</Callout>
<div className={styles.callout}>
<Callout
color="danger"
size="sm"
icon={<CircleAlert />}
testId="invite-validation-error"
>
{getValidationErrorMessage()}
</Callout>
</div>
)}
{hasResults && hasFailures && (
<Callout
type="error"
size="small"
showIcon
icon={<CircleAlert size={12} />}
className={styles.callout}
data-testid="invite-api-error"
>
<div className={styles.results}>
{hasSuccesses && (
<div className={styles.callout}>
<Callout
color="danger"
size="sm"
icon={<CircleAlert />}
testId="invite-api-error"
>
<div className={styles.results}>
{hasSuccesses && (
<Typography.Text size="small">
{successResults.length} invite(s) sent successfully.
</Typography.Text>
)}
<Typography.Text size="small">
{successResults.length} invite(s) sent successfully.
{failedResults.length} invite(s) failed:
</Typography.Text>
)}
<Typography.Text size="small">
{failedResults.length} invite(s) failed:
</Typography.Text>
<ul className={styles.resultsList}>
{failedResults.map((result) => (
<li key={result.email}>
<Typography.Text size="small">
{result.email}: {result.error}
</Typography.Text>
</li>
))}
</ul>
</div>
</Callout>
<ul className={styles.resultsList}>
{failedResults.map((result) => (
<li key={result.email}>
<Typography.Text size="small">
{result.email}: {result.error}
</Typography.Text>
</li>
))}
</ul>
</div>
</Callout>
</div>
)}
{hasResults && !hasFailures && hasSuccesses && (
<Callout
type="success"
size="small"
showIcon
className={styles.callout}
data-testid="invite-success"
>
<Typography.Text size="small">
{successResults.length} invite(s) sent successfully!
</Typography.Text>
</Callout>
<div className={styles.callout}>
<Callout
color="success"
size="sm"
icon={<SolidCheckCircle2 />}
testId="invite-success"
>
<Typography.Text size="small">
{successResults.length} invite(s) sent successfully!
</Typography.Text>
</Callout>
</div>
)}
{renderFooter?.({

View File

@@ -158,37 +158,14 @@
}
}
.views-tabs {
color: var(--l2-foreground);
.view-title {
display: flex;
gap: var(--margin-2);
align-items: center;
justify-content: center;
font-size: var(--font-size-xs);
font-style: normal;
font-weight: var(--font-weight-normal);
}
> button {
border: 1px solid var(--l1-border);
width: 114px;
&::before {
background: var(--l1-border);
}
&[data-state='on'] {
background: var(--l3-background);
color: var(--l1-foreground);
border: 1px solid var(--l1-border);
&::before {
background: var(--l1-border);
}
}
}
.view-title {
display: flex;
gap: var(--margin-2);
align-items: center;
justify-content: center;
font-size: var(--font-size-xs);
font-style: normal;
font-weight: var(--font-weight-normal);
}
.search-input {
@@ -239,42 +216,4 @@
align-items: center;
margin-left: 8px;
}
.log-arrow-btn {
padding: 0;
min-width: 28px;
height: 28px;
border-radius: 4px;
background: var(--l2-background);
color: var(--l2-foreground);
border: 1px solid var(--l1-border);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.08);
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s ease-in-out;
}
.log-arrow-btn-up,
.log-arrow-btn-down {
background: var(--l2-background);
}
.log-arrow-btn:active,
.log-arrow-btn:focus {
background: var(--l3-background);
color: var(--l1-foreground);
}
.log-arrow-btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
background: var(--l1-background);
color: var(--l3-foreground);
.log-arrow-btn:hover:not([disabled]) {
background: var(--l3-background);
color: var(--l1-foreground);
}
}
}

View File

@@ -6,25 +6,12 @@
gap: 8px;
}
.tooltipContent {
--tooltip-z-index: 2100;
}
.dropdownContent {
--dropdown-menu-content-z-index: 2100;
}
.leftSection {
display: flex;
align-items: center;
gap: 8px;
}
.divider {
height: 16px;
margin: 0;
}
.timestamp {
font-family: 'Geist Mono', monospace;
font-size: var(--font-size-sm);

View File

@@ -1,11 +1,11 @@
import { Button } from '@signozhq/ui/button';
import { Divider } from '@signozhq/ui/divider';
import { DropdownMenuSimple as Dropdown } from '@signozhq/ui/dropdown-menu';
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
import { Typography } from '@signozhq/ui/typography';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Tooltip } from '@signozhq/ui/tooltip';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { aggregateAttributesResourcesToString } from 'container/LogDetailedView/utils';
import { toast } from '@signozhq/ui/sonner';
import { toast } from '@signozhq/ui/toast';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import {
ChevronDown,
@@ -23,8 +23,6 @@ import { useCopyToClipboard } from 'react-use';
import styles from './LogDetailsHeader.module.scss';
const TOOLTIP_CONTENT_PROPS = { className: styles.tooltipContent };
interface LogDetailsHeaderProps {
log: ILog;
onNavigatePrev: () => void;
@@ -53,17 +51,19 @@ function LogDetailsHeader({
toast.success('Copied to clipboard', { position: 'bottom-right' });
};
const menuItems = [
const menuItems: DropdownItemType[] = [
{
key: 'copy-log',
type: 'item',
value: 'copy-log',
label: 'Copy log',
icon: <Copy size={14} />,
prefix: <Copy size={14} />,
onClick: handleCopyLog,
},
{
key: 'copy-link',
type: 'item',
value: 'copy-link',
label: 'Copy link to log',
icon: <Link size={14} />,
prefix: <Link size={14} />,
onClick: (): void => onLogCopy(),
},
];
@@ -76,7 +76,7 @@ function LogDetailsHeader({
return (
<div className={styles.header} data-log-detail-ignore="true">
<div className={styles.leftSection}>
<Divider type="vertical" className={styles.divider} />
<Divider orientation="vertical" height={16} spacing={0} />
<Typography.Text
className={styles.timestamp}
data-testid="log-details-header-timestamp"
@@ -91,6 +91,7 @@ function LogDetailsHeader({
<div className={styles.actions}>
{showOpenInExplorer && (
<Button
size="md"
variant="outlined"
color="secondary"
prefix={<Compass size={16} />}
@@ -100,51 +101,57 @@ function LogDetailsHeader({
</Button>
)}
<Dropdown
menu={{ items: menuItems }}
align="end"
className={styles.dropdownContent}
onClick={(e: MouseEvent): void => e.stopPropagation()}
>
<Dropdown items={menuItems} nativeButton align="end" side="bottom">
<Button
size="md"
variant="link"
color="secondary"
prefix={<Ellipsis size={16} />}
data-testid="log-details-header-menu"
/>
icon
aria-label="Log actions"
testId="log-details-header-menu"
onClick={(e: MouseEvent): void => e.stopPropagation()}
>
<Ellipsis size={16} />
</Button>
</Dropdown>
<div className={styles.arrows}>
<TooltipSimple
title="Move to previous log"
<Tooltip
title={isPrevDisabled ? undefined : 'Move to previous log'}
side="top"
open={isPrevDisabled ? false : undefined}
tooltipContentProps={TOOLTIP_CONTENT_PROPS}
>
<Button
disabledTooltip="No previous log"
size="md"
variant="outlined"
color="secondary"
prefix={<ChevronUp size={14} />}
icon
aria-label="Move to previous log"
disabled={isPrevDisabled}
onClick={onNavigatePrev}
data-testid="log-details-header-prev"
/>
</TooltipSimple>
<TooltipSimple
title="Move to next log"
testId="log-details-header-prev"
>
<ChevronUp size={14} />
</Button>
</Tooltip>
<Tooltip
title={isNextDisabled ? undefined : 'Move to next log'}
side="top"
open={isNextDisabled ? false : undefined}
tooltipContentProps={TOOLTIP_CONTENT_PROPS}
>
<Button
disabledTooltip="No next log"
size="md"
variant="outlined"
color="secondary"
prefix={<ChevronDown size={14} />}
icon
aria-label="Move to next log"
disabled={isNextDisabled}
onClick={onNavigateNext}
data-testid="log-details-header-next"
/>
</TooltipSimple>
testId="log-details-header-next"
>
<ChevronDown size={14} />
</Button>
</Tooltip>
</div>
</div>
</div>

View File

@@ -12,13 +12,6 @@
}
}
.valueBadge {
--badge-font-size: 13px;
box-sizing: border-box;
max-width: 100%;
min-width: 0;
}
// Truncating text inside a badge
.badgeText {
min-width: 0;
@@ -29,11 +22,14 @@
.serviceDot {
width: 6px;
min-width: 6px;
height: 6px;
min-height: 6px;
border-radius: 50%;
background: var(--accent-forest);
flex-shrink: 0;
margin-right: 4px;
display: inline-block;
}
.traceLink {

View File

@@ -1,21 +1,20 @@
import { ReactNode } from 'react';
import { Badge, BadgeColor } from '@signozhq/ui/badge';
import { Badge, type BadgeColorType } from '@signozhq/ui/badge';
import { LogType } from 'components/Logs/LogStateIndicator/LogStateIndicator';
import { getLogIndicatorType } from 'components/Logs/LogStateIndicator/utils';
import { ILog } from 'types/api/logs/log';
import { getSemconvMembers } from 'utils/semconv';
import styles from './LogHighlights.module.scss';
import TraceIdField from './TraceIdField';
// Severity badge color mirrors the LogStateIndicator bar
const SEVERITY_COLOR: Record<string, BadgeColor> = {
[LogType.TRACE]: 'forest',
[LogType.DEBUG]: 'aqua',
[LogType.INFO]: 'robin',
[LogType.WARN]: 'amber',
[LogType.ERROR]: 'cherry',
[LogType.FATAL]: 'sakura',
const SEVERITY_COLOR: Record<string, BadgeColorType> = {
[LogType.TRACE]: 'success',
[LogType.DEBUG]: 'info',
[LogType.INFO]: 'primary',
[LogType.WARN]: 'warning',
[LogType.ERROR]: 'danger',
[LogType.FATAL]: 'highlight-danger',
};
export interface LogHighlightConfig {
@@ -33,9 +32,13 @@ const getAttr = (log: ILog, key: string): string =>
const valueBadge = (
value: string,
options?: { prefix?: ReactNode; color?: BadgeColor },
options?: { prefix?: ReactNode; color?: BadgeColorType },
): ReactNode => (
<Badge color={options?.color ?? 'vanilla'} className={styles.valueBadge}>
<Badge
variant="solid"
color={options?.color ?? 'secondary'}
textTransform="none"
>
{options?.prefix}
<span className={styles.badgeText} title={value}>
{value}
@@ -80,9 +83,7 @@ export const LOG_HIGHLIGHTS: LogHighlightConfig[] = [
key: 'environment',
label: 'ENVIRONMENT',
render: (log): ReactNode | null => {
const value = getSemconvMembers('deployment.environment.name')
.map((key) => getAttr(log, key))
.find(Boolean);
const value = getAttr(log, 'deployment.environment');
return value ? valueBadge(value) : null;
},
},

View File

@@ -1,4 +1,4 @@
import { toast } from '@signozhq/ui/sonner';
import { toast } from '@signozhq/ui/toast';
import { LOCALSTORAGE } from 'constants/localStorage';
import { render, screen, userEvent } from 'tests/test-utils';
import { ILog } from 'types/api/logs/log';
@@ -7,8 +7,8 @@ import LogDetail from '..';
import { VIEW_TYPES } from '../constants';
import { LogDetailProps } from '../LogDetail.interfaces';
jest.mock('@signozhq/ui/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
jest.mock('@signozhq/ui/toast', () => ({
toast: { success: jest.fn(), danger: jest.fn() },
}));
// DataViewer pulls in react-json-tree (ESM) + Monaco; mock it (as trace's tests
@@ -129,6 +129,18 @@ describe('LogDetail drawer — header', () => {
});
});
it('keeps the drawer open when a ⋯ menu item is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onClose = jest.fn();
renderDrawer({ onClose });
await user.click(screen.getByTestId('log-details-header-menu'));
await user.click(await screen.findByText('Copy log'));
expect(onClose).not.toHaveBeenCalled();
});
it('shows "Open in Explorer" when a handleOpenInExplorer handler is provided', () => {
renderDrawer({ handleOpenInExplorer: jest.fn() });
@@ -217,8 +229,14 @@ describe('LogDetail drawer — header', () => {
// Active log is the first one.
renderDrawer({ log: logs[0], logs, onNavigateLog });
expect(screen.getByTestId('log-details-header-prev')).toBeDisabled();
expect(screen.getByTestId('log-details-header-next')).toBeEnabled();
expect(screen.getByTestId('log-details-header-prev')).toHaveAttribute(
'aria-disabled',
'true',
);
expect(screen.getByTestId('log-details-header-next')).not.toHaveAttribute(
'aria-disabled',
'true',
);
await user.click(screen.getByTestId('log-details-header-next'));
expect(onNavigateLog).toHaveBeenLastCalledWith(logs[1]);

View File

@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Color, Spacing } from '@signozhq/design-tokens';
import { Button } from '@signozhq/ui/button';
import { Drawer, Tooltip } from 'antd';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { ToggleGroup } from '@signozhq/ui/toggle-group';
import { Typography } from '@signozhq/ui/typography';
import LogStateIndicator from 'components/Logs/LogStateIndicator/LogStateIndicator';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
@@ -86,7 +86,8 @@ function LogDetailInner({
target.closest('.cm-tooltip-autocomplete') ||
target.closest('.drawer-popover') ||
target.closest('.query-status-popover') ||
target.closest('[data-radix-popper-content-wrapper]')
target.closest('[data-radix-popper-content-wrapper]') ||
target.closest('[data-base-ui-portal]')
) {
return;
}
@@ -315,9 +316,12 @@ function LogDetailInner({
<div className="log-detail-drawer__section-divider" />
<div className="tabs-and-search">
<ToggleGroupSimple
<ToggleGroup
variant="outlined"
color="secondary"
size="sm"
type="single"
className="views-tabs"
testId="log-detail-views-tabs"
onChange={handleModeChange}
value={selectedView}
items={[
@@ -363,9 +367,12 @@ function LogDetailInner({
variant="link"
color="secondary"
size="sm"
prefix={<Filter size="lg" />}
icon
aria-label="Show Filters"
onClick={handleFilterVisible}
/>
>
<Filter size="lg" />
</Button>
</Tooltip>
)}
</div>

View File

@@ -199,49 +199,23 @@
}
}
// Stepper group: the Input sits seamlessly between the -/+
// periscope-btns (26px tall, l1-border).
.max-lines-per-row-input {
display: flex;
.ant-input-number-handler-wrap {
display: none;
}
--input-height: 26px;
--input-border-radius: 0;
--input-border: var(--l1-border);
--input-border-width: 1px 0;
.ant-input-number {
[data-testid='max-lines-per-row-field'] {
min-width: 36px;
width: auto;
border-right: none;
border-left: none;
border-top: 1px solid var(--l1-border);
border-bottom: 1px solid var(--l1-border);
text-align: center;
height: 26px;
border-radius: 0;
&:active,
&:focus {
border: none;
box-shadow: none;
}
}
.ant-input-number-focused {
box-shadow: none !important;
}
.ant-input-number-input-wrap {
// No Input token for text alignment.
input {
text-align: center;
font-size: 13px;
&:active,
&:focus {
border: none;
}
}
&:active,
&:focus {
border: none;
}
}

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { Button, InputNumber, Popover, Tooltip } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Button, Popover, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { LogViewMode } from 'container/OptionsMenu/types';
@@ -207,11 +208,14 @@ function OptionsMenu({
{' '}
<Minus size={12} />{' '}
</button>
<InputNumber
<Input.Number
min={1}
max={10}
value={maxLinesPerRow}
onChange={handleLinesPerRowChange}
controls={false}
noFocusRing
testId="max-lines-per-row-field"
/>
<button
type="button"

View File

@@ -1,4 +1,4 @@
import type { ReactNode } from 'react';
import type { ReactElement, ReactNode } from 'react';
import {
Bold,
CodeXml,
@@ -11,16 +11,17 @@ import {
Type,
} from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Tooltip } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { READ_ONLY_TOOLTIP } from './constants';
import InsertVariableMenu from './InsertVariableMenu';
import MarkdownHelp from './MarkdownHelp';
import type { EditorCommand, EditorVariable } from './types';
import styles from './MarkdownEditor.module.scss';
const COMMAND_ICONS: Record<string, ReactNode> = {
const COMMAND_ICONS: Record<string, ReactElement> = {
heading: <Heading size={14} />,
bold: <Bold size={14} />,
italic: <Italic size={14} />,
@@ -61,20 +62,22 @@ function EditorToolbar({
<span className={styles.toolbarDivider} />
<div className={styles.commands}>
{commands.map((command) => (
<TooltipSimple key={command.id} title={command.label}>
<Tooltip key={command.id} title={disabled ? undefined : command.label}>
<Button
disabledTooltip={READ_ONLY_TOOLTIP}
type="button"
variant="ghost"
color="secondary"
size="icon"
size="sm"
icon
disabled={disabled}
aria-label={command.label}
data-testid={`markdown-command-${command.id}`}
testId={`markdown-command-${command.id}`}
onClick={(): void => onRunCommand(command)}
>
{COMMAND_ICONS[command.id]}
</Button>
</TooltipSimple>
</Tooltip>
))}
</div>
<div className={styles.toolbarEnd}>

View File

@@ -1,8 +1,9 @@
import { useMemo, useState } from 'react';
import { ChevronDown, DollarSign, Search } from '@signozhq/icons';
import { ChevronDown, DollarSign } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import { Dropdown, type DropdownActionItemType } from '@signozhq/ui/dropdown';
import { READ_ONLY_TOOLTIP } from './constants';
import type { EditorVariable } from './types';
import styles from './MarkdownEditor.module.scss';
@@ -17,9 +18,10 @@ interface InsertVariableMenuProps {
function toMenuItems(
variables: EditorVariable[],
onSelect: (name: string) => void,
): MenuItem[] {
): DropdownActionItemType[] {
return variables.map((variable) => ({
key: variable.name,
type: 'item',
value: variable.name,
label: (
<span
className={styles.variableRow}
@@ -60,15 +62,17 @@ function InsertVariableMenu({
}
return (
<DropdownMenuSimple
className={styles.variableMenu}
menu={{
items,
search: {
placeholder: 'Search variables',
searchIcon: <Search size={14} />,
onSearchChange: setSearch,
},
<Dropdown
items={items}
nativeButton
align="end"
side="bottom"
contentMaxWidth={320}
disabled={disabled}
disabledTooltip={READ_ONLY_TOOLTIP}
searchInputProps={{
placeholder: 'Search variables',
onChange: setSearch,
}}
>
<Button
@@ -76,15 +80,13 @@ function InsertVariableMenu({
variant="outlined"
color="secondary"
size="sm"
disabled={disabled}
prefix={<DollarSign size={14} className={styles.insertVariableIcon} />}
suffix={<ChevronDown size={14} />}
className={styles.insertVariable}
data-testid="markdown-insert-variable"
testId="markdown-insert-variable"
>
Insert variable
</Button>
</DropdownMenuSimple>
</Dropdown>
);
}

View File

@@ -74,27 +74,10 @@
margin-left: auto;
}
.insertVariable {
white-space: nowrap;
}
.insertVariableIcon {
color: var(--text-amber-400);
}
// The ui library's dropdown assumes a global border-box reset this app doesn't
// have (`box-sizing` is set on `body` only and doesn't inherit): its items are
// `width: 100%` + padding, so in the portal they lay out content-box and
// overflow the popup by the padding — clipping the flush-right badge.
.variableMenu,
.variableMenu * {
box-sizing: border-box;
}
.variableMenu {
width: 320px;
}
// Shrinkable, so a clamped popup truncates the name instead of clipping the
// badge at the content's `overflow: hidden` edge.
.variableRow {
@@ -201,8 +184,8 @@
}
.helpContent {
width: 280px;
max-height: 320px;
// The popover shell carries no padding of its own, so the content owns it.
padding: var(--spacing-8);
overflow-y: auto;
@include custom-scrollbar;
}

View File

@@ -1,6 +1,6 @@
import { CircleHelp } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
import { Popover } from '@signozhq/ui/popover';
import { Typography } from '@signozhq/ui/typography';
import { MARKDOWN_HELP_ITEMS } from './constants';
@@ -9,20 +9,28 @@ import styles from './MarkdownEditor.module.scss';
function MarkdownHelp(): JSX.Element {
return (
<Popover>
<PopoverTrigger asChild>
<Popover
nativeButton
side="bottom"
align="end"
width={280}
maxHeight={320}
aria-label="Markdown syntax"
trigger={
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
size="sm"
icon
aria-label="Markdown syntax help"
data-testid="markdown-help-trigger"
testId="markdown-help-trigger"
>
<CircleHelp size={14} />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className={styles.helpContent}>
}
>
<div className={styles.helpContent}>
<Typography.Text className={styles.helpTitle}>
Markdown syntax
</Typography.Text>
@@ -36,7 +44,7 @@ function MarkdownHelp(): JSX.Element {
</div>
))}
</dl>
</PopoverContent>
</div>
</Popover>
);
}

View File

@@ -256,8 +256,14 @@ describe('MarkdownEditor', () => {
/>,
);
expect(screen.getByTestId('markdown-command-bold')).toBeDisabled();
expect(screen.getByTestId('markdown-insert-variable')).toBeDisabled();
expect(screen.getByTestId('markdown-command-bold')).toHaveAttribute(
'aria-disabled',
'true',
);
expect(screen.getByTestId('markdown-insert-variable')).toHaveAttribute(
'aria-disabled',
'true',
);
});
it('hides the insert-variable control when none are available', () => {

View File

@@ -1,6 +1,8 @@
// The body is persisted inline in the dashboard JSON, so its length is capped.
export const MARKDOWN_MAX_LENGTH = 16000;
export const READ_ONLY_TOOLTIP = 'The editor is read-only';
/** The canonical syntax; the renderer resolves the other three too. */
export const formatVariableToken = (name: string): string => `$${name}`;

View File

@@ -86,18 +86,6 @@
border: none !important;
box-shadow: none !important;
}
.member-status-cell {
[data-slot='badge'] {
padding: var(--padding-1) var(--padding-2);
align-items: center;
font-size: var(--uppercase-small-500-font-size);
font-weight: var(--uppercase-small-500-font-weight);
line-height: 100%;
letter-spacing: 0.44px;
text-transform: uppercase;
}
}
}
.member-name-email-cell {
display: flex;

View File

@@ -55,14 +55,14 @@ function NameEmailCell({
function StatusBadge({ status }: { status: MemberRow['status'] }): JSX.Element {
if (status === MemberStatus.Active) {
return (
<Badge color="forest" variant="outline">
<Badge color="success" variant="outlined">
ACTIVE
</Badge>
);
}
if (status === MemberStatus.Deleted) {
return (
<Badge color="cherry" variant="outline">
<Badge color="danger" variant="outlined">
DELETED
</Badge>
);
@@ -70,13 +70,17 @@ function StatusBadge({ status }: { status: MemberRow['status'] }): JSX.Element {
if (status === MemberStatus.Invited) {
return (
<Badge color="amber" variant="outline">
<Badge color="warning" variant="outlined">
INVITED
</Badge>
);
}
return <Badge color="vanilla">⎯</Badge>;
return (
<Badge variant="solid" color="secondary">
⎯
</Badge>
);
}
function MembersEmptyState({

View File

@@ -20,7 +20,7 @@ import {
import { Color } from '@signozhq/design-tokens';
import { Button, Select } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
import { TooltipProvider, TooltipSimple } from '@signozhq/ui/tooltip';
import { Tooltip } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import TextToolTip from 'components/TextToolTip/TextToolTip';
@@ -758,9 +758,14 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
tabIndex={isActive ? 0 : -1}
>
<Checkbox
color="primary"
value={isSelected}
className="option-checkbox"
onClick={(e): void => selectFromButton(e, 'checkbox')}
width="100%"
onChange={(): void => {
handleItemSelection('checkbox');
setActiveChipIndex(-1);
setActiveIndex(-1);
}}
>
<div className="option-content">
<Typography.Text truncate={1} className="option-label-text">
@@ -1595,7 +1600,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
}}
>
<div style={{ display: 'flex', alignItems: 'center', width: '100%' }}>
<Checkbox value={allOptionsSelected} className="option-checkbox">
<Checkbox color="primary" value={allOptionsSelected} width="100%">
<div className="option-content">
<div className="all-option-text">ALL</div>
</div>
@@ -1973,13 +1978,9 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
// `label` arrives already cut to maxTagTextLength, so the reveal reads the
// option's own text (falling back to the raw value for freeform tags).
return (
<TooltipSimple
side="top"
delayDuration={300}
title={findOptionLabelText(options, value)}
>
<Tooltip side="top" title={findOptionLabelText(options, value)}>
{tag}
</TooltipSimple>
</Tooltip>
);
}
@@ -2015,56 +2016,51 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
return (
// Self-provided so the per-tag tooltips work wherever this select is rendered,
// without every consumer having to sit under an app-level provider.
<TooltipProvider>
<div
className={cx('custom-multiselect-wrapper', {
'all-selected': allOptionShown || isAllSelected,
<div
className={cx('custom-multiselect-wrapper', {
'all-selected': allOptionShown || isAllSelected,
})}
>
{(allOptionShown || isAllSelected) && !searchText && (
<div className="all-text">ALL</div>
)}
<Select
ref={selectRef}
className={cx('custom-multiselect', className, {
'has-selection': selectedChips.length > 0 && !isAllSelected,
'is-all-selected': isAllSelected,
})}
>
{(allOptionShown || isAllSelected) && !searchText && (
<div className="all-text">ALL</div>
)}
<Select
ref={selectRef}
className={cx('custom-multiselect', className, {
'has-selection': selectedChips.length > 0 && !isAllSelected,
'is-all-selected': isAllSelected,
})}
placeholder={placeholder}
mode="multiple"
showSearch
filterOption={false}
onSearch={handleSearch}
value={displayValue}
onChange={(newValue): void => {
handleInternalChange(newValue, false);
}}
onClear={onClearHandler}
onDropdownVisibleChange={handleDropdownVisibleChange}
open={isOpen}
defaultActiveFirstOption={defaultActiveFirstOption}
popupMatchSelectWidth={dropdownMatchSelectWidth}
allowClear={allowClear}
getPopupContainer={getPopupContainer ?? popupContainer}
suffixIcon={<ChevronDown style={{ cursor: 'default' }} size="md" />}
dropdownRender={customDropdownRender}
menuItemSelectedIcon={null}
popupClassName={cx(
'custom-multiselect-dropdown-container',
popupClassName,
)}
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
onKeyDown={handleKeyDown}
tagRender={tagRender as any}
placement={placement}
listHeight={300}
searchValue={searchText}
maxTagTextLength={maxTagTextLength}
maxTagCount={isAllSelected ? undefined : maxTagCount}
{...rest}
/>
</div>
</TooltipProvider>
placeholder={placeholder}
mode="multiple"
showSearch
filterOption={false}
onSearch={handleSearch}
value={displayValue}
onChange={(newValue): void => {
handleInternalChange(newValue, false);
}}
onClear={onClearHandler}
onDropdownVisibleChange={handleDropdownVisibleChange}
open={isOpen}
defaultActiveFirstOption={defaultActiveFirstOption}
popupMatchSelectWidth={dropdownMatchSelectWidth}
allowClear={allowClear}
getPopupContainer={getPopupContainer ?? popupContainer}
suffixIcon={<ChevronDown style={{ cursor: 'default' }} size="md" />}
dropdownRender={customDropdownRender}
menuItemSelectedIcon={null}
popupClassName={cx('custom-multiselect-dropdown-container', popupClassName)}
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
onKeyDown={handleKeyDown}
tagRender={tagRender as any}
placement={placement}
listHeight={300}
searchValue={searchText}
maxTagTextLength={maxTagTextLength}
maxTagCount={isAllSelected ? undefined : maxTagCount}
{...rest}
/>
</div>
);
};

View File

@@ -1,6 +1,5 @@
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import CustomMultiSelect from '../CustomMultiSelect';
@@ -14,15 +13,13 @@ const SELECTED = ['checkout-service-prod', 'payments-service-prod'];
function renderSelect(): void {
render(
<TooltipProvider>
<CustomMultiSelect
options={OPTIONS}
value={SELECTED}
maxTagCount={1}
maxTagTextLength={10}
maxTagPlaceholder={(omitted): string => `+${omitted.length}`}
/>
</TooltipProvider>,
<CustomMultiSelect
options={OPTIONS}
value={SELECTED}
maxTagCount={1}
maxTagTextLength={10}
maxTagPlaceholder={(omitted): string => `+${omitted.length}`}
/>,
);
}

View File

@@ -498,114 +498,95 @@ $custom-border-color: #2c3044;
margin-bottom: 8px;
}
.option-checkbox {
.all-option-text {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
cursor: default;
}
// The checkbox button is the only pointer target on the left; the label
// still toggles on click but keeps a default cursor.
> button {
cursor: pointer;
}
.option-content {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
// @signozhq/ui Checkbox renders children inside a <label> that is
// content-sized by default. Make it fill the row (min-width: 0 lets it
// shrink) so the option text below can truncate instead of overflowing.
> label {
.option-label-text {
flex: 1 1 auto;
min-width: 0;
margin-bottom: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.all-option-text {
.option-badge {
font-size: 12px;
padding: 2px 6px;
border-radius: 4px;
background-color: $custom-border-color;
color: var(--l2-foreground);
margin-left: 8px;
}
}
// "Only"/"All" is the primary action — a filled pill that reads as a
// button; "Toggle" is a secondary hint in plain text. Sized to the row's
// resting height so revealing them on hover never shifts it.
.only-btn,
.toggle-btn {
display: none;
align-items: center;
justify-content: center;
height: 18px;
min-height: 0;
font-size: 12px;
line-height: 1;
box-shadow: none;
}
.only-btn {
padding: 4px 8px;
// Black interior + a visible border so the pill stands out clearly
// against the near-black row when revealed on hover.
border: 1px solid var(--l3-border);
border-radius: 3px;
background-color: var(--bg-ink-500, #0b0c0e);
color: var(--l1-foreground);
cursor: pointer;
}
.toggle-btn {
padding: 0 6px;
border: none;
background-color: transparent;
color: var(--l2-foreground);
cursor: pointer;
}
// Toggle appears over the checkbox area; "Only/All" takes over the row
// content and hides Toggle there (higher specificity wins).
&:hover {
.toggle-btn {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.option-content {
.option-badge {
display: none;
}
}
.option-content:hover {
.only-btn {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
.option-label-text {
flex: 1 1 auto;
min-width: 0;
margin-bottom: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.option-badge {
font-size: 12px;
padding: 2px 6px;
border-radius: 4px;
background-color: $custom-border-color;
color: var(--l2-foreground);
margin-left: 8px;
}
}
// "Only"/"All" is the primary action — a filled pill that reads as a
// button; "Toggle" is a secondary hint in plain text. Sized to the row's
// resting height so revealing them on hover never shifts it.
.only-btn,
.toggle-btn {
display: none;
align-items: center;
justify-content: center;
height: 18px;
min-height: 0;
font-size: 12px;
line-height: 1;
box-shadow: none;
}
.only-btn {
padding: 4px 8px;
// Black interior + a visible border so the pill stands out clearly
// against the near-black row when revealed on hover.
border: 1px solid var(--l3-border);
border-radius: 3px;
background-color: var(--bg-ink-500, #0b0c0e);
color: var(--l1-foreground);
cursor: pointer;
}
.toggle-btn {
padding: 0 6px;
border: none;
background-color: transparent;
color: var(--l2-foreground);
cursor: pointer;
}
// Toggle appears over the checkbox area; "Only/All" takes over the row
// content and hides Toggle there (higher specificity wins).
&:hover {
.toggle-btn {
display: flex;
}
.option-badge {
display: none;
}
}
.option-content:hover {
.only-btn {
display: flex;
}
.toggle-btn {
display: none;
}
.option-badge {
display: none;
}
.option-badge {
display: none;
}
}
}

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