mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-10 16:40:34 +01:00
Compare commits
13 Commits
issue_5601
...
issue_5552
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b7f1cb654 | ||
|
|
ca5d888970 | ||
|
|
76b4d249e2 | ||
|
|
ef892f3361 | ||
|
|
9c4046d84a | ||
|
|
a91cee2b95 | ||
|
|
51036d6cc4 | ||
|
|
f7bfd0eba6 | ||
|
|
da250bfd3e | ||
|
|
67007e2902 | ||
|
|
ff3ec133b4 | ||
|
|
0ea5399c15 | ||
|
|
f7aa3cee9c |
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -53,7 +53,6 @@ jobs:
|
||||
- queriermetrics
|
||||
- querierscalar
|
||||
- queriercommon
|
||||
- querierai
|
||||
- rawexportdata
|
||||
- role
|
||||
- rootuser
|
||||
|
||||
@@ -129,7 +129,7 @@ sqlstore:
|
||||
# The timeout for the sqlite database to wait for a lock.
|
||||
busy_timeout: 10s
|
||||
# The default transaction locking behavior. Supported values: deferred, immediate, exclusive.
|
||||
transaction_mode: deferred
|
||||
transaction_mode: immediate
|
||||
|
||||
##################### APIServer #####################
|
||||
apiserver:
|
||||
|
||||
@@ -4189,6 +4189,7 @@ components:
|
||||
- namespaces
|
||||
- clusters
|
||||
- volumes
|
||||
- kube_containers
|
||||
type: string
|
||||
InframonitoringtypesChecks:
|
||||
properties:
|
||||
@@ -4294,6 +4295,158 @@ components:
|
||||
- total
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesContainerCountsByReady:
|
||||
properties:
|
||||
notReady:
|
||||
type: integer
|
||||
ready:
|
||||
type: integer
|
||||
required:
|
||||
- ready
|
||||
- notReady
|
||||
type: object
|
||||
InframonitoringtypesContainerCountsByStatus:
|
||||
properties:
|
||||
completed:
|
||||
type: integer
|
||||
containerCannotRun:
|
||||
type: integer
|
||||
containerCreating:
|
||||
type: integer
|
||||
crashLoopBackOff:
|
||||
type: integer
|
||||
createContainerConfigError:
|
||||
type: integer
|
||||
errImagePull:
|
||||
type: integer
|
||||
error:
|
||||
type: integer
|
||||
imagePullBackOff:
|
||||
type: integer
|
||||
oomKilled:
|
||||
type: integer
|
||||
running:
|
||||
type: integer
|
||||
terminated:
|
||||
type: integer
|
||||
unknown:
|
||||
type: integer
|
||||
waiting:
|
||||
type: integer
|
||||
required:
|
||||
- running
|
||||
- waiting
|
||||
- terminated
|
||||
- crashLoopBackOff
|
||||
- imagePullBackOff
|
||||
- errImagePull
|
||||
- createContainerConfigError
|
||||
- containerCreating
|
||||
- oomKilled
|
||||
- completed
|
||||
- error
|
||||
- containerCannotRun
|
||||
- unknown
|
||||
type: object
|
||||
InframonitoringtypesContainerReady:
|
||||
enum:
|
||||
- ready
|
||||
- not_ready
|
||||
- no_data
|
||||
type: string
|
||||
InframonitoringtypesContainerRecord:
|
||||
properties:
|
||||
containerCountsByReady:
|
||||
$ref: '#/components/schemas/InframonitoringtypesContainerCountsByReady'
|
||||
containerCountsByStatus:
|
||||
$ref: '#/components/schemas/InframonitoringtypesContainerCountsByStatus'
|
||||
containerName:
|
||||
type: string
|
||||
cpu:
|
||||
format: double
|
||||
type: number
|
||||
cpuLimitUtilization:
|
||||
format: double
|
||||
type: number
|
||||
cpuRequestUtilization:
|
||||
format: double
|
||||
type: number
|
||||
memory:
|
||||
format: double
|
||||
type: number
|
||||
memoryLimitUtilization:
|
||||
format: double
|
||||
type: number
|
||||
memoryRequestUtilization:
|
||||
format: double
|
||||
type: number
|
||||
meta:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
podUID:
|
||||
type: string
|
||||
ready:
|
||||
$ref: '#/components/schemas/InframonitoringtypesContainerReady'
|
||||
restarts:
|
||||
format: int64
|
||||
type: integer
|
||||
status:
|
||||
$ref: '#/components/schemas/InframonitoringtypesContainerStatus'
|
||||
required:
|
||||
- podUID
|
||||
- containerName
|
||||
- status
|
||||
- containerCountsByStatus
|
||||
- ready
|
||||
- containerCountsByReady
|
||||
- restarts
|
||||
- cpu
|
||||
- cpuRequestUtilization
|
||||
- cpuLimitUtilization
|
||||
- memory
|
||||
- memoryRequestUtilization
|
||||
- memoryLimitUtilization
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesContainerStatus:
|
||||
enum:
|
||||
- running
|
||||
- waiting
|
||||
- terminated
|
||||
- crashloopbackoff
|
||||
- imagepullbackoff
|
||||
- errimagepull
|
||||
- createcontainerconfigerror
|
||||
- containercreating
|
||||
- oomkilled
|
||||
- completed
|
||||
- error
|
||||
- containercannotrun
|
||||
- unknown
|
||||
- no_data
|
||||
type: string
|
||||
InframonitoringtypesContainers:
|
||||
properties:
|
||||
endTimeBeforeRetention:
|
||||
type: boolean
|
||||
records:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesContainerRecord'
|
||||
type: array
|
||||
total:
|
||||
type: integer
|
||||
type:
|
||||
$ref: '#/components/schemas/InframonitoringtypesResponseType'
|
||||
warning:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryWarnData'
|
||||
required:
|
||||
- type
|
||||
- records
|
||||
- total
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesDaemonSetRecord:
|
||||
properties:
|
||||
currentNodes:
|
||||
@@ -4968,6 +5121,32 @@ components:
|
||||
- end
|
||||
- limit
|
||||
type: object
|
||||
InframonitoringtypesPostableContainers:
|
||||
properties:
|
||||
end:
|
||||
format: int64
|
||||
type: integer
|
||||
filter:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Filter'
|
||||
groupBy:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
|
||||
nullable: true
|
||||
type: array
|
||||
limit:
|
||||
type: integer
|
||||
offset:
|
||||
type: integer
|
||||
orderBy:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
|
||||
start:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- start
|
||||
- end
|
||||
- limit
|
||||
type: object
|
||||
InframonitoringtypesPostableDaemonSets:
|
||||
properties:
|
||||
end:
|
||||
@@ -7215,9 +7394,11 @@ components:
|
||||
op:
|
||||
$ref: '#/components/schemas/RuletypesCompareOperator'
|
||||
recoveryTarget:
|
||||
format: double
|
||||
nullable: true
|
||||
type: number
|
||||
target:
|
||||
format: double
|
||||
nullable: true
|
||||
type: number
|
||||
targetUnit:
|
||||
@@ -7501,6 +7682,7 @@ components:
|
||||
selectedQueryName:
|
||||
type: string
|
||||
target:
|
||||
format: double
|
||||
nullable: true
|
||||
type: number
|
||||
targetUnit:
|
||||
@@ -8312,7 +8494,6 @@ components:
|
||||
TelemetrytypesSource:
|
||||
enum:
|
||||
- meter
|
||||
- ai
|
||||
type: string
|
||||
TelemetrytypesTelemetryFieldKey:
|
||||
properties:
|
||||
@@ -16100,6 +16281,86 @@ paths:
|
||||
summary: List Jobs for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
/api/v2/infra_monitoring/kube_containers:
|
||||
post:
|
||||
deprecated: false
|
||||
description: 'Returns a paginated list of Kubernetes containers with key kubeletstats
|
||||
metrics: CPU usage (cores), CPU request/limit utilization, memory working
|
||||
set, and memory request/limit utilization. Each container also reports health
|
||||
signals from the k8s_cluster receiver: status (kubectl-style display status
|
||||
derived from k8s.container.status.state + k8s.container.status.reason), restarts
|
||||
(absolute count from k8s.container.restarts), and ready (ready/not_ready from
|
||||
k8s.container.ready). The row identity is (k8s.pod.uid, k8s.container.name),
|
||||
stable across container restarts. Each container includes metadata attributes
|
||||
(k8s.container.name, k8s.pod.name, container.image.name, container.image.tag,
|
||||
k8s.namespace.name, k8s.node.name, k8s.cluster.name, and workload owner such
|
||||
as deployment/statefulset/daemonset/job). The response type is ''list'' for
|
||||
the default (k8s.pod.uid, k8s.container.name) grouping (each row is one container
|
||||
with its current status and ready state) or ''grouped_list'' for custom groupBy
|
||||
keys (each row aggregates containers in the group with per-status counts under
|
||||
containerCountsByStatus, per-readiness counts under containerCountsByReady,
|
||||
and restarts as the group sum). Status requires the optional k8s.container.status.state
|
||||
and k8s.container.status.reason metrics; when either is missing, status is
|
||||
omitted and a warning is returned while restarts and ready are still computed.
|
||||
Supports filtering via a filter expression, custom groupBy, ordering by any
|
||||
of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit),
|
||||
and pagination via offset/limit. Also reports whether the requested time range
|
||||
falls before the data retention boundary. Numeric metric fields (cpu, cpuRequestUtilization,
|
||||
cpuLimitUtilization, memory, memoryRequestUtilization, memoryLimitUtilization)
|
||||
and restarts return -1 as a sentinel when no data is available for that field.'
|
||||
operationId: ListContainers
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPostableContainers'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/InframonitoringtypesContainers'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List Kubernetes Containers for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
/api/v2/infra_monitoring/namespaces:
|
||||
post:
|
||||
deprecated: false
|
||||
@@ -17910,6 +18171,16 @@ paths:
|
||||
public dashboard. The panel is addressed by its key in spec.panels.
|
||||
operationId: GetPublicDashboardPanelQueryRangeV2
|
||||
parameters:
|
||||
- in: query
|
||||
name: startTime
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: endTime
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
"crypto-js": "4.2.0",
|
||||
"d3-hierarchy": "3.1.2",
|
||||
"dayjs": "^1.10.7",
|
||||
"dompurify": "3.4.0",
|
||||
"dompurify": "3.4.11",
|
||||
"event-source-polyfill": "1.0.31",
|
||||
"eventemitter3": "5.0.1",
|
||||
"history": "4.10.1",
|
||||
@@ -238,10 +238,18 @@
|
||||
"prismjs": "1.30.0",
|
||||
"got": "11.8.5",
|
||||
"form-data": "4.0.6",
|
||||
"brace-expansion": "^2.0.2",
|
||||
"brace-expansion": "^2.0.3",
|
||||
"on-headers": "^1.1.0",
|
||||
"js-cookie": "^3.0.7",
|
||||
"tmp": "0.2.7",
|
||||
"vite": "npm:rolldown-vite@7.3.1"
|
||||
"vite": "npm:rolldown-vite@7.3.1",
|
||||
"dompurify": "3.4.11",
|
||||
"js-yaml@3": "3.15.0",
|
||||
"js-yaml@4": "4.2.0",
|
||||
"yaml@1": "1.10.3",
|
||||
"react-router@6": "6.30.4",
|
||||
"markdown-it": "14.2.0",
|
||||
"mdast-util-to-hast@13": "13.2.1",
|
||||
"protocol-buffers-schema": "3.6.1"
|
||||
}
|
||||
}
|
||||
152
frontend/pnpm-lock.yaml
generated
152
frontend/pnpm-lock.yaml
generated
@@ -19,11 +19,19 @@ overrides:
|
||||
prismjs: 1.30.0
|
||||
got: 11.8.5
|
||||
form-data: 4.0.6
|
||||
brace-expansion: ^2.0.2
|
||||
brace-expansion: ^2.0.3
|
||||
on-headers: ^1.1.0
|
||||
js-cookie: ^3.0.7
|
||||
tmp: 0.2.7
|
||||
vite: npm:rolldown-vite@7.3.1
|
||||
dompurify: 3.4.11
|
||||
js-yaml@3: 3.15.0
|
||||
js-yaml@4: 4.2.0
|
||||
yaml@1: 1.10.3
|
||||
react-router@6: 6.30.4
|
||||
markdown-it: 14.2.0
|
||||
mdast-util-to-hast@13: 13.2.1
|
||||
protocol-buffers-schema: 3.6.1
|
||||
|
||||
importers:
|
||||
|
||||
@@ -79,7 +87,7 @@ importers:
|
||||
version: 0.0.2(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@signozhq/ui':
|
||||
specifier: 0.0.23
|
||||
version: 0.0.23(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)
|
||||
version: 0.0.23(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.4(react@18.2.0))(react@18.2.0)
|
||||
'@tanstack/react-table':
|
||||
specifier: 8.21.3
|
||||
version: 8.21.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
@@ -153,8 +161,8 @@ importers:
|
||||
specifier: ^1.10.7
|
||||
version: 1.11.20
|
||||
dompurify:
|
||||
specifier: 3.4.0
|
||||
version: 3.4.0
|
||||
specifier: 3.4.11
|
||||
version: 3.4.11
|
||||
event-source-polyfill:
|
||||
specifier: 1.0.31
|
||||
version: 1.0.31
|
||||
@@ -196,7 +204,7 @@ importers:
|
||||
version: 12.4.13(@emotion/is-prop-valid@1.2.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
nuqs:
|
||||
specifier: 2.8.8
|
||||
version: 2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)
|
||||
version: 2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.4(react@18.2.0))(react@18.2.0)
|
||||
overlayscrollbars:
|
||||
specifier: ^2.8.1
|
||||
version: 2.9.2
|
||||
@@ -3040,6 +3048,10 @@ packages:
|
||||
resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@remix-run/router@1.23.3':
|
||||
resolution: {integrity: sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.0-beta.53':
|
||||
resolution: {integrity: sha512-Ok9V8o7o6YfSdTTYA/uHH30r3YtOxLD6G3wih/U9DO0ucBBFq8WPt/DslU53OgfteLRHITZny9N/qCUxMf9kjQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -4143,8 +4155,8 @@ packages:
|
||||
boolbase@1.0.0:
|
||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||
|
||||
brace-expansion@2.0.2:
|
||||
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
|
||||
brace-expansion@2.1.1:
|
||||
resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==}
|
||||
|
||||
braces@3.0.3:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
@@ -4805,14 +4817,8 @@ packages:
|
||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
dompurify@3.2.4:
|
||||
resolution: {integrity: sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==}
|
||||
|
||||
dompurify@3.2.7:
|
||||
resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==}
|
||||
|
||||
dompurify@3.4.0:
|
||||
resolution: {integrity: sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==}
|
||||
dompurify@3.4.11:
|
||||
resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==}
|
||||
|
||||
domutils@2.8.0:
|
||||
resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==}
|
||||
@@ -6055,12 +6061,12 @@ packages:
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
js-yaml@3.14.1:
|
||||
resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==}
|
||||
js-yaml@3.15.0:
|
||||
resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==}
|
||||
hasBin: true
|
||||
|
||||
js-yaml@4.1.1:
|
||||
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
||||
js-yaml@4.2.0:
|
||||
resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==}
|
||||
hasBin: true
|
||||
|
||||
jsdom@20.0.3:
|
||||
@@ -6244,8 +6250,8 @@ packages:
|
||||
lines-and-columns@1.2.4:
|
||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||
|
||||
linkify-it@5.0.0:
|
||||
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
|
||||
linkify-it@5.0.2:
|
||||
resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==}
|
||||
|
||||
lint-staged@17.0.4:
|
||||
resolution: {integrity: sha512-+rU9lSUyVOZ/hDUmRLVGzyS2v73cDdQjX+XQz1AaOdIE4RysLq0HoPW2HrrgeNCLklkhi904VBU1bmgWLHVnkA==}
|
||||
@@ -6364,8 +6370,8 @@ packages:
|
||||
mapbox-to-css-font@2.4.5:
|
||||
resolution: {integrity: sha512-VJ6nB8emkO9VODI0Fk+TQ/0zKBTqmf/Pkt8Xv0kHstoc0iXRajA00DAid4Kc3K5xeFIOoiZrVxijEzj0GLVO2w==}
|
||||
|
||||
markdown-it@14.1.1:
|
||||
resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==}
|
||||
markdown-it@14.2.0:
|
||||
resolution: {integrity: sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==}
|
||||
hasBin: true
|
||||
|
||||
markdown-table@3.0.4:
|
||||
@@ -6429,8 +6435,8 @@ packages:
|
||||
mdast-util-to-hast@12.3.0:
|
||||
resolution: {integrity: sha512-pits93r8PhnIoU4Vy9bjW39M2jJ6/tdHyja9rrot9uujkN7UTU9SDnE6WNJz/IGyQk3XHX6yNNtrBH6cQzm8Hw==}
|
||||
|
||||
mdast-util-to-hast@13.1.0:
|
||||
resolution: {integrity: sha512-/e2l/6+OdGp/FB+ctrJ9Avz71AN/GRH3oi/3KAx/kMnoUsD6q0woXlDT8lLEeViVKE7oZxE7RXzvO3T8kF2/sA==}
|
||||
mdast-util-to-hast@13.2.1:
|
||||
resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
|
||||
|
||||
mdast-util-to-markdown@1.5.0:
|
||||
resolution: {integrity: sha512-bbv7TPv/WC49thZPg3jXuqzuvI45IL2EVAr/KxF0BSdHsU0ceFHOmwQn6evxAh1GaoK/6GQ1wp4R4oW2+LFL/A==}
|
||||
@@ -6772,7 +6778,7 @@ packages:
|
||||
'@tanstack/react-router': ^1
|
||||
next: '>=14.2.0'
|
||||
react: '>=18.2.0 || ^19.0.0-0'
|
||||
react-router: ^5 || ^6 || ^7
|
||||
react-router: 6.30.4
|
||||
react-router-dom: ^5 || ^6 || ^7
|
||||
peerDependenciesMeta:
|
||||
'@remix-run/react':
|
||||
@@ -6793,7 +6799,7 @@ packages:
|
||||
'@tanstack/react-router': ^1
|
||||
next: '>=14.2.0'
|
||||
react: '>=18.2.0 || ^19.0.0-0'
|
||||
react-router: ^5 || ^6 || ^7
|
||||
react-router: 6.30.4
|
||||
react-router-dom: ^5 || ^6 || ^7
|
||||
peerDependenciesMeta:
|
||||
'@remix-run/react':
|
||||
@@ -7174,8 +7180,8 @@ packages:
|
||||
property-information@6.3.0:
|
||||
resolution: {integrity: sha512-gVNZ74nqhRMiIUYWGQdosYetaKc83x8oT41a0LlV3AAFCAZwCpg4vmGkq8t34+cUhp3cnM4XDiU/7xlgK7HGrg==}
|
||||
|
||||
protocol-buffers-schema@3.6.0:
|
||||
resolution: {integrity: sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==}
|
||||
protocol-buffers-schema@3.6.1:
|
||||
resolution: {integrity: sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==}
|
||||
|
||||
proxy-from-env@1.1.0:
|
||||
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
||||
@@ -7690,8 +7696,8 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>=15'
|
||||
|
||||
react-router@6.30.3:
|
||||
resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==}
|
||||
react-router@6.30.4:
|
||||
resolution: {integrity: sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=16.8'
|
||||
@@ -8946,8 +8952,8 @@ packages:
|
||||
yallist@4.0.0:
|
||||
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
|
||||
|
||||
yaml@1.10.2:
|
||||
resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
|
||||
yaml@1.10.3:
|
||||
resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
yaml@2.8.2:
|
||||
@@ -9068,7 +9074,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@jsdevtools/ono': 7.1.3
|
||||
'@types/json-schema': 7.0.15
|
||||
js-yaml: 4.1.1
|
||||
js-yaml: 4.2.0
|
||||
|
||||
'@babel/code-frame@7.29.0':
|
||||
dependencies:
|
||||
@@ -10327,7 +10333,7 @@ snapshots:
|
||||
'@types/string-hash': 1.1.3
|
||||
d3-interpolate: 3.0.1
|
||||
date-fns: 4.1.0
|
||||
dompurify: 3.2.4
|
||||
dompurify: 3.4.11
|
||||
eventemitter3: 5.0.1
|
||||
fast_array_intersect: 1.1.0
|
||||
history: 4.10.1
|
||||
@@ -10478,7 +10484,7 @@ snapshots:
|
||||
camelcase: 5.3.1
|
||||
find-up: 4.1.0
|
||||
get-package-type: 0.1.0
|
||||
js-yaml: 3.14.1
|
||||
js-yaml: 3.15.0
|
||||
resolve-from: 5.0.0
|
||||
|
||||
'@istanbuljs/schema@0.1.3': {}
|
||||
@@ -11805,6 +11811,8 @@ snapshots:
|
||||
|
||||
'@remix-run/router@1.23.2': {}
|
||||
|
||||
'@remix-run/router@1.23.3': {}
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.0-beta.53':
|
||||
optional: true
|
||||
|
||||
@@ -12042,7 +12050,7 @@ snapshots:
|
||||
- react-dom
|
||||
- tailwindcss
|
||||
|
||||
'@signozhq/ui@0.0.23(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)':
|
||||
'@signozhq/ui@0.0.23(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.4(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@chenglou/pretext': 0.0.5
|
||||
'@radix-ui/react-checkbox': 1.3.3(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
@@ -12071,7 +12079,7 @@ snapshots:
|
||||
lodash-es: 4.18.1
|
||||
motion: 11.18.2(@emotion/is-prop-valid@1.2.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
next-themes: 0.4.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
nuqs: 2.8.9(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)
|
||||
nuqs: 2.8.9(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.4(react@18.2.0))(react@18.2.0)
|
||||
react: 18.2.0
|
||||
react-day-picker: 9.9.0(react@18.2.0)
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
@@ -13057,7 +13065,7 @@ snapshots:
|
||||
|
||||
boolbase@1.0.0: {}
|
||||
|
||||
brace-expansion@2.0.2:
|
||||
brace-expansion@2.1.1:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
||||
@@ -13381,14 +13389,14 @@ snapshots:
|
||||
import-fresh: 3.3.1
|
||||
parse-json: 5.2.0
|
||||
path-type: 4.0.0
|
||||
yaml: 1.10.2
|
||||
yaml: 1.10.3
|
||||
optional: true
|
||||
|
||||
cosmiconfig@9.0.1(typescript@5.9.3):
|
||||
dependencies:
|
||||
env-paths: 2.2.1
|
||||
import-fresh: 3.3.1
|
||||
js-yaml: 4.1.1
|
||||
js-yaml: 4.2.0
|
||||
parse-json: 5.2.0
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
@@ -13720,15 +13728,7 @@ snapshots:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
||||
dompurify@3.2.4:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
dompurify@3.2.7:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
dompurify@3.4.0:
|
||||
dompurify@3.4.11:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
@@ -14416,7 +14416,7 @@ snapshots:
|
||||
hast-util-from-parse5: 8.0.1
|
||||
hast-util-to-parse5: 8.0.0
|
||||
html-void-elements: 3.0.0
|
||||
mdast-util-to-hast: 13.1.0
|
||||
mdast-util-to-hast: 13.2.1
|
||||
parse5: 7.3.0
|
||||
unist-util-position: 5.0.0
|
||||
unist-util-visit: 5.0.0
|
||||
@@ -15342,12 +15342,12 @@ snapshots:
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@3.14.1:
|
||||
js-yaml@3.15.0:
|
||||
dependencies:
|
||||
argparse: 1.0.10
|
||||
esprima: 4.0.1
|
||||
|
||||
js-yaml@4.1.1:
|
||||
js-yaml@4.2.0:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
@@ -15396,7 +15396,7 @@ snapshots:
|
||||
'@types/json-schema': 7.0.15
|
||||
'@types/lodash': 4.17.24
|
||||
is-glob: 4.0.3
|
||||
js-yaml: 4.1.1
|
||||
js-yaml: 4.2.0
|
||||
lodash: 4.18.1
|
||||
minimist: 1.2.8
|
||||
prettier: 3.8.3
|
||||
@@ -15533,7 +15533,7 @@ snapshots:
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
||||
linkify-it@5.0.0:
|
||||
linkify-it@5.0.2:
|
||||
dependencies:
|
||||
uc.micro: 2.1.0
|
||||
|
||||
@@ -15654,11 +15654,11 @@ snapshots:
|
||||
|
||||
mapbox-to-css-font@2.4.5: {}
|
||||
|
||||
markdown-it@14.1.1:
|
||||
markdown-it@14.2.0:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
entities: 4.5.0
|
||||
linkify-it: 5.0.0
|
||||
linkify-it: 5.0.2
|
||||
mdurl: 2.0.0
|
||||
punycode.js: 2.3.1
|
||||
uc.micro: 2.1.0
|
||||
@@ -15772,7 +15772,7 @@ snapshots:
|
||||
unist-util-position: 4.0.4
|
||||
unist-util-visit: 4.1.2
|
||||
|
||||
mdast-util-to-hast@13.1.0:
|
||||
mdast-util-to-hast@13.2.1:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/mdast': 4.0.3
|
||||
@@ -16049,19 +16049,19 @@ snapshots:
|
||||
|
||||
minimatch@10.2.5:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.2
|
||||
brace-expansion: 2.1.1
|
||||
|
||||
minimatch@3.1.5:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.2
|
||||
brace-expansion: 2.1.1
|
||||
|
||||
minimatch@5.1.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.2
|
||||
brace-expansion: 2.1.1
|
||||
|
||||
minimatch@9.0.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.2
|
||||
brace-expansion: 2.1.1
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
@@ -16075,7 +16075,7 @@ snapshots:
|
||||
|
||||
monaco-editor@0.55.1:
|
||||
dependencies:
|
||||
dompurify: 3.2.7
|
||||
dompurify: 3.4.11
|
||||
marked: 14.0.0
|
||||
|
||||
motion-dom@11.18.1:
|
||||
@@ -16216,20 +16216,20 @@ snapshots:
|
||||
dependencies:
|
||||
boolbase: 1.0.0
|
||||
|
||||
nuqs@2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0):
|
||||
nuqs@2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.4(react@18.2.0))(react@18.2.0):
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.0.0
|
||||
react: 18.2.0
|
||||
optionalDependencies:
|
||||
react-router: 6.30.3(react@18.2.0)
|
||||
react-router: 6.30.4(react@18.2.0)
|
||||
react-router-dom: 5.3.4(react@18.2.0)
|
||||
|
||||
nuqs@2.8.9(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0):
|
||||
nuqs@2.8.9(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.4(react@18.2.0))(react@18.2.0):
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.0.0
|
||||
react: 18.2.0
|
||||
optionalDependencies:
|
||||
react-router: 6.30.3(react@18.2.0)
|
||||
react-router: 6.30.4(react@18.2.0)
|
||||
react-router-dom: 5.3.4(react@18.2.0)
|
||||
|
||||
nwsapi@2.2.23: {}
|
||||
@@ -16336,7 +16336,7 @@ snapshots:
|
||||
find-up: 8.0.0
|
||||
fs-extra: 11.3.3
|
||||
jiti: 2.6.1
|
||||
js-yaml: 4.1.1
|
||||
js-yaml: 4.2.0
|
||||
remeda: 2.34.0
|
||||
string-argv: 0.3.2
|
||||
tsconfck: 3.1.6(typescript@5.9.3)
|
||||
@@ -16549,7 +16549,7 @@ snapshots:
|
||||
postcss-load-config@3.1.4(postcss@8.5.14)(ts-node@10.9.1(@types/node@16.18.25)(typescript@5.9.3)):
|
||||
dependencies:
|
||||
lilconfig: 2.1.0
|
||||
yaml: 1.10.2
|
||||
yaml: 1.10.3
|
||||
optionalDependencies:
|
||||
postcss: 8.5.14
|
||||
ts-node: 10.9.1(@types/node@16.18.25)(typescript@5.9.3)
|
||||
@@ -16654,7 +16654,7 @@ snapshots:
|
||||
|
||||
property-information@6.3.0: {}
|
||||
|
||||
protocol-buffers-schema@3.6.0: {}
|
||||
protocol-buffers-schema@3.6.1: {}
|
||||
|
||||
proxy-from-env@1.1.0: {}
|
||||
|
||||
@@ -17264,7 +17264,7 @@ snapshots:
|
||||
history: 5.3.0
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
react-router: 6.30.3(react@18.2.0)
|
||||
react-router: 6.30.4(react@18.2.0)
|
||||
react-router-dom: 5.3.4(react@18.2.0)
|
||||
|
||||
react-router-dom@5.3.4(react@18.2.0):
|
||||
@@ -17291,9 +17291,9 @@ snapshots:
|
||||
tiny-invariant: 1.3.3
|
||||
tiny-warning: 1.0.3
|
||||
|
||||
react-router@6.30.3(react@18.2.0):
|
||||
react-router@6.30.4(react@18.2.0):
|
||||
dependencies:
|
||||
'@remix-run/router': 1.23.2
|
||||
'@remix-run/router': 1.23.3
|
||||
react: 18.2.0
|
||||
|
||||
react-style-singleton@2.2.3(@types/react@18.0.26)(react@18.2.0):
|
||||
@@ -17517,7 +17517,7 @@ snapshots:
|
||||
|
||||
resolve-protobuf-schema@2.1.0:
|
||||
dependencies:
|
||||
protocol-buffers-schema: 3.6.0
|
||||
protocol-buffers-schema: 3.6.1
|
||||
|
||||
resolve@1.22.11:
|
||||
dependencies:
|
||||
@@ -18209,7 +18209,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@gerrit0/mini-shiki': 3.23.0
|
||||
lunr: 2.3.9
|
||||
markdown-it: 14.1.1
|
||||
markdown-it: 14.2.0
|
||||
minimatch: 10.2.5
|
||||
typescript: 5.9.3
|
||||
yaml: 2.8.4
|
||||
@@ -18673,7 +18673,7 @@ snapshots:
|
||||
|
||||
yallist@4.0.0: {}
|
||||
|
||||
yaml@1.10.2: {}
|
||||
yaml@1.10.3: {}
|
||||
|
||||
yaml@2.8.2: {}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ import type {
|
||||
GetPublicDashboardDataV2200,
|
||||
GetPublicDashboardDataV2PathParameters,
|
||||
GetPublicDashboardPanelQueryRangeV2200,
|
||||
GetPublicDashboardPanelQueryRangeV2Params,
|
||||
GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
GetPublicDashboardPathParameters,
|
||||
GetPublicDashboardWidgetQueryRange200,
|
||||
@@ -1912,20 +1913,25 @@ export const invalidateGetPublicDashboardDataV2 = async (
|
||||
*/
|
||||
export const getPublicDashboardPanelQueryRangeV2 = (
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
params?: GetPublicDashboardPanelQueryRangeV2Params,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetPublicDashboardPanelQueryRangeV2200>({
|
||||
url: `/api/v2/public/dashboards/${id}/panels/${key}/query_range`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetPublicDashboardPanelQueryRangeV2QueryKey = ({
|
||||
id,
|
||||
key,
|
||||
}: GetPublicDashboardPanelQueryRangeV2PathParameters) => {
|
||||
return [`/api/v2/public/dashboards/${id}/panels/${key}/query_range`] as const;
|
||||
export const getGetPublicDashboardPanelQueryRangeV2QueryKey = (
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
params?: GetPublicDashboardPanelQueryRangeV2Params,
|
||||
) => {
|
||||
return [
|
||||
`/api/v2/public/dashboards/${id}/panels/${key}/query_range`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getGetPublicDashboardPanelQueryRangeV2QueryOptions = <
|
||||
@@ -1933,6 +1939,7 @@ export const getGetPublicDashboardPanelQueryRangeV2QueryOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
params?: GetPublicDashboardPanelQueryRangeV2Params,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
|
||||
@@ -1945,11 +1952,12 @@ export const getGetPublicDashboardPanelQueryRangeV2QueryOptions = <
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ??
|
||||
getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key });
|
||||
getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key }, params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>
|
||||
> = ({ signal }) => getPublicDashboardPanelQueryRangeV2({ id, key }, signal);
|
||||
> = ({ signal }) =>
|
||||
getPublicDashboardPanelQueryRangeV2({ id, key }, params, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
@@ -1978,6 +1986,7 @@ export function useGetPublicDashboardPanelQueryRangeV2<
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
params?: GetPublicDashboardPanelQueryRangeV2Params,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
|
||||
@@ -1988,6 +1997,7 @@ export function useGetPublicDashboardPanelQueryRangeV2<
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetPublicDashboardPanelQueryRangeV2QueryOptions(
|
||||
{ id, key },
|
||||
params,
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -2004,10 +2014,16 @@ export function useGetPublicDashboardPanelQueryRangeV2<
|
||||
export const invalidateGetPublicDashboardPanelQueryRangeV2 = async (
|
||||
queryClient: QueryClient,
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
params?: GetPublicDashboardPanelQueryRangeV2Params,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key }) },
|
||||
{
|
||||
queryKey: getGetPublicDashboardPanelQueryRangeV2QueryKey(
|
||||
{ id, key },
|
||||
params,
|
||||
),
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
GetChecks200,
|
||||
GetChecksParams,
|
||||
InframonitoringtypesPostableClustersDTO,
|
||||
InframonitoringtypesPostableContainersDTO,
|
||||
InframonitoringtypesPostableDaemonSetsDTO,
|
||||
InframonitoringtypesPostableDeploymentsDTO,
|
||||
InframonitoringtypesPostableHostsDTO,
|
||||
@@ -31,6 +32,7 @@ import type {
|
||||
InframonitoringtypesPostableStatefulSetsDTO,
|
||||
InframonitoringtypesPostableVolumesDTO,
|
||||
ListClusters200,
|
||||
ListContainers200,
|
||||
ListDaemonSets200,
|
||||
ListDeployments200,
|
||||
ListHosts200,
|
||||
@@ -548,6 +550,89 @@ export const useListJobs = <
|
||||
> => {
|
||||
return useMutation(getListJobsMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes containers with key kubeletstats metrics: CPU usage (cores), CPU request/limit utilization, memory working set, and memory request/limit utilization. Each container also reports health signals from the k8s_cluster receiver: status (kubectl-style display status derived from k8s.container.status.state + k8s.container.status.reason), restarts (absolute count from k8s.container.restarts), and ready (ready/not_ready from k8s.container.ready). The row identity is (k8s.pod.uid, k8s.container.name), stable across container restarts. Each container includes metadata attributes (k8s.container.name, k8s.pod.name, container.image.name, container.image.tag, k8s.namespace.name, k8s.node.name, k8s.cluster.name, and workload owner such as deployment/statefulset/daemonset/job). The response type is 'list' for the default (k8s.pod.uid, k8s.container.name) grouping (each row is one container with its current status and ready state) or 'grouped_list' for custom groupBy keys (each row aggregates containers in the group with per-status counts under containerCountsByStatus, per-readiness counts under containerCountsByReady, and restarts as the group sum). Status requires the optional k8s.container.status.state and k8s.container.status.reason metrics; when either is missing, status is omitted and a warning is returned while restarts and ready are still computed. Supports filtering via a filter expression, custom groupBy, ordering by any of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (cpu, cpuRequestUtilization, cpuLimitUtilization, memory, memoryRequestUtilization, memoryLimitUtilization) and restarts return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Kubernetes Containers for Infra Monitoring
|
||||
*/
|
||||
export const listContainers = (
|
||||
inframonitoringtypesPostableContainersDTO?: BodyType<InframonitoringtypesPostableContainersDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListContainers200>({
|
||||
url: `/api/v2/infra_monitoring/kube_containers`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: inframonitoringtypesPostableContainersDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListContainersMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listContainers>>,
|
||||
TError,
|
||||
{ data?: BodyType<InframonitoringtypesPostableContainersDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listContainers>>,
|
||||
TError,
|
||||
{ data?: BodyType<InframonitoringtypesPostableContainersDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['listContainers'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof listContainers>>,
|
||||
{ data?: BodyType<InframonitoringtypesPostableContainersDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return listContainers(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type ListContainersMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listContainers>>
|
||||
>;
|
||||
export type ListContainersMutationBody =
|
||||
| BodyType<InframonitoringtypesPostableContainersDTO>
|
||||
| undefined;
|
||||
export type ListContainersMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List Kubernetes Containers for Infra Monitoring
|
||||
*/
|
||||
export const useListContainers = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listContainers>>,
|
||||
TError,
|
||||
{ data?: BodyType<InframonitoringtypesPostableContainersDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof listContainers>>,
|
||||
TError,
|
||||
{ data?: BodyType<InframonitoringtypesPostableContainersDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getListContainersMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes namespaces with key aggregated pod metrics: CPU usage and memory working set (summed across pods in the group), plus per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value in the window). Each namespace includes metadata attributes (k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.namespace.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / memory, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (namespaceCPU, namespaceMemory) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Namespaces for Infra Monitoring
|
||||
|
||||
@@ -3631,7 +3631,6 @@ export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQue
|
||||
}
|
||||
export enum TelemetrytypesSourceDTO {
|
||||
meter = 'meter',
|
||||
ai = 'ai',
|
||||
}
|
||||
export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTO {
|
||||
/**
|
||||
@@ -5578,6 +5577,7 @@ export enum InframonitoringtypesCheckTypeDTO {
|
||||
namespaces = 'namespaces',
|
||||
clusters = 'clusters',
|
||||
volumes = 'volumes',
|
||||
kube_containers = 'kube_containers',
|
||||
}
|
||||
export interface InframonitoringtypesMissingMetricsComponentEntryDTO {
|
||||
associatedComponent: InframonitoringtypesAssociatedComponentDTO;
|
||||
@@ -5857,6 +5857,174 @@ export interface InframonitoringtypesClustersDTO {
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesContainerCountsByReadyDTO {
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
notReady: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
ready: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesContainerCountsByStatusDTO {
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
completed: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
containerCannotRun: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
containerCreating: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
crashLoopBackOff: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
createContainerConfigError: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
errImagePull: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
error: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
imagePullBackOff: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
oomKilled: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
running: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
terminated: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
unknown: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
waiting: number;
|
||||
}
|
||||
|
||||
export enum InframonitoringtypesContainerReadyDTO {
|
||||
ready = 'ready',
|
||||
not_ready = 'not_ready',
|
||||
no_data = 'no_data',
|
||||
}
|
||||
export type InframonitoringtypesContainerRecordDTOMetaAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type InframonitoringtypesContainerRecordDTOMeta =
|
||||
InframonitoringtypesContainerRecordDTOMetaAnyOf | null;
|
||||
|
||||
export enum InframonitoringtypesContainerStatusDTO {
|
||||
running = 'running',
|
||||
waiting = 'waiting',
|
||||
terminated = 'terminated',
|
||||
crashloopbackoff = 'crashloopbackoff',
|
||||
imagepullbackoff = 'imagepullbackoff',
|
||||
errimagepull = 'errimagepull',
|
||||
createcontainerconfigerror = 'createcontainerconfigerror',
|
||||
containercreating = 'containercreating',
|
||||
oomkilled = 'oomkilled',
|
||||
completed = 'completed',
|
||||
error = 'error',
|
||||
containercannotrun = 'containercannotrun',
|
||||
unknown = 'unknown',
|
||||
no_data = 'no_data',
|
||||
}
|
||||
export interface InframonitoringtypesContainerRecordDTO {
|
||||
containerCountsByReady: InframonitoringtypesContainerCountsByReadyDTO;
|
||||
containerCountsByStatus: InframonitoringtypesContainerCountsByStatusDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
containerName: string;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
cpu: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
cpuLimitUtilization: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
cpuRequestUtilization: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
memory: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
memoryLimitUtilization: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
memoryRequestUtilization: number;
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
meta: InframonitoringtypesContainerRecordDTOMeta;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
podUID: string;
|
||||
ready: InframonitoringtypesContainerReadyDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
restarts: number;
|
||||
status: InframonitoringtypesContainerStatusDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesContainersDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
endTimeBeforeRetention: boolean;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
records: InframonitoringtypesContainerRecordDTO[];
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
total: number;
|
||||
type: InframonitoringtypesResponseTypeDTO;
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO;
|
||||
}
|
||||
|
||||
export type InframonitoringtypesDaemonSetRecordDTOMetaAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
@@ -6439,6 +6607,33 @@ export interface InframonitoringtypesPostableClustersDTO {
|
||||
start: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesPostableContainersDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
end: number;
|
||||
filter?: Querybuildertypesv5FilterDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
limit: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
offset?: number;
|
||||
orderBy?: Querybuildertypesv5OrderByDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
start: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesPostableDaemonSetsDTO {
|
||||
/**
|
||||
* @type integer
|
||||
@@ -8285,10 +8480,12 @@ export interface RuletypesBasicRuleThresholdDTO {
|
||||
op: RuletypesCompareOperatorDTO;
|
||||
/**
|
||||
* @type number,null
|
||||
* @format double
|
||||
*/
|
||||
recoveryTarget?: number | null;
|
||||
/**
|
||||
* @type number,null
|
||||
* @format double
|
||||
*/
|
||||
target: number | null;
|
||||
/**
|
||||
@@ -8482,6 +8679,7 @@ export interface RuletypesRuleConditionDTO {
|
||||
selectedQueryName?: string;
|
||||
/**
|
||||
* @type number,null
|
||||
* @format double
|
||||
*/
|
||||
target?: number | null;
|
||||
/**
|
||||
@@ -11042,6 +11240,14 @@ export type ListJobs200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListContainers200 = {
|
||||
data: InframonitoringtypesContainersDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListNamespaces200 = {
|
||||
data: InframonitoringtypesNamespacesDTO;
|
||||
/**
|
||||
@@ -11367,6 +11573,19 @@ export type GetPublicDashboardPanelQueryRangeV2PathParameters = {
|
||||
id: string;
|
||||
key: string;
|
||||
};
|
||||
export type GetPublicDashboardPanelQueryRangeV2Params = {
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
startTime?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
endTime?: string;
|
||||
};
|
||||
|
||||
export type GetPublicDashboardPanelQueryRangeV2200 = {
|
||||
data: Querybuildertypesv5QueryRangeResponseDTO;
|
||||
/**
|
||||
|
||||
@@ -65,7 +65,9 @@ function TagKeyValueInput({
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Enter') {
|
||||
// Plain Enter adds the tag; let Cmd/Ctrl+Enter pass through so a host form
|
||||
// (e.g. a modal) can submit on it.
|
||||
if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
}
|
||||
@@ -93,11 +95,17 @@ function TagKeyValueInput({
|
||||
};
|
||||
|
||||
const handleEditKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Enter') {
|
||||
// Plain Enter commits the edit; let Cmd/Ctrl+Enter pass through so a host
|
||||
// form (e.g. a modal) can submit on it.
|
||||
if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
commitEdit();
|
||||
} else if (e.key === 'Escape') {
|
||||
// Contain Escape so it cancels the inline edit instead of bubbling up and
|
||||
// closing the host drawer/modal.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
cancelEdit();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -31,12 +31,6 @@ export enum LOCALSTORAGE {
|
||||
METRICS_LIST_OPTIONS = 'METRICS_LIST_OPTIONS',
|
||||
SHOW_EXCEPTIONS_QUICK_FILTERS = 'SHOW_EXCEPTIONS_QUICK_FILTERS',
|
||||
QUICK_FILTERS_SETTINGS_ANNOUNCEMENT = 'QUICK_FILTERS_SETTINGS_ANNOUNCEMENT',
|
||||
QUICK_FILTERS_WIDTH_LOGS = 'QUICK_FILTERS_WIDTH_LOGS',
|
||||
QUICK_FILTERS_WIDTH_TRACES = 'QUICK_FILTERS_WIDTH_TRACES',
|
||||
QUICK_FILTERS_WIDTH_METER = 'QUICK_FILTERS_WIDTH_METER',
|
||||
QUICK_FILTERS_WIDTH_API_MONITORING = 'QUICK_FILTERS_WIDTH_API_MONITORING',
|
||||
QUICK_FILTERS_WIDTH_EXCEPTIONS = 'QUICK_FILTERS_WIDTH_EXCEPTIONS',
|
||||
QUICK_FILTERS_WIDTH_INFRA = 'QUICK_FILTERS_WIDTH_INFRA',
|
||||
FUNNEL_STEPS = 'FUNNEL_STEPS',
|
||||
SPAN_DETAILS_PINNED_ATTRIBUTES = 'SPAN_DETAILS_PINNED_ATTRIBUTES',
|
||||
LAST_USED_CUSTOM_TIME_RANGES = 'LAST_USED_CUSTOM_TIME_RANGES',
|
||||
|
||||
@@ -7,6 +7,7 @@ export const REACT_QUERY_KEY = {
|
||||
AUTO_REFRESH_QUERY: 'AUTO_REFRESH_QUERY',
|
||||
|
||||
GET_PUBLIC_DASHBOARD: 'GET_PUBLIC_DASHBOARD',
|
||||
GET_PUBLIC_DASHBOARD_RESOLVED: 'GET_PUBLIC_DASHBOARD_RESOLVED',
|
||||
GET_PUBLIC_DASHBOARD_META: 'GET_PUBLIC_DASHBOARD_META',
|
||||
GET_PUBLIC_DASHBOARD_WIDGET_DATA: 'GET_PUBLIC_DASHBOARD_WIDGET_DATA',
|
||||
GET_ALL_LICENCES: 'GET_ALL_LICENCES',
|
||||
|
||||
@@ -163,23 +163,12 @@
|
||||
}
|
||||
|
||||
&.filter-visible {
|
||||
// Width is owned by ResizableBox (inline style); this section is the
|
||||
// ResizableBox root, so it stays position: relative for the drag handle.
|
||||
.api-quick-filter-left-section {
|
||||
.resizable-box__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.quick-filters-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.api-module-right-section {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,49 +4,21 @@ import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
|
||||
import DomainList from './Domains/DomainList';
|
||||
|
||||
import './Explorer.styles.scss';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 260;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
function Explorer(): JSX.Element {
|
||||
useEffect(() => {
|
||||
logEvent('API Monitoring: Landing page visited', {});
|
||||
}, []);
|
||||
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_API_MONITORING,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div className={cx('api-monitoring-page', 'filter-visible')}>
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
className="api-quick-filter-left-section"
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<section className="api-quick-filter-left-section">
|
||||
<QuickFilters
|
||||
className="qf-api-monitoring"
|
||||
source={QuickFiltersSource.API_MONITORING}
|
||||
@@ -55,7 +27,7 @@ function Explorer(): JSX.Element {
|
||||
showQueryName={false}
|
||||
handleFilterVisibilityChange={(): void => {}}
|
||||
/>
|
||||
</ResizableBox>
|
||||
</section>
|
||||
<DomainList />
|
||||
</div>
|
||||
</Sentry.ErrorBoundary>
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
|
||||
&__title {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 14px;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
@@ -51,14 +51,14 @@
|
||||
|
||||
&__subtitle {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 14px;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
&__description {
|
||||
font-size: 14px;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--l2-foreground);
|
||||
line-height: 20px;
|
||||
}
|
||||
@@ -67,7 +67,7 @@
|
||||
margin: 0;
|
||||
margin-top: 8px;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 14px;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
@@ -106,7 +106,7 @@
|
||||
border: 1px dashed var(--l1-border);
|
||||
background: transparent;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 14px;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
@@ -120,15 +120,15 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
// Stack the message and the resources card; card matches the content
|
||||
// width above it and is capped so it doesn't sprawl in a wide panel.
|
||||
&__row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
max-width: 825px;
|
||||
gap: 25px;
|
||||
justify-content: center;
|
||||
margin-left: 21px;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
width: fit-content;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
&__content {
|
||||
@@ -142,7 +142,7 @@
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
width: 332px;
|
||||
width: 100%; // match the content width above
|
||||
}
|
||||
|
||||
&__resources-title {
|
||||
@@ -155,7 +155,6 @@
|
||||
text-transform: uppercase;
|
||||
padding: 16px 16px 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
height: 46px;
|
||||
}
|
||||
|
||||
&__resources-links {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.wrapper :global(button[data-color]) {
|
||||
--checkbox-checked-background: var(--series-color);
|
||||
--checkbox-border-color: var(--series-color);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
import { CheckBoxProps } from '../types';
|
||||
import styles from './CustomCheckBox.module.scss';
|
||||
|
||||
function CustomCheckBox({
|
||||
data,
|
||||
@@ -15,12 +16,11 @@ function CustomCheckBox({
|
||||
const isChecked = graphVisibilityState[index] || false;
|
||||
|
||||
const colorStyle = {
|
||||
'--checkbox-checked-background': color,
|
||||
'--checkbox-border-color': color,
|
||||
'--series-color': color,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<span style={colorStyle}>
|
||||
<span className={styles.wrapper} style={colorStyle}>
|
||||
<Checkbox
|
||||
onChange={(checked): void => checkBoxOnChangeHandler(checked, index)}
|
||||
value={isChecked}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useListDashboardsForUserV2 } from 'api/generated/services/dashboard';
|
||||
import {
|
||||
DashboardtypesListOrderDTO,
|
||||
DashboardtypesListSortDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
|
||||
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { ArrowRight, ArrowUpRight, Plus } from '@signozhq/icons';
|
||||
import Card from 'periscope/components/Card/Card';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
@@ -17,6 +22,13 @@ import dialsUrl from '@/assets/Icons/dials.svg';
|
||||
|
||||
import { getItemIcon } from '../constants';
|
||||
|
||||
// The five most-recent dashboards, normalised across the v1 and v2 list APIs.
|
||||
interface RecentDashboard {
|
||||
id: string;
|
||||
title: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export default function Dashboards({
|
||||
onUpdateChecklistDoneItem,
|
||||
loadingUserPreferences,
|
||||
@@ -26,33 +38,58 @@ export default function Dashboards({
|
||||
}): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { user } = useAppContext();
|
||||
const isDashboardV2 = useIsDashboardV2();
|
||||
|
||||
const [sortedDashboards, setSortedDashboards] = useState<Dashboard[]>([]);
|
||||
|
||||
// Fetch Dashboards
|
||||
// Fetch the recent dashboards from whichever API the `use_dashboard_v2` flag
|
||||
// selects; the inactive one stays disabled so it never fires.
|
||||
const {
|
||||
data: dashboardsList,
|
||||
isLoading: isDashboardListLoading,
|
||||
isError: isDashboardListError,
|
||||
} = useGetAllDashboard();
|
||||
data: v1List,
|
||||
isLoading: v1Loading,
|
||||
isError: v1Error,
|
||||
} = useGetAllDashboard({ enabled: !isDashboardV2 });
|
||||
|
||||
const {
|
||||
data: v2List,
|
||||
isLoading: v2Loading,
|
||||
isError: v2Error,
|
||||
} = useListDashboardsForUserV2(
|
||||
{
|
||||
sort: DashboardtypesListSortDTO.updated_at,
|
||||
order: DashboardtypesListOrderDTO.desc,
|
||||
limit: 5,
|
||||
offset: 0,
|
||||
},
|
||||
{ query: { enabled: isDashboardV2 } },
|
||||
);
|
||||
|
||||
const isDashboardListLoading = isDashboardV2 ? v2Loading : v1Loading;
|
||||
const isDashboardListError = isDashboardV2 ? v2Error : v1Error;
|
||||
|
||||
const sortedDashboards = useMemo<RecentDashboard[]>(() => {
|
||||
if (isDashboardV2) {
|
||||
return (v2List?.data?.dashboards ?? []).map((d) => ({
|
||||
id: d.id,
|
||||
title: d.spec?.display?.name ?? d.name,
|
||||
tags: (d.tags ?? []).map((t) => (t.value ? `${t.key}:${t.value}` : t.key)),
|
||||
}));
|
||||
}
|
||||
return [...(v1List?.data ?? [])]
|
||||
.sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
)
|
||||
.slice(0, 5)
|
||||
.map((d) => ({
|
||||
id: d.id,
|
||||
title: d.data.title,
|
||||
tags: d.data.tags ?? [],
|
||||
}));
|
||||
}, [isDashboardV2, v1List, v2List]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashboardsList) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sortedDashboards = dashboardsList.data.sort((a, b) => {
|
||||
const aUpdateAt = new Date(a.updatedAt).getTime();
|
||||
const bUpdateAt = new Date(b.updatedAt).getTime();
|
||||
return bUpdateAt - aUpdateAt;
|
||||
});
|
||||
|
||||
if (sortedDashboards.length > 0 && !loadingUserPreferences) {
|
||||
onUpdateChecklistDoneItem('SETUP_DASHBOARDS');
|
||||
}
|
||||
|
||||
setSortedDashboards(sortedDashboards.slice(0, 5));
|
||||
}, [dashboardsList, onUpdateChecklistDoneItem, loadingUserPreferences]);
|
||||
}, [sortedDashboards, onUpdateChecklistDoneItem, loadingUserPreferences]);
|
||||
|
||||
const emptyStateCard = (): JSX.Element => (
|
||||
<div className="empty-state-container">
|
||||
@@ -113,7 +150,7 @@ export default function Dashboards({
|
||||
event.stopPropagation();
|
||||
logEvent('Homepage: Dashboard clicked', {
|
||||
dashboardId: dashboard.id,
|
||||
dashboardName: dashboard.data.title,
|
||||
dashboardName: dashboard.title,
|
||||
});
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
openInNewTab(getLink());
|
||||
@@ -143,12 +180,12 @@ export default function Dashboards({
|
||||
/>
|
||||
|
||||
<div className="alert-rule-item-name home-data-item-name">
|
||||
{dashboard.data.title}
|
||||
{dashboard.title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="alert-rule-item-description home-data-item-tag">
|
||||
{dashboard.data.tags?.map((tag) => (
|
||||
{dashboard.tags.map((tag) => (
|
||||
<Badge color="sienna" variant="outline" key={tag}>
|
||||
{tag}
|
||||
</Badge>
|
||||
|
||||
@@ -6,7 +6,6 @@ import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import K8sBaseDetails from 'container/InfraMonitoringK8s/Base/K8sBaseDetails';
|
||||
import { K8sBaseList } from 'container/InfraMonitoringK8s/Base/K8sBaseList';
|
||||
import { K8sBaseFilters } from 'container/InfraMonitoringK8s/Base/types';
|
||||
@@ -17,8 +16,6 @@ import {
|
||||
} from 'container/InfraMonitoringK8s/hooks';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
@@ -43,21 +40,8 @@ import { getHostsQuickFiltersConfig } from './utils';
|
||||
import styles from './InfraMonitoringHosts.module.scss';
|
||||
import { ArrowUpToLine, Filter } from '@signozhq/icons';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
function Hosts(): JSX.Element {
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
const [, setCurrentPage] = useInfraMonitoringPageListing();
|
||||
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
|
||||
|
||||
@@ -155,18 +139,7 @@ function Hosts(): JSX.Element {
|
||||
<div className={styles.infraMonitoringContainer}>
|
||||
<div className={styles.infraContentRow}>
|
||||
{showFilters && (
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
className={styles.quickFiltersContainer}
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<div className={styles.quickFiltersContainer}>
|
||||
<div className={styles.quickFiltersContainerHeader}>
|
||||
<Typography.Text>Filters</Typography.Text>
|
||||
<Tooltip title="Collapse Filters">
|
||||
@@ -183,7 +156,7 @@ function Hosts(): JSX.Element {
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleQuickFiltersChange}
|
||||
/>
|
||||
</ResizableBox>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`${styles.listContainer}${
|
||||
|
||||
@@ -44,20 +44,26 @@
|
||||
}
|
||||
|
||||
.quickFiltersContainer {
|
||||
// Width is owned by ResizableBox (inline style). No overflow here: the panel
|
||||
// must not scroll (QuickFilters scrolls its own list internally) — an
|
||||
// overflow on the root would also clip the edge-mounted resize grip.
|
||||
flex-shrink: 0;
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
overflow-y: auto;
|
||||
|
||||
:global(.resizable-box__content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
height: 0.1rem;
|
||||
}
|
||||
|
||||
:global(.quick-filters-container) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header) {
|
||||
@@ -136,9 +142,7 @@
|
||||
}
|
||||
|
||||
.listContainerFiltersVisible {
|
||||
// Width is driven by flex now that the filters panel is resizable; the list
|
||||
// fills whatever space the (variable-width) panel leaves.
|
||||
max-width: 100%;
|
||||
max-width: calc(100% - 280px);
|
||||
}
|
||||
|
||||
.quickFiltersToggleContainer {
|
||||
|
||||
@@ -6,7 +6,6 @@ import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import K8sBaseDetails from 'container/InfraMonitoringK8sV2/Base/K8sBaseDetails';
|
||||
import { K8sBaseList } from 'container/InfraMonitoringK8sV2/Base/K8sBaseList';
|
||||
import { K8sBaseFilters } from 'container/InfraMonitoringK8sV2/Base/types';
|
||||
@@ -17,8 +16,6 @@ import {
|
||||
} from 'container/InfraMonitoringK8sV2/hooks';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
@@ -43,21 +40,8 @@ import { getHostsQuickFiltersConfig } from './utils';
|
||||
import styles from './InfraMonitoringHosts.module.scss';
|
||||
import { ArrowUpToLine, Filter } from '@signozhq/icons';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
function Hosts(): JSX.Element {
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
const [, setCurrentPage] = useInfraMonitoringPageListing();
|
||||
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
|
||||
|
||||
@@ -155,18 +139,7 @@ function Hosts(): JSX.Element {
|
||||
<div className={styles.infraMonitoringContainer}>
|
||||
<div className={styles.infraContentRow}>
|
||||
{showFilters && (
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
className={styles.quickFiltersContainer}
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<div className={styles.quickFiltersContainer}>
|
||||
<div className={styles.quickFiltersContainerHeader}>
|
||||
<Typography.Text>Filters</Typography.Text>
|
||||
<Tooltip title="Collapse Filters">
|
||||
@@ -183,7 +156,7 @@ function Hosts(): JSX.Element {
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleQuickFiltersChange}
|
||||
/>
|
||||
</ResizableBox>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`${styles.listContainer}${
|
||||
|
||||
@@ -44,20 +44,26 @@
|
||||
}
|
||||
|
||||
.quickFiltersContainer {
|
||||
// Width is owned by ResizableBox (inline style). No overflow here: the panel
|
||||
// must not scroll (QuickFilters scrolls its own list internally) — an
|
||||
// overflow on the root would also clip the edge-mounted resize grip.
|
||||
flex-shrink: 0;
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
overflow-y: auto;
|
||||
|
||||
:global(.resizable-box__content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
height: 0.1rem;
|
||||
}
|
||||
|
||||
:global(.quick-filters-container) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header) {
|
||||
@@ -136,9 +142,7 @@
|
||||
}
|
||||
|
||||
.listContainerFiltersVisible {
|
||||
// Width is driven by flex now that the filters panel is resizable; the list
|
||||
// fills whatever space the (variable-width) panel leaves.
|
||||
max-width: 100%;
|
||||
max-width: calc(100% - 280px);
|
||||
}
|
||||
|
||||
.quickFiltersToggleContainer {
|
||||
|
||||
@@ -44,15 +44,26 @@
|
||||
}
|
||||
|
||||
.quickFiltersContainer {
|
||||
// Width is owned by ResizableBox (inline style). No overflow here: the panel
|
||||
// must not scroll (QuickFilters scrolls its own list internally) — an
|
||||
// overflow on the root would also clip the edge-mounted resize grip.
|
||||
flex-shrink: 0;
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
overflow-y: auto;
|
||||
|
||||
:global(.resizable-box__content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
height: 0.1rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
|
||||
}
|
||||
|
||||
:global(.quick-filters) {
|
||||
@@ -117,9 +128,7 @@
|
||||
}
|
||||
|
||||
.listContainerFiltersVisible {
|
||||
// Width is driven by flex now that the filters panel is resizable; the list
|
||||
// fills whatever space the (variable-width) panel leaves.
|
||||
max-width: 100%;
|
||||
max-width: calc(100% - 280px);
|
||||
}
|
||||
|
||||
.categorySelectorSection {
|
||||
@@ -210,16 +219,8 @@
|
||||
|
||||
.quickFiltersSection {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
|
||||
:global(.quick-filters-container) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import logEvent from 'api/common/logEvent';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import {
|
||||
@@ -23,8 +22,6 @@ import {
|
||||
Workflow,
|
||||
} from '@signozhq/icons';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { FeatureKeys } from '../../constants/features';
|
||||
@@ -59,23 +56,9 @@ import K8sVolumesList from './Volumes/K8sVolumesList';
|
||||
|
||||
import styles from './InfraMonitoringK8s.module.scss';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
export default function InfraMonitoringK8s(): JSX.Element {
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
|
||||
const [selectedCategory, setSelectedCategory] = useInfraMonitoringCategory();
|
||||
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
|
||||
const [, setGroupBy] = useInfraMonitoringGroupBy();
|
||||
@@ -229,18 +212,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
<div className={styles.infraMonitoringContainer}>
|
||||
<div className={styles.infraContentRow}>
|
||||
{showFilters && (
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
className={styles.quickFiltersContainer}
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<div className={styles.quickFiltersContainer}>
|
||||
<div className={styles.categorySelectorSection}>
|
||||
<div className={styles.sectionHeader} data-type="resource">
|
||||
<Typography.Text className={styles.sectionLabel}>
|
||||
@@ -293,7 +265,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ResizableBox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
|
||||
@@ -44,15 +44,26 @@
|
||||
}
|
||||
|
||||
.quickFiltersContainer {
|
||||
// Width is owned by ResizableBox (inline style). No overflow here: the panel
|
||||
// must not scroll (QuickFilters scrolls its own list internally) — an
|
||||
// overflow on the root would also clip the edge-mounted resize grip.
|
||||
flex-shrink: 0;
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
overflow-y: auto;
|
||||
|
||||
:global(.resizable-box__content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
height: 0.1rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
|
||||
}
|
||||
|
||||
:global(.quick-filters) {
|
||||
@@ -117,9 +128,7 @@
|
||||
}
|
||||
|
||||
.listContainerFiltersVisible {
|
||||
// Width is driven by flex now that the filters panel is resizable; the list
|
||||
// fills whatever space the (variable-width) panel leaves.
|
||||
max-width: 100%;
|
||||
max-width: calc(100% - 280px);
|
||||
}
|
||||
|
||||
.categorySelectorSection {
|
||||
@@ -210,16 +219,8 @@
|
||||
|
||||
.quickFiltersSection {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
|
||||
:global(.quick-filters-container) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import logEvent from 'api/common/logEvent';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import {
|
||||
@@ -23,8 +22,6 @@ import {
|
||||
Workflow,
|
||||
} from '@signozhq/icons';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { FeatureKeys } from '../../constants/features';
|
||||
@@ -59,23 +56,9 @@ import K8sVolumesList from './Volumes/K8sVolumesList';
|
||||
|
||||
import styles from './InfraMonitoringK8s.module.scss';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
export default function InfraMonitoringK8s(): JSX.Element {
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
|
||||
const [selectedCategory, setSelectedCategory] = useInfraMonitoringCategory();
|
||||
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
|
||||
const [, setGroupBy] = useInfraMonitoringGroupBy();
|
||||
@@ -229,18 +212,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
<div className={styles.infraMonitoringContainer}>
|
||||
<div className={styles.infraContentRow}>
|
||||
{showFilters && (
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
className={styles.quickFiltersContainer}
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<div className={styles.quickFiltersContainer}>
|
||||
<div className={styles.categorySelectorSection}>
|
||||
<div className={styles.sectionHeader} data-type="resource">
|
||||
<Typography.Text className={styles.sectionLabel}>
|
||||
@@ -293,7 +265,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ResizableBox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
|
||||
@@ -3,23 +3,12 @@
|
||||
flex-direction: row;
|
||||
|
||||
.meter-explorer-quick-filters-section {
|
||||
// Width is owned by ResizableBox (inline style).
|
||||
flex-shrink: 0;
|
||||
width: 280px;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.resizable-box__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.quick-filters-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.meter-explorer-content-section {
|
||||
@@ -97,9 +86,7 @@
|
||||
|
||||
&.quick-filters-open {
|
||||
.meter-explorer-content-section {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
width: calc(100% - 280px);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import cx from 'classnames';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
@@ -19,8 +18,6 @@ import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { Filter } from '@signozhq/icons';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -33,10 +30,6 @@ import { splitQueryIntoOneChartPerQuery } from './utils';
|
||||
|
||||
import './Explorer.styles.scss';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
function Explorer(): JSX.Element {
|
||||
const {
|
||||
handleRunQuery,
|
||||
@@ -62,16 +55,6 @@ function Explorer(): JSX.Element {
|
||||
|
||||
const [showQuickFilters, setShowQuickFilters] = useState(true);
|
||||
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_METER,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
|
||||
const defaultQuery = useMemo(
|
||||
() =>
|
||||
updateAllQueriesOperators(
|
||||
@@ -144,19 +127,10 @@ function Explorer(): JSX.Element {
|
||||
'quick-filters-open': showQuickFilters,
|
||||
})}
|
||||
>
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
<div
|
||||
className={cx('meter-explorer-quick-filters-section', {
|
||||
hidden: !showQuickFilters,
|
||||
})}
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<QuickFilters
|
||||
className="qf-meter-explorer"
|
||||
@@ -168,7 +142,7 @@ function Explorer(): JSX.Element {
|
||||
setShowQuickFilters(!showQuickFilters);
|
||||
}}
|
||||
/>
|
||||
</ResizableBox>
|
||||
</div>
|
||||
|
||||
<div className="meter-explorer-content-section">
|
||||
<div className="meter-explorer-explore-content">
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { getPublicDashboardDataV2 } from 'api/generated/services/dashboard';
|
||||
import getPublicDashboardDataAPI from 'api/dashboard/public/getPublicDashboardData';
|
||||
import { ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
|
||||
import {
|
||||
PublicDashboardSchema,
|
||||
useGetResolvedPublicDashboard,
|
||||
} from '../useGetResolvedPublicDashboard';
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
getPublicDashboardDataV2: jest.fn(),
|
||||
}));
|
||||
jest.mock('api/dashboard/public/getPublicDashboardData', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockV2 = getPublicDashboardDataV2 as jest.Mock;
|
||||
const mockV1 = getPublicDashboardDataAPI as jest.Mock;
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
};
|
||||
|
||||
// A schema mismatch on the v2 endpoint surfaces as an AxiosError with HTTP 501 and this
|
||||
// error code; anything else must NOT trigger the v1 fallback.
|
||||
const schemaMismatchError = {
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 501,
|
||||
data: { error: { code: 'dashboard_invalid_data', message: 'not in v6' } },
|
||||
},
|
||||
};
|
||||
|
||||
describe('useGetResolvedPublicDashboard', () => {
|
||||
beforeEach(() => {
|
||||
mockV2.mockReset();
|
||||
mockV1.mockReset();
|
||||
});
|
||||
|
||||
it('returns the v2 model when the v2 endpoint succeeds and never calls v1', async () => {
|
||||
mockV2.mockResolvedValue({ status: 'success', data: { dashboard: {} } });
|
||||
|
||||
const { result } = renderHook(() => useGetResolvedPublicDashboard('id-1'), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data?.schema).toBe(PublicDashboardSchema.V2);
|
||||
expect(mockV2).toHaveBeenCalledWith({ id: 'id-1' });
|
||||
expect(mockV1).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to v1 when the v2 endpoint reports a schema mismatch', async () => {
|
||||
mockV2.mockRejectedValue(schemaMismatchError);
|
||||
mockV1.mockResolvedValue({
|
||||
httpStatusCode: 200,
|
||||
data: { dashboard: { data: { title: 'v1' } } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useGetResolvedPublicDashboard('id-2'), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data?.schema).toBe(PublicDashboardSchema.V1);
|
||||
expect(mockV1).toHaveBeenCalledWith({ id: 'id-2' });
|
||||
});
|
||||
|
||||
it('surfaces a non-schema-mismatch v2 error without falling back to v1', async () => {
|
||||
mockV2.mockRejectedValue({
|
||||
isAxiosError: true,
|
||||
response: { status: 500, data: { error: { code: 'internal' } } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useGetResolvedPublicDashboard('id-3'), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
expect(mockV1).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fetch without an id', () => {
|
||||
renderHook(() => useGetResolvedPublicDashboard(''), { wrapper });
|
||||
expect(mockV2).not.toHaveBeenCalled();
|
||||
expect(mockV1).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { getPublicDashboardDataV2 } from 'api/generated/services/dashboard';
|
||||
import { DashboardtypesGettablePublicDashboardDataV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import getPublicDashboardDataAPI from 'api/dashboard/public/getPublicDashboardData';
|
||||
import { AxiosError, isAxiosError } from 'axios';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useQuery, UseQueryResult } from 'react-query';
|
||||
import { ErrorV2Resp } from 'types/api';
|
||||
import { PublicDashboardDataProps } from 'types/api/dashboard/public/get';
|
||||
|
||||
export enum PublicDashboardSchema {
|
||||
V1 = 'v1',
|
||||
V2 = 'v2',
|
||||
}
|
||||
|
||||
export type ResolvedPublicDashboard =
|
||||
| {
|
||||
schema: PublicDashboardSchema.V2;
|
||||
data: DashboardtypesGettablePublicDashboardDataV2DTO;
|
||||
}
|
||||
| { schema: PublicDashboardSchema.V1; data: PublicDashboardDataProps };
|
||||
|
||||
// The v2 endpoint rejects non-v6 rows with this code — our signal that it's a v1 dashboard.
|
||||
const V2_SCHEMA_MISMATCH_CODE = 'dashboard_invalid_data';
|
||||
|
||||
function isV2SchemaMismatch(error: unknown): boolean {
|
||||
if (!isAxiosError(error)) {
|
||||
return false;
|
||||
}
|
||||
const { response } = error as AxiosError<ErrorV2Resp>;
|
||||
return response?.data?.error?.code === V2_SCHEMA_MISMATCH_CODE;
|
||||
}
|
||||
|
||||
// Probe v2 first, fall back to v1 only on a schema mismatch. v1-first is unsafe: it 200s for a
|
||||
// v2 dashboard with queries un-redacted. Other v2 errors re-throw rather than mis-render as v1.
|
||||
async function resolvePublicDashboard(
|
||||
id: string,
|
||||
): Promise<ResolvedPublicDashboard> {
|
||||
try {
|
||||
const v2 = await getPublicDashboardDataV2({ id });
|
||||
return { schema: PublicDashboardSchema.V2, data: v2.data };
|
||||
} catch (error) {
|
||||
if (!isV2SchemaMismatch(error)) {
|
||||
throw error;
|
||||
}
|
||||
const v1 = await getPublicDashboardDataAPI({ id });
|
||||
return { schema: PublicDashboardSchema.V1, data: v1.data };
|
||||
}
|
||||
}
|
||||
|
||||
export const useGetResolvedPublicDashboard = (
|
||||
id: string,
|
||||
): UseQueryResult<ResolvedPublicDashboard, Error> =>
|
||||
useQuery<ResolvedPublicDashboard, Error>({
|
||||
queryFn: () => resolvePublicDashboard(id),
|
||||
queryKey: [REACT_QUERY_KEY.GET_PUBLIC_DASHBOARD_RESOLVED, id],
|
||||
enabled: !!id,
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
import debounce from 'lodash-es/debounce';
|
||||
|
||||
export type Dimensions = {
|
||||
@@ -15,6 +15,15 @@ export function useResizeObserver<T extends HTMLElement>(
|
||||
height: ref.current?.clientHeight || 0,
|
||||
});
|
||||
|
||||
// Measure before paint so the first frame has real dimensions, not 0 (the
|
||||
// debounced observer below only catches up after paint → layout jump).
|
||||
useLayoutEffect(() => {
|
||||
const node = ref.current;
|
||||
if (node) {
|
||||
setSize({ width: node.clientWidth, height: node.clientHeight });
|
||||
}
|
||||
}, [ref]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = debounce(
|
||||
(entries: ResizeObserverEntry[]) => {
|
||||
|
||||
@@ -20,23 +20,12 @@
|
||||
}
|
||||
|
||||
&.filter-visible {
|
||||
// Width is owned by ResizableBox (inline style); this section is the
|
||||
// ResizableBox root, so it stays position: relative for the drag handle.
|
||||
.all-errors-quick-filter-section {
|
||||
.resizable-box__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.quick-filters-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.all-errors-right-section {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,18 +18,12 @@ import Toolbar from 'container/Toolbar/Toolbar';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import history from 'lib/history';
|
||||
import { isNull } from 'lodash-es';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
|
||||
import { routes } from './config';
|
||||
import { useAllErrorsQueryState } from './QueryStateContext';
|
||||
|
||||
import './AllErrors.styles.scss';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 260;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
function AllErrors(): JSX.Element {
|
||||
const { pathname } = useLocation();
|
||||
const { handleRunQuery } = useQueryBuilder();
|
||||
@@ -61,38 +55,17 @@ function AllErrors(): JSX.Element {
|
||||
setShowFilters((prev) => !prev);
|
||||
};
|
||||
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_EXCEPTIONS,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cx('all-errors-page', showFilters ? 'filter-visible' : '')}>
|
||||
{showFilters && (
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
className="all-errors-quick-filter-section"
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<section className={cx('all-errors-quick-filter-section')}>
|
||||
<QuickFilters
|
||||
className="qf-exceptions"
|
||||
source={QuickFiltersSource.EXCEPTIONS}
|
||||
signal={SignalType.EXCEPTIONS}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
/>
|
||||
</ResizableBox>
|
||||
</section>
|
||||
)}
|
||||
<section
|
||||
className={cx(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { KeyboardEvent } from 'react';
|
||||
import { type FocusEvent, KeyboardEvent } from 'react';
|
||||
import {
|
||||
Check,
|
||||
Globe,
|
||||
@@ -90,12 +90,20 @@ function DashboardInfo({
|
||||
}
|
||||
};
|
||||
|
||||
// Clicking outside the editor commits, matching the input's Enter behaviour.
|
||||
// Guard against blurs that move focus to the Save/Cancel buttons within it.
|
||||
const onEditorBlur = (event: FocusEvent<HTMLDivElement>): void => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
onCommit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardInfo}>
|
||||
<img src={image} alt={title} className={styles.dashboardImage} />
|
||||
|
||||
{isEditing ? (
|
||||
<div className={styles.dashboardTitleEditor}>
|
||||
<div className={styles.dashboardTitleEditor} onBlur={onEditorBlur}>
|
||||
<Input
|
||||
autoFocus
|
||||
value={draft}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
.trigger {
|
||||
display: flex;
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: var(--l3-background);
|
||||
|
||||
// icon-only trigger: drop the dropdown chevron, keep just the selected icon
|
||||
svg {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.options {
|
||||
min-width: min-content;
|
||||
}
|
||||
|
||||
.item {
|
||||
width: min-content;
|
||||
|
||||
span {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.image {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '@signozhq/ui/select';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { Base64Icons } from '../utils';
|
||||
|
||||
import styles from './DashboardImagePicker.module.scss';
|
||||
|
||||
interface Props {
|
||||
// The selected image — one of the base64 icon data-URIs.
|
||||
image: string;
|
||||
onChange: (value: string) => void;
|
||||
// Consumers set the trigger's border-radius (e.g. rounded-left when joined to
|
||||
// a name input); this component owns size / background / icon-only styling.
|
||||
triggerClassName?: string;
|
||||
}
|
||||
|
||||
// Icon picker shared by the dashboard-details settings and the create-dashboard
|
||||
// modal so both choose from the same `Base64Icons` set.
|
||||
function DashboardImagePicker({
|
||||
image,
|
||||
onChange,
|
||||
triggerClassName,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<Select value={image} onChange={(value): void => onChange(value as string)}>
|
||||
<SelectTrigger className={cx(styles.trigger, triggerClassName)} />
|
||||
<SelectContent className={styles.options} withPortal={false}>
|
||||
{Base64Icons.map((icon) => (
|
||||
<SelectItem key={icon} value={icon} className={styles.item}>
|
||||
<img src={icon} alt="dashboard-icon" className={styles.image} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardImagePicker;
|
||||
@@ -25,38 +25,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
// The image-picker owns its size/background; here we only round the left edge so
|
||||
// it joins flush with the name input to its right.
|
||||
.dashboardImageInput {
|
||||
display: flex;
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
background: var(--l3-background);
|
||||
|
||||
// icon-only trigger: drop the dropdown chevron, keep just the selected icon
|
||||
svg {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboardImageOptions {
|
||||
min-width: min-content;
|
||||
}
|
||||
|
||||
.dashboardImageSelectItem {
|
||||
width: min-content;
|
||||
|
||||
span {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.listItemImage {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.dashboardNameInput {
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '@signozhq/ui/select';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- multiline TextArea has no @signozhq/ui equivalent yet
|
||||
import { Input as AntdInput } from 'antd';
|
||||
import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
|
||||
|
||||
import { Base64Icons } from '../utils';
|
||||
import DashboardImagePicker from '../DashboardImagePicker/DashboardImagePicker';
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../../constants';
|
||||
import settingsStyles from '../../DashboardSettings.module.scss';
|
||||
import styles from './DashboardInfoForm.module.scss';
|
||||
@@ -43,30 +37,11 @@ function DashboardInfoForm({
|
||||
<div className={styles.infoItemContainer}>
|
||||
<Typography className={styles.infoTitle}>Dashboard Name</Typography>
|
||||
<section className={styles.nameIconInput}>
|
||||
<Select
|
||||
value={image}
|
||||
onChange={(value): void => onImageChange(value as string)}
|
||||
>
|
||||
<SelectTrigger className={styles.dashboardImageInput} />
|
||||
<SelectContent
|
||||
className={styles.dashboardImageOptions}
|
||||
withPortal={false}
|
||||
>
|
||||
{Base64Icons.map((icon) => (
|
||||
<SelectItem
|
||||
key={icon}
|
||||
value={icon}
|
||||
className={styles.dashboardImageSelectItem}
|
||||
>
|
||||
<img
|
||||
src={icon}
|
||||
alt="dashboard-icon"
|
||||
className={styles.listItemImage}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<DashboardImagePicker
|
||||
image={image}
|
||||
onChange={onImageChange}
|
||||
triggerClassName={styles.dashboardImageInput}
|
||||
/>
|
||||
|
||||
<Input
|
||||
testId="dashboard-name"
|
||||
|
||||
@@ -83,7 +83,18 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
);
|
||||
}
|
||||
if (updatedImage !== image) {
|
||||
ops.push(replace('/image', updatedImage));
|
||||
// `replace` fails when the image doesn't exist yet, so add it when the
|
||||
// dashboard has none (`add` creates or replaces the member). Key off the
|
||||
// raw stored value, not the `Base64Icons[0]`-defaulted local `image`.
|
||||
ops.push(
|
||||
op(
|
||||
dashboard.image
|
||||
? DashboardtypesPatchOpDTO.replace
|
||||
: DashboardtypesPatchOpDTO.add,
|
||||
'/image',
|
||||
updatedImage,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!isEqual(updatedTags, tagsAsStrings)) {
|
||||
ops.push(replace('/tags', stringsToTags(updatedTags)));
|
||||
@@ -96,6 +107,7 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
description,
|
||||
updatedImage,
|
||||
image,
|
||||
dashboard.image,
|
||||
updatedTags,
|
||||
tagsAsStrings,
|
||||
]);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Pan
|
||||
import { resolveSignal } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import type { LegendSeries } from '../hooks/useLegendSeries';
|
||||
import type { LegendSeries } from '../utils/legendSeries';
|
||||
import type { TableColumnOption } from '../hooks/useTableColumns';
|
||||
import ConfigActions from './ConfigActions/ConfigActions';
|
||||
import SectionSlot from './SectionSlot/SectionSlot';
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Input } from 'antd';
|
||||
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
|
||||
import type { LegendSeries } from '../../../hooks/useLegendSeries';
|
||||
import type { LegendSeries } from '../../../utils/legendSeries';
|
||||
import LegendColorRow from './LegendColorRow';
|
||||
import {
|
||||
clearSeriesColor,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import type { LegendSeries } from '../../../../hooks/useLegendSeries';
|
||||
import type { LegendSeries } from '../../../../utils/legendSeries';
|
||||
import LegendColors from '../LegendColors';
|
||||
|
||||
const SERIES: LegendSeries[] = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LegendSeries } from '../../../../hooks/useLegendSeries';
|
||||
import type { LegendSeries } from '../../../../utils/legendSeries';
|
||||
import {
|
||||
clearSeriesColor,
|
||||
filterLegendSeries,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { LegendSeries } from '../../../hooks/useLegendSeries';
|
||||
import type { LegendSeries } from '../../../utils/legendSeries';
|
||||
|
||||
/** Case-insensitive substring filter over series labels. Empty query → all series. */
|
||||
export function filterLegendSeries(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { PanelKind } from '../../Panels/types/panelKind';
|
||||
import type { LegendSeries } from '../hooks/useLegendSeries';
|
||||
import type { LegendSeries } from '../utils/legendSeries';
|
||||
import type { TableColumnOption } from '../hooks/useTableColumns';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
|
||||
@@ -49,6 +49,23 @@ describe('ContextLinksSection utils', () => {
|
||||
{ key: 'q', value: '{{x}}' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats undecodable percent sequences as literal text instead of throwing', () => {
|
||||
expect(getUrlParams('/logs?search=95%')).toStrictEqual([
|
||||
{ key: 'search', value: '95%' },
|
||||
]);
|
||||
expect(getUrlParams('/logs?95%=value')).toStrictEqual([
|
||||
{ key: '95%', value: 'value' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('decodes a valid escape once even when the result has a stray percent', () => {
|
||||
// %2525 double-decodes to '%'; 95%25 decodes once to '95%' and stops there
|
||||
expect(getUrlParams('/logs?a=95%25&b=%2525')).toStrictEqual([
|
||||
{ key: 'a', value: '95%' },
|
||||
{ key: 'b', value: '%' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateUrlWithParams', () => {
|
||||
|
||||
@@ -29,18 +29,22 @@ export function insertVariableAtCursor(
|
||||
);
|
||||
}
|
||||
|
||||
// Values may be double-encoded on the wire; decode a second time only when it changes
|
||||
// the string, so already-single-encoded values are left intact.
|
||||
function decodeForDisplay(value: string): string {
|
||||
const decoded = decodeURIComponent(value);
|
||||
// Users can type raw `%` into the URL field (e.g. `?q=95%`), which is not valid
|
||||
// percent-encoding — treat undecodable input as literal text instead of throwing.
|
||||
function safeDecodeURIComponent(value: string): string {
|
||||
try {
|
||||
const doubleDecoded = decodeURIComponent(decoded);
|
||||
return doubleDecoded !== decoded ? doubleDecoded : decoded;
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return decoded;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// Values may be double-encoded on the wire, so decode twice; a second decode is a
|
||||
// no-op once nothing is left to unescape, leaving single-encoded values intact.
|
||||
function decodeForDisplay(value: string): string {
|
||||
return safeDecodeURIComponent(safeDecodeURIComponent(value));
|
||||
}
|
||||
|
||||
/** Parses the `?a=b&c=d` query string of a URL into decoded key/value rows. */
|
||||
export function getUrlParams(url: string): UrlParam[] {
|
||||
const [, queryString] = url.split('?');
|
||||
@@ -53,7 +57,7 @@ export function getUrlParams(url: string): UrlParam[] {
|
||||
const [key, value] = pair.split('=');
|
||||
if (key) {
|
||||
params.push({
|
||||
key: decodeURIComponent(key),
|
||||
key: safeDecodeURIComponent(key),
|
||||
value: decodeForDisplay(value || ''),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ interface ColumnUnitsProps {
|
||||
columns: TableColumnOption[];
|
||||
/** Current per-column unit map (`formatting.columnUnits`), keyed by column key. */
|
||||
value: Record<string, string>;
|
||||
/** Unit the selected metric was sent with; each column warns if its unit mismatches. */
|
||||
metricUnit?: string;
|
||||
onChange: (next: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
@@ -23,6 +25,7 @@ interface ColumnUnitsProps {
|
||||
function ColumnUnits({
|
||||
columns,
|
||||
value,
|
||||
metricUnit,
|
||||
onChange,
|
||||
}: ColumnUnitsProps): JSX.Element {
|
||||
if (columns.length === 0) {
|
||||
@@ -53,6 +56,7 @@ function ColumnUnits({
|
||||
placeholder="Select unit"
|
||||
source={YAxisSource.DASHBOARDS}
|
||||
value={value[column.key]}
|
||||
initialValue={metricUnit}
|
||||
containerClassName={styles.columnUnitSelector}
|
||||
onChange={(unit): void => setUnit(column.key, unit)}
|
||||
/>
|
||||
|
||||
@@ -81,6 +81,7 @@ function FormattingSection({
|
||||
<ColumnUnits
|
||||
columns={tableColumns}
|
||||
value={value?.columnUnits ?? {}}
|
||||
metricUnit={metricUnit}
|
||||
onChange={(columnUnits): void => onChange({ ...value, columnUnits })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event';
|
||||
|
||||
import FormattingSection from '../FormattingSection';
|
||||
|
||||
// Auto-seeding is covered by useMetricYAxisUnit's tests; here `metricUnit` is just a prop.
|
||||
// Auto-seeding is covered by useSeedMetricUnit's tests; here `metricUnit` is just a prop.
|
||||
|
||||
// Open the Decimals select (clicking its antd selector) and pick the option with the
|
||||
// given visible label.
|
||||
@@ -100,4 +100,33 @@ describe('FormattingSection', () => {
|
||||
|
||||
expect(screen.queryByLabelText('warning')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('warns when a column unit mismatches the metric unit', () => {
|
||||
// metric sent in seconds, but the column is set to bytes.
|
||||
render(
|
||||
<FormattingSection
|
||||
value={{ columnUnits: { A: 'By' } }}
|
||||
controls={{ columnUnits: true }}
|
||||
tableColumns={[{ key: 'A', label: 'A' }]}
|
||||
metricUnit="s"
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('warning')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no warning when the column unit matches the metric unit', () => {
|
||||
render(
|
||||
<FormattingSection
|
||||
value={{ columnUnits: { A: 's' } }}
|
||||
controls={{ columnUnits: true }}
|
||||
tableColumns={[{ key: 'A', label: 'A' }]}
|
||||
metricUnit="s"
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByLabelText('warning')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,4 +7,9 @@
|
||||
|
||||
.dropdown {
|
||||
width: 260px;
|
||||
/* The dropdown portals to body; lift it above the query builder's antd
|
||||
* popups (1050) so it stays clickable, incl. inside the View modal.
|
||||
* TODO: remove after the antd -> @signozhq/ui migration
|
||||
**/
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ function AddColumnDropdown({
|
||||
<Plus size={16} />
|
||||
</Button>
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent arrow side="top" align="end" className={styles.dropdown}>
|
||||
<ComboboxContent arrow side="bottom" align="end" className={styles.dropdown}>
|
||||
<ComboboxCommand shouldFilter={false}>
|
||||
<ComboboxInput
|
||||
value={searchText}
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
}
|
||||
|
||||
.scrollArea {
|
||||
padding: 12px;
|
||||
// Top padding lives on the sticky tab nav instead, so the nav keeps its
|
||||
// breathing room both at rest and when pinned to the top.
|
||||
padding: 0 12px 12px 12px;
|
||||
}
|
||||
|
||||
.tabsContainer {
|
||||
@@ -24,11 +26,22 @@
|
||||
background-color: var(--l1-background) !important;
|
||||
}
|
||||
:global(.ant-tabs-nav) {
|
||||
// `padding-top` owns the nav's top spacing (moved off `.scrollArea`).
|
||||
padding-top: 12px;
|
||||
background-color: var(--l1-background);
|
||||
|
||||
&::before {
|
||||
border-color: var(--l2-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Opt-in pin to the top of the scroll area; the View modal opts out (shares a scroll area with its own header).
|
||||
.stickyNav :global(.ant-tabs-nav) {
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
z-index: 1100;
|
||||
}
|
||||
.queryTypeTab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Atom, Terminal } from '@signozhq/icons';
|
||||
import { Tabs } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import PromQLIcon from 'assets/Dashboard/PromQl';
|
||||
@@ -45,6 +46,8 @@ interface PanelEditorQueryBuilderProps {
|
||||
onCancelQuery: () => void;
|
||||
/** Optional content pinned below the builder (e.g. the List columns editor). */
|
||||
footer?: ReactNode;
|
||||
/** Pin the tabs + Run Query row to the top of the scroll area. Off in the View modal, which shares a scroll area with its own header. */
|
||||
stickyHeader?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,6 +62,7 @@ function PanelEditorQueryBuilder({
|
||||
onStageRunQuery,
|
||||
onCancelQuery,
|
||||
footer,
|
||||
stickyHeader = true,
|
||||
}: PanelEditorQueryBuilderProps): JSX.Element {
|
||||
// The shared QueryBuilderV2 / list-view checks still speak the legacy PANEL_TYPES.
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
|
||||
@@ -156,7 +160,9 @@ function PanelEditorQueryBuilder({
|
||||
<div className={styles.scrollArea}>
|
||||
<Tabs
|
||||
type="card"
|
||||
className={styles.tabsContainer}
|
||||
className={cx(styles.tabsContainer, {
|
||||
[styles.stickyNav]: stickyHeader,
|
||||
})}
|
||||
activeKey={currentQuery.queryType}
|
||||
onChange={handleQueryCategoryChange}
|
||||
tabBarExtraContent={
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
|
||||
import PanelEditorContainer from '../index';
|
||||
import { useScrollIntoViewStore } from '../../store/useScrollIntoViewStore';
|
||||
|
||||
/**
|
||||
* Characterization test for the editor's composition: which derived values and
|
||||
@@ -19,7 +20,7 @@ const mockRefetch = jest.fn();
|
||||
const mockCancelQuery = jest.fn();
|
||||
const mockBuildSaveSpec = jest.fn((spec: unknown) => spec);
|
||||
const mockOnChangePanelKind = jest.fn();
|
||||
const mockSave = jest.fn().mockResolvedValue(undefined);
|
||||
const mockSave = jest.fn().mockResolvedValue('panel-1');
|
||||
|
||||
const mockUseDraft = jest.fn();
|
||||
jest.mock('../hooks/usePanelEditorDraft', () => ({
|
||||
@@ -61,8 +62,8 @@ jest.mock('../hooks/useLegendSeries', () => ({
|
||||
jest.mock('../hooks/useTableColumns', () => ({
|
||||
useTableColumns: (): [] => [],
|
||||
}));
|
||||
jest.mock('../hooks/useMetricYAxisUnit', () => ({
|
||||
useMetricYAxisUnit: (): unknown => ({
|
||||
jest.mock('../hooks/useSeedMetricUnit', () => ({
|
||||
useSeedMetricUnit: (): unknown => ({
|
||||
metricUnit: undefined,
|
||||
isLoading: false,
|
||||
}),
|
||||
@@ -104,12 +105,17 @@ jest.mock('@signozhq/ui/sonner', () => ({
|
||||
const mockHeaderProps = jest.fn();
|
||||
jest.mock('../Header/Header', () => ({
|
||||
__esModule: true,
|
||||
default: (props: { onSave: () => void }): JSX.Element => {
|
||||
default: (props: { onSave: () => void; onClose: () => void }): JSX.Element => {
|
||||
mockHeaderProps(props);
|
||||
return (
|
||||
<button type="button" data-testid="editor-save" onClick={props.onSave}>
|
||||
save
|
||||
</button>
|
||||
<>
|
||||
<button type="button" data-testid="editor-save" onClick={props.onSave}>
|
||||
save
|
||||
</button>
|
||||
<button type="button" data-testid="editor-close" onClick={props.onClose}>
|
||||
close
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
},
|
||||
}));
|
||||
@@ -196,7 +202,10 @@ function setup(
|
||||
}
|
||||
|
||||
describe('PanelEditorContainer composition', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useScrollIntoViewStore.setState({ scrollTargetId: null });
|
||||
});
|
||||
|
||||
it('renders the editor shell with preview, query builder, and config pane', () => {
|
||||
const panel = makePanel('signoz/TimeSeriesPanel');
|
||||
@@ -299,6 +308,32 @@ describe('PanelEditorContainer composition', () => {
|
||||
expect(mockSave).toHaveBeenCalledWith(panel.spec);
|
||||
});
|
||||
|
||||
it('marks the saved panel to be scrolled into view on the dashboard', async () => {
|
||||
setup(makePanel('signoz/TimeSeriesPanel'));
|
||||
|
||||
await userEvent.click(screen.getByTestId('editor-save'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(useScrollIntoViewStore.getState().scrollTargetId).toBe('panel-1'),
|
||||
);
|
||||
});
|
||||
|
||||
it('marks an existing panel to be revealed when the editor is closed', async () => {
|
||||
setup(makePanel('signoz/TimeSeriesPanel'));
|
||||
|
||||
await userEvent.click(screen.getByTestId('editor-close'));
|
||||
|
||||
expect(useScrollIntoViewStore.getState().scrollTargetId).toBe('panel-1');
|
||||
});
|
||||
|
||||
it('does not mark a scroll target when a new, unsaved panel is closed', async () => {
|
||||
setup(makePanel('signoz/TimeSeriesPanel'), { isNew: true });
|
||||
|
||||
await userEvent.click(screen.getByTestId('editor-close'));
|
||||
|
||||
expect(useScrollIntoViewStore.getState().scrollTargetId).toBeNull();
|
||||
});
|
||||
|
||||
it('offers Switch to View Mode for an existing panel', () => {
|
||||
setup(makePanel('signoz/TimeSeriesPanel'));
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { preparePieData } from 'pages/DashboardPageV2/DashboardContainer/Panels/kinds/PieChartPanel/prepareData';
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { flattenTimeSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
@@ -29,18 +30,36 @@ jest.mock(
|
||||
() => ({
|
||||
flattenTimeSeries: jest.fn(),
|
||||
getTimeSeriesResults: jest.fn(() => []),
|
||||
getScalarResults: jest.fn(() => []),
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables',
|
||||
() => ({ prepareScalarTables: jest.fn(() => []) }),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/Panels/kinds/PieChartPanel/prepareData',
|
||||
() => ({ preparePieData: jest.fn(() => []) }),
|
||||
);
|
||||
|
||||
const mockUseIsDarkMode = useIsDarkMode as unknown as jest.Mock;
|
||||
const mockFlatten = flattenTimeSeries as unknown as jest.Mock;
|
||||
const mockResolveLabel = resolveSeriesLabelV5 as unknown as jest.Mock;
|
||||
const mockGenerateColor = generateColor as unknown as jest.Mock;
|
||||
const mockPreparePie = preparePieData as unknown as jest.Mock;
|
||||
|
||||
const PANEL = {
|
||||
kind: 'Panel',
|
||||
spec: { plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} }, queries: [] },
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
const PIE_PANEL = {
|
||||
kind: 'Panel',
|
||||
spec: { plugin: { kind: 'signoz/PieChartPanel', spec: {} }, queries: [] },
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
const HISTOGRAM_PANEL = {
|
||||
kind: 'Panel',
|
||||
spec: { plugin: { kind: 'signoz/HistogramPanel', spec: {} }, queries: [] },
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
const DATA = { response: {}, legendMap: {} } as unknown as PanelQueryData;
|
||||
|
||||
// Each flattened series carries the label resolveSeriesLabelV5 should report.
|
||||
@@ -88,6 +107,32 @@ describe('useLegendSeries', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves histogram panels via the time-series path', () => {
|
||||
mockFlatten.mockReturnValue(seriesWithLabels(['a', 'b']));
|
||||
const { result } = renderHook(() => useLegendSeries(HISTOGRAM_PANEL, DATA));
|
||||
expect(result.current).toStrictEqual([
|
||||
{ label: 'a', defaultColor: 'color:a' },
|
||||
{ label: 'b', defaultColor: 'color:b' },
|
||||
]);
|
||||
// The pie path must not run for a histogram panel.
|
||||
expect(mockPreparePie).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves pie panels from their scalar slices, deduped by label', () => {
|
||||
mockPreparePie.mockReturnValue([
|
||||
{ label: 'x', color: 'c1' },
|
||||
{ label: 'y', color: 'c2' },
|
||||
{ label: 'x', color: 'c1' },
|
||||
]);
|
||||
const { result } = renderHook(() => useLegendSeries(PIE_PANEL, DATA));
|
||||
expect(result.current).toStrictEqual([
|
||||
{ label: 'x', defaultColor: 'c1' },
|
||||
{ label: 'y', defaultColor: 'c2' },
|
||||
]);
|
||||
// The time-series path must not run for a pie panel.
|
||||
expect(mockFlatten).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the dark palette in dark mode and the light palette otherwise', () => {
|
||||
mockFlatten.mockReturnValue(seriesWithLabels(['a']));
|
||||
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
|
||||
|
||||
import { useMetricYAxisUnit } from '../useMetricYAxisUnit';
|
||||
|
||||
jest.mock('hooks/useGetYAxisUnit', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseGetYAxisUnit = useGetYAxisUnit as unknown as jest.Mock;
|
||||
|
||||
function mockMetricUnit(
|
||||
yAxisUnit: string | undefined,
|
||||
isLoading = false,
|
||||
): void {
|
||||
mockUseGetYAxisUnit.mockReturnValue({ yAxisUnit, isLoading, isError: false });
|
||||
}
|
||||
|
||||
describe('useMetricYAxisUnit', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('seeds the unit from the metric on a new panel', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onSelectUnit = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useMetricYAxisUnit({ isNewPanel: true, unit: undefined, onSelectUnit }),
|
||||
);
|
||||
|
||||
expect(onSelectUnit).toHaveBeenCalledWith('bytes');
|
||||
});
|
||||
|
||||
it('does not seed when not a new panel', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onSelectUnit = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useMetricYAxisUnit({ isNewPanel: false, unit: undefined, onSelectUnit }),
|
||||
);
|
||||
|
||||
expect(onSelectUnit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not seed when the metric has no unit', () => {
|
||||
mockMetricUnit(undefined);
|
||||
const onSelectUnit = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useMetricYAxisUnit({ isNewPanel: true, unit: undefined, onSelectUnit }),
|
||||
);
|
||||
|
||||
expect(onSelectUnit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not seed when the unit already matches the metric', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onSelectUnit = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useMetricYAxisUnit({ isNewPanel: true, unit: 'bytes', onSelectUnit }),
|
||||
);
|
||||
|
||||
expect(onSelectUnit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-seeds when the resolved metric unit changes', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onSelectUnit = jest.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(props: { unit: string | undefined }) =>
|
||||
useMetricYAxisUnit({
|
||||
isNewPanel: true,
|
||||
unit: props.unit,
|
||||
onSelectUnit,
|
||||
}),
|
||||
{ initialProps: { unit: undefined as string | undefined } },
|
||||
);
|
||||
expect(onSelectUnit).toHaveBeenLastCalledWith('bytes');
|
||||
|
||||
// The metric changes; the panel now holds the previously-seeded unit.
|
||||
mockMetricUnit('ms');
|
||||
rerender({ unit: 'bytes' });
|
||||
|
||||
expect(onSelectUnit).toHaveBeenLastCalledWith('ms');
|
||||
});
|
||||
|
||||
it('returns the resolved metric unit and loading state', () => {
|
||||
mockMetricUnit('bytes', true);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMetricYAxisUnit({
|
||||
isNewPanel: false,
|
||||
unit: undefined,
|
||||
onSelectUnit: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.metricUnit).toBe('bytes');
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -247,11 +247,13 @@ describe('usePanelEditorQuerySync', () => {
|
||||
});
|
||||
|
||||
describe('datasource switch', () => {
|
||||
const withSource = (id: string, dataSource: string): Query =>
|
||||
const withSource = (id: string, ...dataSources: string[]): Query =>
|
||||
({
|
||||
id,
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [{ dataSource }] },
|
||||
builder: {
|
||||
queryData: dataSources.map((dataSource) => ({ dataSource })),
|
||||
},
|
||||
}) as unknown as Query;
|
||||
|
||||
it('commits the active query when a query datasource changes', () => {
|
||||
@@ -286,6 +288,58 @@ describe('usePanelEditorQuerySync', () => {
|
||||
|
||||
expect(setSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not commit when a query is added (the fresh query must not auto-run)', () => {
|
||||
const state = builderState({ currentQuery: withSource('a', 'metrics') });
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
|
||||
const { setSpec, rerender } = setup();
|
||||
setSpec.mockClear();
|
||||
|
||||
state.currentQuery = withSource('b', 'metrics', 'metrics');
|
||||
rerender();
|
||||
|
||||
expect(setSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('commits when a query is removed', () => {
|
||||
const state = builderState({
|
||||
currentQuery: withSource('a', 'metrics', 'logs'),
|
||||
});
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
|
||||
const { setSpec, rerender } = setup();
|
||||
setSpec.mockClear();
|
||||
|
||||
state.currentQuery = withSource('b', 'metrics');
|
||||
rerender();
|
||||
|
||||
expect(setSpec).toHaveBeenCalledWith({
|
||||
...makeDraft().spec,
|
||||
queries: CONVERTED_QUERIES,
|
||||
});
|
||||
});
|
||||
|
||||
it('commits a datasource switch on a query added after mount', () => {
|
||||
const state = builderState({ currentQuery: withSource('a', 'metrics') });
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
|
||||
const { setSpec, rerender } = setup();
|
||||
setSpec.mockClear();
|
||||
|
||||
state.currentQuery = withSource('b', 'metrics', 'metrics');
|
||||
rerender();
|
||||
state.currentQuery = withSource('c', 'metrics', 'logs');
|
||||
rerender();
|
||||
|
||||
expect(setSpec).toHaveBeenCalledWith({
|
||||
...makeDraft().spec,
|
||||
queries: CONVERTED_QUERIES,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('query dirty + save', () => {
|
||||
|
||||
@@ -24,6 +24,8 @@ jest.mock('api/generated/services/dashboard', () => ({
|
||||
getGetDashboardV2QueryKey: jest.fn(() => ['/api/v2/dashboards/dash-1']),
|
||||
}));
|
||||
|
||||
jest.mock('uuid', () => ({ v4: (): string => 'minted-panel-id' }));
|
||||
|
||||
describe('usePanelEditorSave', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -44,7 +46,7 @@ describe('usePanelEditorSave', () => {
|
||||
queries: [],
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
|
||||
await result.current.save(spec);
|
||||
const savedPanelId = await result.current.save(spec);
|
||||
|
||||
expect(mockPatchAsync).toHaveBeenCalledWith([
|
||||
{
|
||||
@@ -53,6 +55,25 @@ describe('usePanelEditorSave', () => {
|
||||
value: spec,
|
||||
},
|
||||
]);
|
||||
// Editing resolves with the panel's own id.
|
||||
expect(savedPanelId).toBe('panel-9');
|
||||
});
|
||||
|
||||
it('mints and resolves with a fresh id when creating a new panel', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'new', isNew: true }),
|
||||
);
|
||||
|
||||
const spec = {
|
||||
display: { name: 'New panel' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: [],
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
|
||||
const savedPanelId = await result.current.save(spec);
|
||||
|
||||
expect(savedPanelId).toBe('minted-panel-id');
|
||||
expect(mockPatchAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces the patch in-flight state as isSaving', () => {
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
|
||||
|
||||
import type { TableColumnOption } from '../useTableColumns';
|
||||
import { useSeedMetricUnit } from '../useSeedMetricUnit';
|
||||
|
||||
jest.mock('hooks/useGetYAxisUnit', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseGetYAxisUnit = useGetYAxisUnit as unknown as jest.Mock;
|
||||
|
||||
function mockMetricUnit(
|
||||
yAxisUnit: string | undefined,
|
||||
isLoading = false,
|
||||
): void {
|
||||
mockUseGetYAxisUnit.mockReturnValue({ yAxisUnit, isLoading, isError: false });
|
||||
}
|
||||
|
||||
function makeSpec(formatting?: unknown): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
plugin: {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
spec: formatting ? { formatting } : {},
|
||||
},
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
function unit(spec: DashboardtypesPanelSpecDTO): unknown {
|
||||
return (spec.plugin.spec as { formatting?: { unit?: unknown } }).formatting
|
||||
?.unit;
|
||||
}
|
||||
|
||||
function columnUnits(spec: DashboardtypesPanelSpecDTO): unknown {
|
||||
return (spec.plugin.spec as { formatting?: { columnUnits?: unknown } })
|
||||
.formatting?.columnUnits;
|
||||
}
|
||||
|
||||
const COLUMNS: TableColumnOption[] = [
|
||||
{ key: 'A', label: 'A' },
|
||||
{ key: 'B', label: 'B' },
|
||||
];
|
||||
|
||||
const NO_COLUMNS: TableColumnOption[] = [];
|
||||
|
||||
describe('useSeedMetricUnit', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
describe('panel-wide unit (controls.unit)', () => {
|
||||
it('seeds formatting.unit from the metric on a new panel', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: { unit: true },
|
||||
columns: NO_COLUMNS,
|
||||
spec: makeSpec(),
|
||||
onChangeSpec,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(unit(onChangeSpec.mock.calls[0][0])).toBe('bytes');
|
||||
});
|
||||
|
||||
it('does not seed when not a new panel', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: false,
|
||||
formattingControls: { unit: true },
|
||||
columns: NO_COLUMNS,
|
||||
spec: makeSpec(),
|
||||
onChangeSpec,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onChangeSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not seed when the unit already matches the metric', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: { unit: true },
|
||||
columns: NO_COLUMNS,
|
||||
spec: makeSpec({ unit: 'bytes' }),
|
||||
onChangeSpec,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onChangeSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-seeds when the resolved metric unit changes', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(props: { spec: DashboardtypesPanelSpecDTO }) =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: { unit: true },
|
||||
columns: NO_COLUMNS,
|
||||
spec: props.spec,
|
||||
onChangeSpec,
|
||||
}),
|
||||
{ initialProps: { spec: makeSpec() } },
|
||||
);
|
||||
expect(unit(onChangeSpec.mock.calls[0][0])).toBe('bytes');
|
||||
|
||||
// Metric changes; the panel now holds the previously-seeded unit.
|
||||
mockMetricUnit('ms');
|
||||
rerender({ spec: makeSpec({ unit: 'bytes' }) });
|
||||
|
||||
expect(unit(onChangeSpec.mock.calls[1][0])).toBe('ms');
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-column units (controls.columnUnits)', () => {
|
||||
it('seeds every value column with the metric unit once columns resolve', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(props: { columns: TableColumnOption[] }) =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: { columnUnits: true },
|
||||
columns: props.columns,
|
||||
spec: makeSpec(),
|
||||
onChangeSpec,
|
||||
}),
|
||||
{ initialProps: { columns: NO_COLUMNS } },
|
||||
);
|
||||
// Waits for results to resolve the columns.
|
||||
expect(onChangeSpec).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ columns: COLUMNS });
|
||||
expect(columnUnits(onChangeSpec.mock.calls[0][0])).toStrictEqual({
|
||||
A: 'bytes',
|
||||
B: 'bytes',
|
||||
});
|
||||
});
|
||||
|
||||
it('never writes formatting.unit for a Table', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: { columnUnits: true, decimals: true },
|
||||
columns: COLUMNS,
|
||||
spec: makeSpec(),
|
||||
onChangeSpec,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(unit(onChangeSpec.mock.calls[0][0])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('only fills columns without a unit yet, keeping the user-set one', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: { columnUnits: true },
|
||||
columns: COLUMNS,
|
||||
spec: makeSpec({ columnUnits: { A: 'ms' } }),
|
||||
onChangeSpec,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(columnUnits(onChangeSpec.mock.calls[0][0])).toStrictEqual({
|
||||
A: 'ms',
|
||||
B: 'bytes',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not write when every column already has a unit', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: { columnUnits: true },
|
||||
columns: COLUMNS,
|
||||
spec: makeSpec({ columnUnits: { A: 'ms', B: 's' } }),
|
||||
onChangeSpec,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onChangeSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('seeds once and does not re-run after the metric unit changes', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(props: { metric: string }) => {
|
||||
mockMetricUnit(props.metric);
|
||||
return useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: { columnUnits: true },
|
||||
columns: COLUMNS,
|
||||
spec: makeSpec(),
|
||||
onChangeSpec,
|
||||
});
|
||||
},
|
||||
{ initialProps: { metric: 'bytes' } },
|
||||
);
|
||||
expect(onChangeSpec).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender({ metric: 'ms' });
|
||||
expect(onChangeSpec).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('seeds nothing when the kind has no unit control (Histogram/List)', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: undefined,
|
||||
columns: COLUMNS,
|
||||
spec: makeSpec(),
|
||||
onChangeSpec,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onChangeSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the resolved metric unit and loading state', () => {
|
||||
mockMetricUnit('bytes', true);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: false,
|
||||
formattingControls: { unit: true },
|
||||
columns: NO_COLUMNS,
|
||||
spec: makeSpec(),
|
||||
onChangeSpec: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.metricUnit).toBe('bytes');
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,30 +1,19 @@
|
||||
import { useMemo } from 'react';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import {
|
||||
flattenTimeSeries,
|
||||
getTimeSeriesResults,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
|
||||
export interface LegendSeries {
|
||||
/** Resolved display label — the key `legend.customColors` is indexed by. */
|
||||
label: string;
|
||||
/** The series' auto-assigned color, shown when no override is set. */
|
||||
defaultColor: string;
|
||||
}
|
||||
import {
|
||||
type LegendSeries,
|
||||
resolvePieLegendSeries,
|
||||
resolveTimeSeriesLegendSeries,
|
||||
} from '../utils/legendSeries';
|
||||
|
||||
/**
|
||||
* Resolves the panel's rendered series into `{ label, defaultColor }` pairs, using the
|
||||
* exact label resolution the time-series renderer applies (`flattenTimeSeries` →
|
||||
* `resolveSeriesLabelV5`) and the same `generateColor` default. The legend-colors control
|
||||
* keys overrides by these labels, so they must match what the chart draws. Deduplicated,
|
||||
* order-preserving; empty until data arrives or for kinds without flat time-series data.
|
||||
* Resolves the panel's rendered series into `{ label, defaultColor }` pairs so the
|
||||
* legend-colors control can key overrides by the exact labels the chart draws. Only the
|
||||
* kinds that expose a colors control resolve series (Pie from its scalar slices, Time
|
||||
* Series from its flat series); every other kind returns none.
|
||||
*/
|
||||
export function useLegendSeries(
|
||||
panel: DashboardtypesPanelDTO,
|
||||
@@ -33,27 +22,15 @@ export function useLegendSeries(
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
return useMemo(() => {
|
||||
const palette = isDarkMode
|
||||
? themeColors.chartcolors
|
||||
: themeColors.lightModeColor;
|
||||
const series = flattenTimeSeries(
|
||||
getTimeSeriesResults(data?.response),
|
||||
data.legendMap,
|
||||
);
|
||||
const builderQueries = getBuilderQueries(panel.spec.queries);
|
||||
|
||||
const byLabel = new Map<string, string>();
|
||||
series.forEach((s) => {
|
||||
const baseLabel = getLabelName(s.labels, s.queryName, s.legend);
|
||||
const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);
|
||||
if (label && !byLabel.has(label)) {
|
||||
byLabel.set(label, generateColor(label, palette));
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(byLabel, ([label, defaultColor]) => ({
|
||||
label,
|
||||
defaultColor,
|
||||
}));
|
||||
}, [panel.spec.queries, data.response, data.legendMap, isDarkMode]);
|
||||
switch (panel.spec.plugin.kind) {
|
||||
case 'signoz/PieChartPanel':
|
||||
return resolvePieLegendSeries(data, isDarkMode);
|
||||
case 'signoz/TimeSeriesPanel':
|
||||
case 'signoz/BarChartPanel':
|
||||
case 'signoz/HistogramPanel':
|
||||
return resolveTimeSeriesLegendSeries(panel.spec.queries, data, isDarkMode);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}, [panel.spec.plugin.kind, panel.spec.queries, data, isDarkMode]);
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
|
||||
|
||||
interface UseMetricYAxisUnitArgs {
|
||||
/** Only a new panel auto-seeds; editing never overwrites the saved unit. */
|
||||
isNewPanel: boolean;
|
||||
unit: string | undefined;
|
||||
onSelectUnit: (unit: string) => void;
|
||||
}
|
||||
|
||||
interface UseMetricYAxisUnitResult {
|
||||
metricUnit: string | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the selected metric's unit and, on a new panel only, seeds the formatting unit
|
||||
* from it (V1 parity); returns the unit for the selector's mismatch warning.
|
||||
*/
|
||||
export function useMetricYAxisUnit({
|
||||
isNewPanel,
|
||||
unit,
|
||||
onSelectUnit,
|
||||
}: UseMetricYAxisUnitArgs): UseMetricYAxisUnitResult {
|
||||
const { yAxisUnit: metricUnit, isLoading } = useGetYAxisUnit();
|
||||
|
||||
useEffect(() => {
|
||||
if (isNewPanel && metricUnit && metricUnit !== unit) {
|
||||
onSelectUnit(metricUnit);
|
||||
}
|
||||
// Re-seed only when the resolved metric unit changes, not on every unit edit.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isNewPanel, metricUnit]);
|
||||
|
||||
return { metricUnit, isLoading };
|
||||
}
|
||||
@@ -111,17 +111,27 @@ export function usePanelEditorQuerySync({
|
||||
|
||||
// Re-commit on a query-type/datasource switch so the preview refetches. Skip
|
||||
// mount: the draft already holds the saved queries the builder is reset to.
|
||||
const dataSourceSignature = useMemo(
|
||||
() =>
|
||||
(currentQuery.builder?.queryData ?? []).map((q) => q.dataSource).join(','),
|
||||
const dataSources = useMemo(
|
||||
() => (currentQuery.builder?.queryData ?? []).map((q) => q.dataSource),
|
||||
[currentQuery.builder],
|
||||
);
|
||||
const dataSourceSignature = dataSources.join(',');
|
||||
const prevDataSourcesRef = useRef(dataSources);
|
||||
const didMountRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const prev = prevDataSourcesRef.current;
|
||||
prevDataSourcesRef.current = dataSources;
|
||||
if (!didMountRef.current) {
|
||||
didMountRef.current = true;
|
||||
return;
|
||||
}
|
||||
// An added query is still empty — don't auto-run it; it commits on Run Query.
|
||||
const isQueryAdded =
|
||||
dataSources.length > prev.length &&
|
||||
prev.every((source, index) => source === dataSources[index]);
|
||||
if (isQueryAdded) {
|
||||
return;
|
||||
}
|
||||
commitRef.current(queryRef.current);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- structural change only
|
||||
}, [currentQuery.queryType, dataSourceSignature]);
|
||||
|
||||
@@ -23,7 +23,8 @@ interface UsePanelEditorSaveArgs {
|
||||
}
|
||||
|
||||
interface UsePanelEditorSaveApi {
|
||||
save: (spec: DashboardtypesPanelSpecDTO) => Promise<void>;
|
||||
/** Resolves with the saved panel's id (freshly minted when creating). */
|
||||
save: (spec: DashboardtypesPanelSpecDTO) => Promise<string>;
|
||||
isSaving: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
@@ -44,17 +45,20 @@ export function usePanelEditorSave({
|
||||
const { patchAsync, isPatching, error } = useOptimisticPatch(dashboardId);
|
||||
|
||||
const save = useCallback(
|
||||
async (spec: DashboardtypesPanelSpecDTO): Promise<void> => {
|
||||
async (spec: DashboardtypesPanelSpecDTO): Promise<string> => {
|
||||
let ops: DashboardtypesJSONPatchOperationDTO[];
|
||||
// The id a new panel is persisted under (surfaced so the caller can reveal it).
|
||||
let savedPanelId = panelId;
|
||||
if (isNew) {
|
||||
// Resolve the target section against the freshest dashboard we have.
|
||||
const dashboardQueryKey = getGetDashboardV2QueryKey({ id: dashboardId });
|
||||
const cached =
|
||||
queryClient.getQueryData<GetDashboardV2200>(dashboardQueryKey);
|
||||
savedPanelId = uuid();
|
||||
ops = createPanelOps({
|
||||
layouts: cached?.data.spec.layouts ?? [],
|
||||
layoutIndex,
|
||||
panelId: uuid(),
|
||||
panelId: savedPanelId,
|
||||
panel: { kind: DashboardtypesPanelKindDTO.Panel, spec },
|
||||
});
|
||||
} else {
|
||||
@@ -69,6 +73,7 @@ export function usePanelEditorSave({
|
||||
|
||||
// Optimistic cache write + settle refetch (replaces the manual invalidate).
|
||||
await patchAsync(ops);
|
||||
return savedPanelId;
|
||||
},
|
||||
[dashboardId, panelId, isNew, layoutIndex, patchAsync, queryClient],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
|
||||
import type {
|
||||
SectionControls,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import { readFormatting, writeFormatting } from '../utils/formattingSpec';
|
||||
import type { TableColumnOption } from './useTableColumns';
|
||||
|
||||
type FormattingControls = SectionControls[SectionKind.Formatting];
|
||||
|
||||
interface UseSeedMetricUnitArgs {
|
||||
/** Only a new panel auto-seeds; editing never overwrites a saved unit. */
|
||||
isNewPanel: boolean;
|
||||
/**
|
||||
* The current kind's Formatting controls — the single source of truth for which
|
||||
* field a metric unit seeds into: `unit` (panel-wide) vs `columnUnits` (Table).
|
||||
* A kind with neither (Histogram/List) seeds nothing.
|
||||
*/
|
||||
formattingControls: FormattingControls | undefined;
|
||||
/** Resolved value columns (Table only; empty before results arrive / for other kinds). */
|
||||
columns: TableColumnOption[];
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
}
|
||||
|
||||
interface UseSeedMetricUnitResult {
|
||||
metricUnit: string | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the selected metric's unit and, on a new panel only, seeds it into the panel's
|
||||
* formatting — into `formatting.unit` for kinds with a panel-wide unit control, or into
|
||||
* `formatting.columnUnits` (per value column) for a Table, which has no panel-wide unit.
|
||||
* The kind's Formatting `controls` decide which applies, mirroring `buildPluginSpec`'s
|
||||
* switch-time seeding so the two never diverge. Returns the unit for the FormattingSection's
|
||||
* mismatch warning.
|
||||
*/
|
||||
export function useSeedMetricUnit({
|
||||
isNewPanel,
|
||||
formattingControls,
|
||||
columns,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
}: UseSeedMetricUnitArgs): UseSeedMetricUnitResult {
|
||||
const { yAxisUnit: metricUnit, isLoading } = useGetYAxisUnit();
|
||||
|
||||
const seedsUnit = isNewPanel && !!formattingControls?.unit;
|
||||
const seedsColumnUnits = isNewPanel && !!formattingControls?.columnUnits;
|
||||
|
||||
// Panel-wide unit: seed (and re-seed) whenever the resolved metric unit changes. Kept
|
||||
// off `spec` so a manual unit edit doesn't re-run this and fight the user.
|
||||
useEffect(() => {
|
||||
if (!seedsUnit || !metricUnit || metricUnit === readFormatting(spec)?.unit) {
|
||||
return;
|
||||
}
|
||||
onChangeSpec(writeFormatting(spec, { unit: metricUnit }));
|
||||
// Re-seed only when the resolved metric unit changes, not on every unit edit.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [seedsUnit, metricUnit]);
|
||||
|
||||
// Per-column units (Table): seed once, only for columns without a unit yet, so it
|
||||
// never clobbers a user's edit or a cleared column. Waits for results to resolve them.
|
||||
const seededColumnsRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (
|
||||
!seedsColumnUnits ||
|
||||
seededColumnsRef.current ||
|
||||
!metricUnit ||
|
||||
columns.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const columnUnits = readFormatting(spec)?.columnUnits ?? {};
|
||||
const unset = columns.filter(
|
||||
(column) => columnUnits[column.key] === undefined,
|
||||
);
|
||||
seededColumnsRef.current = true;
|
||||
if (unset.length === 0) {
|
||||
return;
|
||||
}
|
||||
const nextColumnUnits = { ...columnUnits };
|
||||
unset.forEach((column) => {
|
||||
nextColumnUnits[column.key] = metricUnit;
|
||||
});
|
||||
onChangeSpec(writeFormatting(spec, { columnUnits: nextColumnUnits }));
|
||||
// Seed once columns first resolve with a unit; not on later spec edits.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [seedsColumnUnits, metricUnit, columns]);
|
||||
|
||||
return { metricUnit, isLoading };
|
||||
}
|
||||
@@ -8,25 +8,29 @@ import {
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
type DashboardtypesPanelDTO,
|
||||
type DashboardtypesPanelFormattingDTO,
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import {
|
||||
type SectionConfig,
|
||||
type SectionControls,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
|
||||
import { getExecStats } from '../queryV5/v5ResponseData';
|
||||
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
|
||||
import { useScrollIntoViewStore } from '../store/useScrollIntoViewStore';
|
||||
import ConfigPane from './ConfigPane/ConfigPane';
|
||||
import Header from './Header/Header';
|
||||
import layoutStorage from './layoutStorage';
|
||||
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder/PanelEditorQueryBuilder';
|
||||
import PreviewPane from './PreviewPane/PreviewPane';
|
||||
import { useLegendSeries } from './hooks/useLegendSeries';
|
||||
import { useMetricYAxisUnit } from './hooks/useMetricYAxisUnit';
|
||||
import { usePanelEditSession } from './hooks/usePanelEditSession';
|
||||
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
|
||||
import { useSeedMetricUnit } from './hooks/useSeedMetricUnit';
|
||||
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
|
||||
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
|
||||
import { useSwitchToViewMode } from './hooks/useSwitchToViewMode';
|
||||
@@ -124,32 +128,20 @@ function PanelEditorContainer({
|
||||
|
||||
const panelKind = draft.spec.plugin.kind;
|
||||
|
||||
// At editor level, not the collapsible FormattingSection, so seeding runs while closed.
|
||||
const formattingUnit = (
|
||||
spec.plugin.spec as {
|
||||
formatting?: DashboardtypesPanelFormattingDTO;
|
||||
}
|
||||
).formatting?.unit;
|
||||
const seedFormattingUnit = useCallback(
|
||||
(unit: string): void => {
|
||||
const pluginSpec = spec.plugin.spec as {
|
||||
formatting?: DashboardtypesPanelFormattingDTO;
|
||||
};
|
||||
setSpec({
|
||||
...spec,
|
||||
plugin: {
|
||||
...spec.plugin,
|
||||
spec: { ...pluginSpec, formatting: { ...pluginSpec.formatting, unit } },
|
||||
},
|
||||
} as DashboardtypesPanelSpecDTO);
|
||||
},
|
||||
[spec, setSpec],
|
||||
);
|
||||
const { metricUnit } = useMetricYAxisUnit({
|
||||
isNewPanel: isNew,
|
||||
unit: formattingUnit,
|
||||
onSelectUnit: seedFormattingUnit,
|
||||
});
|
||||
// The current kind's Formatting controls — which unit field (panel-wide `unit` vs
|
||||
// per-column `columnUnits`) a metric unit may seed into. Same source of truth the
|
||||
// switch-time seeding in `buildPluginSpec` reads, so the two stay in lockstep.
|
||||
const formattingControls = useMemo(():
|
||||
| SectionControls[SectionKind.Formatting]
|
||||
| undefined => {
|
||||
const section = panelDefinition.sections.find(
|
||||
(
|
||||
candidate,
|
||||
): candidate is Extract<SectionConfig, { kind: SectionKind.Formatting }> =>
|
||||
candidate.kind === SectionKind.Formatting,
|
||||
);
|
||||
return section?.controls;
|
||||
}, [panelDefinition]);
|
||||
|
||||
// A new panel is savable once it has a query to run — List auto-seeds one; other
|
||||
// kinds open query-less, so there's nothing to save until the user builds one.
|
||||
@@ -188,6 +180,17 @@ function PanelEditorContainer({
|
||||
const legendSeries = useLegendSeries(draft, data);
|
||||
const tableColumns = useTableColumns(draft, data);
|
||||
|
||||
// Resolves the selected metric's unit and, on a new panel, seeds it into the right
|
||||
// formatting field for the kind (panel-wide `unit`, or per-column `columnUnits` for
|
||||
// a Table once results resolve them). `metricUnit` also drives the mismatch warning.
|
||||
const { metricUnit } = useSeedMetricUnit({
|
||||
isNewPanel: isNew,
|
||||
formattingControls,
|
||||
columns: tableColumns,
|
||||
spec,
|
||||
onChangeSpec: setSpec,
|
||||
});
|
||||
|
||||
// Smallest query step interval (seconds) — the floor for the span-gaps
|
||||
// threshold. Undefined until results carry step metadata.
|
||||
const stepInterval = useMemo((): number | undefined => {
|
||||
@@ -203,19 +206,33 @@ function PanelEditorContainer({
|
||||
query: currentQuery,
|
||||
});
|
||||
|
||||
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
|
||||
|
||||
const onSave = useCallback(async (): Promise<void> => {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Bake the live query into the spec so unstaged edits are saved too.
|
||||
await save(buildSaveSpec(draft.spec));
|
||||
const savedPanelId = await save(buildSaveSpec(draft.spec));
|
||||
// Reveal the saved panel once the dashboard re-renders.
|
||||
setScrollTargetId(savedPanelId);
|
||||
toast.success('Panel saved');
|
||||
onSaved();
|
||||
} catch {
|
||||
toast.error('Failed to save panel');
|
||||
}
|
||||
}, [isEditable, save, buildSaveSpec, draft.spec, onSaved]);
|
||||
}, [isEditable, save, buildSaveSpec, draft.spec, setScrollTargetId, onSaved]);
|
||||
|
||||
// Leaving an existing panel's editor (without saving) still returns to it, so
|
||||
// the dashboard lands on that panel rather than scrolled to the top. A new,
|
||||
// unsaved panel has no persisted target, so there's nothing to reveal.
|
||||
const onCloseEditor = useCallback((): void => {
|
||||
if (!isNew) {
|
||||
setScrollTargetId(panelId);
|
||||
}
|
||||
onClose();
|
||||
}, [isNew, panelId, setScrollTargetId, onClose]);
|
||||
|
||||
return (
|
||||
<div className={styles.page} data-testid="panel-editor-v2">
|
||||
@@ -227,7 +244,7 @@ function PanelEditorContainer({
|
||||
readOnlyReason={editDisabledReason}
|
||||
onSave={onSave}
|
||||
onSwitchToView={onSwitchToView}
|
||||
onClose={onClose}
|
||||
onClose={onCloseEditor}
|
||||
/>
|
||||
<ResizablePanelGroup
|
||||
id="panel-editor-v2"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { readFormatting, writeFormatting } from '../formattingSpec';
|
||||
|
||||
function makeSpec(formatting?: unknown): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
plugin: {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
spec: formatting ? { formatting } : {},
|
||||
},
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
describe('formattingSpec', () => {
|
||||
it('reads the formatting slice (undefined when absent)', () => {
|
||||
expect(readFormatting(makeSpec())).toBeUndefined();
|
||||
expect(readFormatting(makeSpec({ unit: 'bytes' }))).toStrictEqual({
|
||||
unit: 'bytes',
|
||||
});
|
||||
});
|
||||
|
||||
it('merges the patch into the formatting slice, preserving other fields', () => {
|
||||
const next = writeFormatting(makeSpec({ decimalPrecision: '2' }), {
|
||||
unit: 'bytes',
|
||||
});
|
||||
expect(readFormatting(next)).toStrictEqual({
|
||||
decimalPrecision: '2',
|
||||
unit: 'bytes',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mutate the input spec', () => {
|
||||
const spec = makeSpec({ unit: 'ms' });
|
||||
writeFormatting(spec, { unit: 'bytes' });
|
||||
expect(readFormatting(spec)).toStrictEqual({ unit: 'ms' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PanelFormattingSlice } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
|
||||
// `spec.plugin.spec` is a discriminated union over panel kinds; these helpers narrow
|
||||
// to the shared `formatting` slice via a single localized cast at the boundary, so
|
||||
// callers read/write it without repeating the spread.
|
||||
|
||||
export function readFormatting(
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
): PanelFormattingSlice | undefined {
|
||||
return (spec.plugin.spec as { formatting?: PanelFormattingSlice }).formatting;
|
||||
}
|
||||
|
||||
/** Merges a partial formatting patch into the panel's `formatting` slice. */
|
||||
export function writeFormatting(
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
patch: Partial<PanelFormattingSlice>,
|
||||
): DashboardtypesPanelSpecDTO {
|
||||
const pluginSpec = spec.plugin.spec as { formatting?: PanelFormattingSlice };
|
||||
return {
|
||||
...spec,
|
||||
plugin: {
|
||||
...spec.plugin,
|
||||
spec: { ...pluginSpec, formatting: { ...pluginSpec.formatting, ...patch } },
|
||||
},
|
||||
} as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { preparePieData } from 'pages/DashboardPageV2/DashboardContainer/Panels/kinds/PieChartPanel/prepareData';
|
||||
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
import { prepareScalarTables } from 'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import {
|
||||
flattenTimeSeries,
|
||||
getScalarResults,
|
||||
getTimeSeriesResults,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
|
||||
export interface LegendSeries {
|
||||
/** Resolved display label — the key `legend.customColors` is indexed by. */
|
||||
label: string;
|
||||
/** The series' auto-assigned color, shown when no override is set. */
|
||||
defaultColor: string;
|
||||
}
|
||||
|
||||
type PanelQueries = DashboardtypesPanelDTO['spec']['queries'];
|
||||
|
||||
/**
|
||||
* Dedupes `labels` (first-seen order, empties dropped) into `{ label, defaultColor }`
|
||||
* pairs, resolving each unique label's color lazily via `colorFor` — so a repeated
|
||||
* label never resolves a second color.
|
||||
*/
|
||||
function buildLegendSeries(
|
||||
labels: readonly string[],
|
||||
colorFor: (label: string, index: number) => string,
|
||||
): LegendSeries[] {
|
||||
const byLabel = new Map<string, string>();
|
||||
labels.forEach((label, index) => {
|
||||
if (label && !byLabel.has(label)) {
|
||||
byLabel.set(label, colorFor(label, index));
|
||||
}
|
||||
});
|
||||
return Array.from(byLabel, ([label, defaultColor]) => ({
|
||||
label,
|
||||
defaultColor,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pie is fed by scalar results, not time series. Reuse the exact slices the renderer
|
||||
* draws (without overrides, so their colors are the defaults) so the color control keys
|
||||
* overrides by the same labels the chart does.
|
||||
*/
|
||||
export function resolvePieLegendSeries(
|
||||
data: PanelQueryData,
|
||||
isDarkMode: boolean,
|
||||
): LegendSeries[] {
|
||||
const slices = preparePieData({
|
||||
tables: prepareScalarTables({
|
||||
results: getScalarResults(data.response),
|
||||
legendMap: data.legendMap,
|
||||
requestPayload: data.requestPayload,
|
||||
}),
|
||||
isDarkMode,
|
||||
});
|
||||
return buildLegendSeries(
|
||||
slices.map((slice) => slice.label),
|
||||
(_, index) => slices[index].color,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Time-series kinds: resolve each flattened series' label the way the renderer does
|
||||
* (`getLabelName` → `resolveSeriesLabelV5`) and color it with `generateColor`.
|
||||
*/
|
||||
export function resolveTimeSeriesLegendSeries(
|
||||
queries: PanelQueries,
|
||||
data: PanelQueryData,
|
||||
isDarkMode: boolean,
|
||||
): LegendSeries[] {
|
||||
const palette = isDarkMode
|
||||
? themeColors.chartcolors
|
||||
: themeColors.lightModeColor;
|
||||
const builderQueries = getBuilderQueries(queries);
|
||||
const series = flattenTimeSeries(
|
||||
getTimeSeriesResults(data.response),
|
||||
data.legendMap,
|
||||
);
|
||||
return buildLegendSeries(
|
||||
series.map((s) =>
|
||||
resolveSeriesLabelV5(
|
||||
s,
|
||||
builderQueries,
|
||||
getLabelName(s.labels, s.queryName, s.legend),
|
||||
),
|
||||
),
|
||||
(label) => generateColor(label, palette),
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { CalendarRange, Clock, RotateCw } from '@signozhq/icons';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { panelHasFixedTimePreference } from '../../../hooks/resolvePanelTimeWindow';
|
||||
import {
|
||||
selectViewPanelExtendWindow,
|
||||
useViewPanelStore,
|
||||
@@ -15,6 +17,8 @@ interface NoDataProps {
|
||||
isFetching?: boolean;
|
||||
/** When provided, renders a Retry button that re-runs the query. */
|
||||
onRetry?: () => void;
|
||||
/** Hides the global "Extend time range" action when this panel is locked to a fixed time preference. */
|
||||
panel?: DashboardtypesPanelDTO;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
@@ -29,19 +33,30 @@ function NoData({
|
||||
description = 'Nothing in the selected window. Try widening the range.',
|
||||
isFetching = false,
|
||||
onRetry,
|
||||
panel,
|
||||
'data-testid': testId = 'panel-no-data',
|
||||
}: NoDataProps): JSX.Element {
|
||||
const viewExtend = useViewPanelStore(selectViewPanelExtendWindow);
|
||||
const globalExtend = useExtendTimeWindow();
|
||||
const { canExtend, actionLabel, extend } = viewExtend ?? globalExtend;
|
||||
// The View modal's local extender wins; the global one only applies to a panel that
|
||||
// follows the ambient window (a fixed preference can't be widened by it).
|
||||
const hasFixedTimePreference = panel
|
||||
? panelHasFixedTimePreference(panel)
|
||||
: false;
|
||||
const activeExtend =
|
||||
viewExtend ?? (hasFixedTimePreference ? undefined : globalExtend);
|
||||
|
||||
if (isFetching) {
|
||||
return <PanelLoader />;
|
||||
}
|
||||
|
||||
const extendAction: PanelMessageAction | undefined =
|
||||
canExtend && actionLabel
|
||||
? { label: actionLabel, onClick: extend, icon: <CalendarRange size={14} /> }
|
||||
activeExtend?.canExtend && activeExtend.actionLabel
|
||||
? {
|
||||
label: activeExtend.actionLabel,
|
||||
onClick: activeExtend.extend,
|
||||
icon: <CalendarRange size={14} />,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const retryAction: PanelMessageAction | undefined = onRetry
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
type DashboardtypesPanelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import { useViewPanelStore } from '../../../../store/useViewPanelStore';
|
||||
@@ -24,6 +28,15 @@ function extender(over?: Partial<ExtendTimeWindow>): ExtendTimeWindow {
|
||||
};
|
||||
}
|
||||
|
||||
/** Minimal panel whose plugin spec carries the given time preference. */
|
||||
function panelWith(
|
||||
timePreference?: DashboardtypesTimePreferenceDTO,
|
||||
): DashboardtypesPanelDTO {
|
||||
return {
|
||||
spec: { plugin: { spec: { visualization: { timePreference } } } },
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
describe('NoData', () => {
|
||||
beforeEach(() => {
|
||||
mockUseExtendTimeWindow.mockReturnValue(inert);
|
||||
@@ -119,4 +132,47 @@ describe('NoData', () => {
|
||||
|
||||
expect(screen.getByTestId('number-panel-no-data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the global extend action for a panel with a fixed time preference', () => {
|
||||
const onRetry = jest.fn();
|
||||
mockUseExtendTimeWindow.mockReturnValue(extender());
|
||||
render(
|
||||
<NoData
|
||||
onRetry={onRetry}
|
||||
panel={panelWith(DashboardtypesTimePreferenceDTO.last_6_hr)}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Only Retry — extending the dashboard window can't widen a locked panel span.
|
||||
const action = screen.getByTestId('panel-no-data-action');
|
||||
expect(action).toHaveTextContent('Retry');
|
||||
expect(
|
||||
screen.queryByTestId('panel-no-data-secondary-action'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('still offers the global extend action for a panel that follows the global window', () => {
|
||||
mockUseExtendTimeWindow.mockReturnValue(extender());
|
||||
render(
|
||||
<NoData panel={panelWith(DashboardtypesTimePreferenceDTO.global_time)} />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('panel-no-data-action')).toHaveTextContent(
|
||||
'Extend time range',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the View modal extender even when the panel has a fixed time preference', () => {
|
||||
const storeExtend = jest.fn();
|
||||
mockUseExtendTimeWindow.mockReturnValue(extender());
|
||||
useViewPanelStore.setState({
|
||||
viewPanelExtendWindow: extender({ extend: storeExtend }),
|
||||
});
|
||||
render(
|
||||
<NoData panel={panelWith(DashboardtypesTimePreferenceDTO.last_6_hr)} />,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-no-data-action'));
|
||||
expect(storeExtend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -193,7 +193,7 @@ function BarPanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
|
||||
)}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
|
||||
@@ -13,7 +13,7 @@ export const sections: SectionConfig[] = [
|
||||
},
|
||||
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
|
||||
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
|
||||
{ kind: SectionKind.Legend, controls: { position: true } },
|
||||
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
|
||||
{
|
||||
kind: SectionKind.Thresholds,
|
||||
controls: { variant: ThresholdVariant.LABEL },
|
||||
|
||||
@@ -106,7 +106,7 @@ function HistogramPanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
|
||||
)}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
|
||||
@@ -9,7 +9,7 @@ export const sections: SectionConfig[] = [
|
||||
},
|
||||
{
|
||||
kind: SectionKind.Legend,
|
||||
controls: { position: true },
|
||||
controls: { position: true, colors: true },
|
||||
// Merging all queries collapses to one distribution with no legend.
|
||||
isHidden: (spec): boolean =>
|
||||
Boolean(
|
||||
|
||||
@@ -143,7 +143,7 @@ function ListPanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{!table || dataSource.length === 0 ? (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
|
||||
@@ -130,6 +130,7 @@ function NumberPanelRenderer({
|
||||
data-testid="number-panel-no-data"
|
||||
isFetching={isFetching}
|
||||
onRetry={refetch}
|
||||
panel={panel}
|
||||
/>
|
||||
) : (
|
||||
<ValueDisplay
|
||||
|
||||
@@ -95,7 +95,7 @@ function PiePanelRenderer({
|
||||
return (
|
||||
<div data-testid="pie-panel-renderer" className={PanelStyles.panelContainer}>
|
||||
{slices.length === 0 ? (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
|
||||
) : (
|
||||
<Pie
|
||||
data={slices}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { SectionKind, type SectionConfig } from '../../types/sections';
|
||||
|
||||
// Pie has no axes, thresholds, or stacking — just value formatting and a legend.
|
||||
// Legend `colors` is omitted: the pie legend is always interactive swatches.
|
||||
// Pie has no axes, thresholds, or stacking — just value formatting and a legend
|
||||
// (position + per-slice color overrides).
|
||||
export const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.Visualization,
|
||||
controls: { switchPanelKind: true, timePreference: true },
|
||||
},
|
||||
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
|
||||
{ kind: SectionKind.Legend, controls: { position: true } },
|
||||
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
|
||||
{ kind: SectionKind.ContextLinks },
|
||||
];
|
||||
|
||||
@@ -160,7 +160,7 @@ function TablePanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{!table || dataSource.length === 0 ? (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
|
||||
) : (
|
||||
<div className={styles.container}>
|
||||
<Table
|
||||
|
||||
@@ -194,7 +194,7 @@ function TimeSeriesPanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
|
||||
)}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
|
||||
@@ -28,14 +28,36 @@ const mockDefaultColumnsForSignal =
|
||||
defaultColumnsForSignal as unknown as jest.Mock;
|
||||
|
||||
/** A panel spec carrying the plugin.spec a seed reads; the rest of the shape is irrelevant. */
|
||||
function oldSpecWith(pluginSpec: unknown): DashboardtypesPanelSpecDTO {
|
||||
function oldSpecWith(
|
||||
pluginSpec: unknown,
|
||||
queries: unknown[] = [],
|
||||
): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
display: { name: 'Panel' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: pluginSpec },
|
||||
queries: [],
|
||||
queries,
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
function builderQueryNamed(name: string): unknown {
|
||||
return {
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/BuilderQuery',
|
||||
spec: { name, aggregations: [{ expression: 'count()' }] },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function compositeQueryWith(envelopes: unknown[]): unknown {
|
||||
return {
|
||||
spec: {
|
||||
plugin: { kind: 'signoz/CompositeQuery', spec: { queries: envelopes } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockDefaultColumnsForSignal.mockReturnValue([]);
|
||||
@@ -47,16 +69,15 @@ describe('buildPluginSpec', () => {
|
||||
expect(buildPluginSpec([])).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('seeds nothing for sections with no seed (Axes, Buckets, ContextLinks)', () => {
|
||||
it('seeds nothing for sections with no seed (Buckets, ContextLinks)', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
|
||||
{ kind: SectionKind.Buckets, controls: { count: true, width: true } },
|
||||
{ kind: SectionKind.ContextLinks },
|
||||
];
|
||||
expect(buildPluginSpec(sections)).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('omits the key entirely when a seed returns undefined (never key: undefined)', () => {
|
||||
it('omits the key entirely when a seed produces an empty slice (never key: undefined)', () => {
|
||||
const result = buildPluginSpec([
|
||||
{ kind: SectionKind.Legend, controls: { colors: true } },
|
||||
]);
|
||||
@@ -112,6 +133,108 @@ describe('buildPluginSpec', () => {
|
||||
];
|
||||
expect(buildPluginSpec(sections)).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('carries old timePreference / stacking / fillSpans the target declares', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.Visualization,
|
||||
controls: {
|
||||
switchPanelKind: true,
|
||||
timePreference: true,
|
||||
stacking: true,
|
||||
fillSpans: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
visualization: {
|
||||
timePreference: DashboardtypesTimePreferenceDTO.last_6_hr,
|
||||
stackedBarChart: true,
|
||||
fillSpans: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).visualization).toStrictEqual({
|
||||
timePreference: DashboardtypesTimePreferenceDTO.last_6_hr,
|
||||
stackedBarChart: true,
|
||||
fillSpans: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('drops visualization fields the target does not declare (Bar → TimeSeries)', () => {
|
||||
// TimeSeries has no stacking control, so Bar's stackedBarChart must not carry.
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.Visualization,
|
||||
controls: { switchPanelKind: true, timePreference: true, fillSpans: true },
|
||||
},
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
visualization: { stackedBarChart: true },
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).visualization).toStrictEqual({
|
||||
timePreference: DashboardtypesTimePreferenceDTO.global_time,
|
||||
});
|
||||
});
|
||||
|
||||
it('carries old legend position but never customColors', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
legend: {
|
||||
position: DashboardtypesLegendPositionDTO.right,
|
||||
customColors: { 'series-a': '#F1575F' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).legend).toStrictEqual({
|
||||
position: DashboardtypesLegendPositionDTO.right,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('axes seed (carry, gated by controls)', () => {
|
||||
it('carries softMin/softMax/isLogScale when the kind declares both controls', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
axes: { softMin: 0, softMax: 100, isLogScale: true },
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).axes).toStrictEqual({
|
||||
softMin: 0,
|
||||
softMax: 100,
|
||||
isLogScale: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('carries only the fields the target controls declare', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Axes, controls: { logScale: true } },
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
axes: { softMin: 0, softMax: 100, isLogScale: true },
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).axes).toStrictEqual({
|
||||
isLogScale: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips null soft bounds and seeds nothing on a new panel or empty axes', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
|
||||
];
|
||||
expect(buildPluginSpec(sections)).toStrictEqual({});
|
||||
expect(
|
||||
buildPluginSpec(sections, {
|
||||
oldSpec: oldSpecWith({ axes: { softMin: null, softMax: null } }),
|
||||
}),
|
||||
).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('chartAppearance seed', () => {
|
||||
@@ -137,6 +260,30 @@ describe('buildPluginSpec', () => {
|
||||
];
|
||||
expect(buildPluginSpec(sections)).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('carries old values over the defaults, gated by the declared controls', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.ChartAppearance,
|
||||
controls: { lineStyle: true, lineInterpolation: true, showPoints: true },
|
||||
},
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
chartAppearance: {
|
||||
lineStyle: DashboardtypesLineStyleDTO.dashed,
|
||||
fillMode: DashboardtypesFillModeDTO.gradient,
|
||||
showPoints: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).chartAppearance).toStrictEqual(
|
||||
{
|
||||
lineStyle: DashboardtypesLineStyleDTO.dashed,
|
||||
lineInterpolation: DashboardtypesLineInterpolationDTO.spline,
|
||||
showPoints: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatting seed (carry, gated by controls)', () => {
|
||||
@@ -154,7 +301,7 @@ describe('buildPluginSpec', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('drops unit when the target kind does not declare it (TimeSeries → Table)', () => {
|
||||
it('drops the panel-wide unit when no column keys are derivable (TimeSeries → Table, no queries)', () => {
|
||||
// Table formatting has columnUnits + decimals only; carrying unit breaks the save.
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
@@ -171,6 +318,56 @@ describe('buildPluginSpec', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('fans the panel-wide unit out to every value column (TimeSeries → Table)', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.Formatting,
|
||||
controls: { decimals: true, columnUnits: true },
|
||||
},
|
||||
];
|
||||
const oldSpec = oldSpecWith(
|
||||
{ formatting: { unit: 'ms', decimalPrecision: 2 } },
|
||||
[
|
||||
compositeQueryWith([
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
aggregations: [{ expression: 'count()' }, { expression: 'sum(bytes)' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: { name: 'B', aggregations: [{ expression: 'count()' }] },
|
||||
},
|
||||
]),
|
||||
],
|
||||
);
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).formatting).toStrictEqual({
|
||||
decimalPrecision: 2,
|
||||
columnUnits: {
|
||||
'A.count()': 'ms',
|
||||
'A.sum(bytes)': 'ms',
|
||||
B: 'ms',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('never seeds a panel-wide unit from per-column units (Table → TimeSeries)', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
|
||||
];
|
||||
const oldSpec = oldSpecWith(
|
||||
{ formatting: { columnUnits: { A: 'ms', B: 'ns' }, decimalPrecision: 2 } },
|
||||
[builderQueryNamed('A')],
|
||||
);
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).formatting).toStrictEqual({
|
||||
decimalPrecision: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('carries a decimalPrecision of 0 (falsy but defined) and omits missing fields', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
|
||||
@@ -225,12 +422,14 @@ describe('buildPluginSpec', () => {
|
||||
function switchThresholds(
|
||||
variant: ThresholdVariant | undefined,
|
||||
thresholds: unknown[],
|
||||
queries: unknown[] = [],
|
||||
): unknown {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Thresholds, controls: { variant } },
|
||||
];
|
||||
return buildPluginSpec(sections, { oldSpec: oldSpecWith({ thresholds }) })
|
||||
.thresholds;
|
||||
return buildPluginSpec(sections, {
|
||||
oldSpec: oldSpecWith({ thresholds }, queries),
|
||||
}).thresholds;
|
||||
}
|
||||
|
||||
it('keeps color/value/unit/label within the label variant (and defaults to label)', () => {
|
||||
@@ -258,27 +457,55 @@ describe('buildPluginSpec', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves existing operator/format when remapping comparison → table', () => {
|
||||
it('preserves operator/format and keys onto the first query column when remapping comparison → table', () => {
|
||||
expect(
|
||||
switchThresholds(ThresholdVariant.TABLE, [
|
||||
{
|
||||
value: 80,
|
||||
color: '#F1575F',
|
||||
operator: DashboardtypesComparisonOperatorDTO.below,
|
||||
format: DashboardtypesThresholdFormatDTO.text,
|
||||
},
|
||||
]),
|
||||
switchThresholds(
|
||||
ThresholdVariant.TABLE,
|
||||
[
|
||||
{
|
||||
value: 80,
|
||||
color: '#F1575F',
|
||||
operator: DashboardtypesComparisonOperatorDTO.below,
|
||||
format: DashboardtypesThresholdFormatDTO.text,
|
||||
},
|
||||
],
|
||||
[builderQueryNamed('A')],
|
||||
),
|
||||
).toStrictEqual([
|
||||
{
|
||||
value: 80,
|
||||
color: '#F1575F',
|
||||
operator: DashboardtypesComparisonOperatorDTO.below,
|
||||
format: DashboardtypesThresholdFormatDTO.text,
|
||||
columnName: '',
|
||||
columnName: 'A',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps an existing columnName instead of the derived default', () => {
|
||||
expect(
|
||||
switchThresholds(
|
||||
ThresholdVariant.TABLE,
|
||||
[{ value: 80, color: '#F1575F', columnName: 'p99' }],
|
||||
[builderQueryNamed('A')],
|
||||
),
|
||||
).toStrictEqual([
|
||||
{
|
||||
value: 80,
|
||||
color: '#F1575F',
|
||||
operator: DashboardtypesComparisonOperatorDTO.above,
|
||||
format: DashboardtypesThresholdFormatDTO.background,
|
||||
columnName: 'p99',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops table thresholds when no column can be derived (empty columnName fails the save)', () => {
|
||||
expect(
|
||||
switchThresholds(ThresholdVariant.TABLE, [{ value: 80, color: '#F1575F' }]),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops table-only operator/format/columnName when remapping table → label', () => {
|
||||
expect(
|
||||
switchThresholds(ThresholdVariant.LABEL, [
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { getTableColumnKeys } from '../getTableColumnKeys';
|
||||
|
||||
function bareBuilderQuery(spec: unknown): DashboardtypesQueryDTO {
|
||||
return {
|
||||
spec: { plugin: { kind: 'signoz/BuilderQuery', spec } },
|
||||
} as unknown as DashboardtypesQueryDTO;
|
||||
}
|
||||
|
||||
function compositeQuery(envelopes: unknown[]): DashboardtypesQueryDTO {
|
||||
return {
|
||||
spec: {
|
||||
plugin: { kind: 'signoz/CompositeQuery', spec: { queries: envelopes } },
|
||||
},
|
||||
} as unknown as DashboardtypesQueryDTO;
|
||||
}
|
||||
|
||||
describe('getTableColumnKeys', () => {
|
||||
it('keys single-aggregation queries by name and multi-aggregation ones per expression (matches getColId)', () => {
|
||||
const queries = [
|
||||
compositeQuery([
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
aggregations: [{ expression: 'count()' }, { expression: 'sum(bytes)' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: { name: 'B', aggregations: [{ expression: 'count()' }] },
|
||||
},
|
||||
]),
|
||||
];
|
||||
expect(getTableColumnKeys(queries)).toStrictEqual([
|
||||
'A.count()',
|
||||
'A.sum(bytes)',
|
||||
'B',
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips disabled queries', () => {
|
||||
const queries = [
|
||||
compositeQuery([
|
||||
{ type: 'builder_query', spec: { name: 'A', disabled: true } },
|
||||
{ type: 'builder_query', spec: { name: 'B' } },
|
||||
]),
|
||||
];
|
||||
expect(getTableColumnKeys(queries)).toStrictEqual(['B']);
|
||||
});
|
||||
|
||||
it('keys non-builder envelopes by name but skips clickhouse_sql (columns keyed by unknown SQL alias)', () => {
|
||||
const queries = [
|
||||
compositeQuery([
|
||||
{ type: 'builder_formula', spec: { name: 'F1', expression: 'A * 2' } },
|
||||
{ type: 'clickhouse_sql', spec: { name: 'CH1', query: 'SELECT 1' } },
|
||||
]),
|
||||
];
|
||||
expect(getTableColumnKeys(queries)).toStrictEqual(['F1']);
|
||||
});
|
||||
|
||||
it('reads a bare builder-query envelope', () => {
|
||||
expect(getTableColumnKeys([bareBuilderQuery({ name: 'A' })])).toStrictEqual([
|
||||
'A',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns no keys when there is no enabled named query', () => {
|
||||
expect(getTableColumnKeys([])).toStrictEqual([]);
|
||||
expect(
|
||||
getTableColumnKeys([
|
||||
compositeQuery([
|
||||
{ type: 'builder_query', spec: { name: 'A', disabled: true } },
|
||||
]),
|
||||
]),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { defaultColumnsForSignal } from '../../PanelEditor/ListColumnsEditor/selectFields';
|
||||
import {
|
||||
type AnyThreshold,
|
||||
type ControlledSectionKind,
|
||||
type PanelFormattingSlice,
|
||||
type SectionConfig,
|
||||
type SectionControls,
|
||||
@@ -20,13 +21,18 @@ import {
|
||||
type SectionSpecMap,
|
||||
ThresholdVariant,
|
||||
} from '../types/sections';
|
||||
import { getTableColumnKeys } from './getTableColumnKeys';
|
||||
|
||||
/** Cross-section of the per-kind spec union; assigned to `plugin.spec` (unknown) at the boundary. */
|
||||
export interface SeededPluginSpec {
|
||||
visualization?: SectionSpecMap[SectionKind.Visualization];
|
||||
axes?: SectionSpecMap[SectionKind.Axes];
|
||||
legend?: SectionSpecMap[SectionKind.Legend];
|
||||
chartAppearance?: SectionSpecMap[SectionKind.ChartAppearance];
|
||||
formatting?: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'>;
|
||||
formatting?: Pick<
|
||||
PanelFormattingSlice,
|
||||
'unit' | 'decimalPrecision' | 'columnUnits'
|
||||
>;
|
||||
selectFields?: SectionSpecMap[SectionKind.Columns];
|
||||
thresholds?: AnyThreshold[];
|
||||
}
|
||||
@@ -37,6 +43,11 @@ export interface SeedContext {
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
}
|
||||
|
||||
/** `SeedContext` plus `oldSpec.plugin.spec` (typed `unknown`) resolved once, so seeds read it without re-casting. */
|
||||
interface ResolvedSeedContext extends SeedContext {
|
||||
oldPluginSpec?: SeededPluginSpec;
|
||||
}
|
||||
|
||||
interface AnyThresholdFields {
|
||||
color: string;
|
||||
value: number;
|
||||
@@ -51,6 +62,7 @@ interface AnyThresholdFields {
|
||||
function toThresholdVariant(
|
||||
source: AnyThresholdFields,
|
||||
variant: ThresholdVariant,
|
||||
defaultColumnName?: string,
|
||||
): AnyThreshold {
|
||||
const core = {
|
||||
color: source.color,
|
||||
@@ -69,7 +81,7 @@ function toThresholdVariant(
|
||||
...core,
|
||||
operator: source.operator ?? DashboardtypesComparisonOperatorDTO.above,
|
||||
format: source.format ?? DashboardtypesThresholdFormatDTO.background,
|
||||
columnName: source.columnName ?? '',
|
||||
columnName: source.columnName || defaultColumnName || '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -79,97 +91,166 @@ function toThresholdVariant(
|
||||
}
|
||||
|
||||
/**
|
||||
* How one section derives its plugin-spec slice on create/switch — the single place a section
|
||||
* declares this. Sections absent from `SECTION_SEEDS` seed nothing.
|
||||
* How each section derives its plugin-spec slice on create/switch — the single place a section
|
||||
* declares this. Sections absent from `SECTION_SEEDS` seed nothing. Mapped over `SectionKind` so
|
||||
* every `seed` receives its own kind's `controls` (atomic kinds get `undefined`), no per-seed cast.
|
||||
* A `seed` returns an empty object/array (never `undefined`) when it has nothing to carry;
|
||||
* `buildPluginSpec` drops the empties so the omit decision lives in one place.
|
||||
*/
|
||||
interface SectionSeed {
|
||||
specKey: keyof SeededPluginSpec;
|
||||
seed: (controls: unknown, ctx: SeedContext) => unknown;
|
||||
type SectionSeeds = {
|
||||
[K in SectionKind]?: {
|
||||
specKey: keyof SeededPluginSpec;
|
||||
seed: (
|
||||
controls: K extends ControlledSectionKind ? SectionControls[K] : undefined,
|
||||
ctx: ResolvedSeedContext,
|
||||
) => object;
|
||||
};
|
||||
};
|
||||
|
||||
function isEmptySlice(value: object): boolean {
|
||||
return Array.isArray(value)
|
||||
? value.length === 0
|
||||
: Object.keys(value).length === 0;
|
||||
}
|
||||
|
||||
const SECTION_SEEDS: Partial<Record<SectionKind, SectionSeed>> = {
|
||||
const SECTION_SEEDS: SectionSeeds = {
|
||||
[SectionKind.Visualization]: {
|
||||
specKey: 'visualization',
|
||||
seed: (controls): SectionSpecMap[SectionKind.Visualization] | undefined => {
|
||||
const c = controls as SectionControls[SectionKind.Visualization];
|
||||
return c.timePreference
|
||||
? { timePreference: DashboardtypesTimePreferenceDTO.global_time }
|
||||
: undefined;
|
||||
seed: (
|
||||
controls,
|
||||
{ oldPluginSpec },
|
||||
): SectionSpecMap[SectionKind.Visualization] => {
|
||||
const old = oldPluginSpec?.visualization;
|
||||
return {
|
||||
...(controls.timePreference && {
|
||||
timePreference:
|
||||
old?.timePreference ?? DashboardtypesTimePreferenceDTO.global_time,
|
||||
}),
|
||||
...(controls.stacking &&
|
||||
old?.stackedBarChart !== undefined && {
|
||||
stackedBarChart: old.stackedBarChart,
|
||||
}),
|
||||
...(controls.fillSpans &&
|
||||
old?.fillSpans !== undefined && { fillSpans: old.fillSpans }),
|
||||
};
|
||||
},
|
||||
},
|
||||
[SectionKind.Axes]: {
|
||||
specKey: 'axes',
|
||||
seed: (controls, { oldPluginSpec }): SectionSpecMap[SectionKind.Axes] => {
|
||||
const old = oldPluginSpec?.axes;
|
||||
if (!old) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
...(controls.minMax &&
|
||||
typeof old.softMin === 'number' && { softMin: old.softMin }),
|
||||
...(controls.minMax &&
|
||||
typeof old.softMax === 'number' && { softMax: old.softMax }),
|
||||
...(controls.logScale &&
|
||||
old.isLogScale !== undefined && { isLogScale: old.isLogScale }),
|
||||
};
|
||||
},
|
||||
},
|
||||
[SectionKind.Legend]: {
|
||||
specKey: 'legend',
|
||||
seed: (controls): SectionSpecMap[SectionKind.Legend] | undefined => {
|
||||
const c = controls as SectionControls[SectionKind.Legend];
|
||||
return c.position
|
||||
? { position: DashboardtypesLegendPositionDTO.bottom }
|
||||
: undefined;
|
||||
seed: (controls, { oldPluginSpec }): SectionSpecMap[SectionKind.Legend] => {
|
||||
const old = oldPluginSpec?.legend;
|
||||
// customColors is keyed by series label, which the new kind may not reproduce.
|
||||
return controls.position
|
||||
? { position: old?.position ?? DashboardtypesLegendPositionDTO.bottom }
|
||||
: {};
|
||||
},
|
||||
},
|
||||
[SectionKind.ChartAppearance]: {
|
||||
specKey: 'chartAppearance',
|
||||
seed: (controls): SectionSpecMap[SectionKind.ChartAppearance] | undefined => {
|
||||
const c = controls as SectionControls[SectionKind.ChartAppearance];
|
||||
seed: (
|
||||
controls,
|
||||
{ oldPluginSpec },
|
||||
): SectionSpecMap[SectionKind.ChartAppearance] => {
|
||||
// One guard on the optional old slice, then read fields with carried-or-default values.
|
||||
const {
|
||||
lineStyle = DashboardtypesLineStyleDTO.solid,
|
||||
lineInterpolation = DashboardtypesLineInterpolationDTO.spline,
|
||||
fillMode = DashboardtypesFillModeDTO.none,
|
||||
showPoints,
|
||||
spanGaps,
|
||||
} = oldPluginSpec?.chartAppearance ?? {};
|
||||
const appearance: SectionSpecMap[SectionKind.ChartAppearance] = {};
|
||||
if (c.lineStyle) {
|
||||
appearance.lineStyle = DashboardtypesLineStyleDTO.solid;
|
||||
if (controls.lineStyle) {
|
||||
appearance.lineStyle = lineStyle;
|
||||
}
|
||||
if (c.lineInterpolation) {
|
||||
appearance.lineInterpolation = DashboardtypesLineInterpolationDTO.spline;
|
||||
if (controls.lineInterpolation) {
|
||||
appearance.lineInterpolation = lineInterpolation;
|
||||
}
|
||||
if (c.fillMode) {
|
||||
appearance.fillMode = DashboardtypesFillModeDTO.none;
|
||||
if (controls.fillMode) {
|
||||
appearance.fillMode = fillMode;
|
||||
}
|
||||
return Object.keys(appearance).length > 0 ? appearance : undefined;
|
||||
if (controls.showPoints && showPoints !== undefined) {
|
||||
appearance.showPoints = showPoints;
|
||||
}
|
||||
if (controls.spanGaps && spanGaps !== undefined) {
|
||||
appearance.spanGaps = spanGaps;
|
||||
}
|
||||
return appearance;
|
||||
},
|
||||
},
|
||||
[SectionKind.Formatting]: {
|
||||
specKey: 'formatting',
|
||||
seed: (
|
||||
controls,
|
||||
{ oldSpec },
|
||||
): Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'> | undefined => {
|
||||
const c = controls as SectionControls[SectionKind.Formatting];
|
||||
const old = (oldSpec?.plugin.spec as { formatting?: PanelFormattingSlice })
|
||||
?.formatting;
|
||||
{ oldSpec, oldPluginSpec },
|
||||
): NonNullable<SeededPluginSpec['formatting']> => {
|
||||
const old = oldPluginSpec?.formatting;
|
||||
// Carry a field only when the target kind declares it (e.g. Table has no `unit`),
|
||||
// else the save API rejects the spec.
|
||||
const carried: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'> = {
|
||||
...(c.unit && old?.unit !== undefined && { unit: old.unit }),
|
||||
...(c.decimals &&
|
||||
const carried: NonNullable<SeededPluginSpec['formatting']> = {
|
||||
...(controls.unit && old?.unit !== undefined && { unit: old.unit }),
|
||||
...(controls.decimals &&
|
||||
old?.decimalPrecision !== undefined && {
|
||||
decimalPrecision: old.decimalPrecision,
|
||||
}),
|
||||
};
|
||||
return Object.keys(carried).length > 0 ? carried : undefined;
|
||||
// A panel-wide unit fans out to every value column when the target keys units
|
||||
// per column (→ Table). One-way: `columnUnits` never seed a panel-wide `unit`.
|
||||
const unit = old?.unit;
|
||||
if (controls.columnUnits && unit) {
|
||||
const keys = getTableColumnKeys(oldSpec?.queries ?? []);
|
||||
if (keys.length > 0) {
|
||||
carried.columnUnits = Object.fromEntries(keys.map((key) => [key, unit]));
|
||||
}
|
||||
}
|
||||
return carried;
|
||||
},
|
||||
},
|
||||
[SectionKind.Columns]: {
|
||||
specKey: 'selectFields',
|
||||
seed: (
|
||||
_controls,
|
||||
{ signal },
|
||||
): SectionSpecMap[SectionKind.Columns] | undefined => {
|
||||
if (!signal) {
|
||||
return undefined;
|
||||
}
|
||||
const columns = defaultColumnsForSignal(signal);
|
||||
return columns.length > 0 ? columns : undefined;
|
||||
},
|
||||
seed: (_controls, { signal }): SectionSpecMap[SectionKind.Columns] =>
|
||||
signal ? defaultColumnsForSignal(signal) : [],
|
||||
},
|
||||
[SectionKind.Thresholds]: {
|
||||
specKey: 'thresholds',
|
||||
seed: (controls, { oldSpec }): AnyThreshold[] | undefined => {
|
||||
const c = controls as SectionControls[SectionKind.Thresholds];
|
||||
const variant = c.variant ?? ThresholdVariant.LABEL;
|
||||
const old = (oldSpec?.plugin.spec as { thresholds?: AnyThreshold[] | null })
|
||||
?.thresholds;
|
||||
seed: (controls, { oldSpec, oldPluginSpec }): AnyThreshold[] => {
|
||||
const variant = controls.variant ?? ThresholdVariant.LABEL;
|
||||
const old = oldPluginSpec?.thresholds;
|
||||
if (!old || old.length === 0) {
|
||||
return undefined;
|
||||
return [];
|
||||
}
|
||||
return old.map((threshold) =>
|
||||
toThresholdVariant(threshold as AnyThresholdFields, variant),
|
||||
// The save API rejects an empty table-threshold columnName.
|
||||
const defaultColumnName =
|
||||
variant === ThresholdVariant.TABLE
|
||||
? getTableColumnKeys(oldSpec?.queries ?? [])[0]
|
||||
: undefined;
|
||||
const mapped = old.map((threshold) =>
|
||||
toThresholdVariant(
|
||||
threshold as AnyThresholdFields,
|
||||
variant,
|
||||
defaultColumnName,
|
||||
),
|
||||
);
|
||||
return variant === ThresholdVariant.TABLE
|
||||
? mapped.filter((t) => (t as { columnName?: string }).columnName)
|
||||
: mapped;
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -184,14 +265,23 @@ export function buildPluginSpec(
|
||||
): SeededPluginSpec {
|
||||
const spec: SeededPluginSpec = {};
|
||||
|
||||
// One localized cast for all seeds: `plugin.spec` is typed `unknown`.
|
||||
const resolved: ResolvedSeedContext = {
|
||||
...ctx,
|
||||
oldPluginSpec: ctx.oldSpec?.plugin.spec as SeededPluginSpec | undefined,
|
||||
};
|
||||
|
||||
sections.forEach((section) => {
|
||||
const entry = SECTION_SEEDS[section.kind];
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
const controls = 'controls' in section ? section.controls : undefined;
|
||||
const value = entry.seed(controls, ctx);
|
||||
if (value !== undefined) {
|
||||
// The lookup can't prove `section.controls` matches `entry`'s key, so `entry.seed`
|
||||
// is a union of differently-typed fns; one boundary cast, in place of a per-seed one.
|
||||
const seed = entry.seed as (c: unknown, ctx: ResolvedSeedContext) => object;
|
||||
const value = seed(controls, resolved);
|
||||
if (!isEmptySlice(value)) {
|
||||
// specKey ↔ value correlation can't be proven across the lookup; one localized cast.
|
||||
(spec as Record<string, unknown>)[entry.specKey] = value;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { resolveContextLinkUrl } from '../resolveContextLinkUrl';
|
||||
|
||||
describe('resolveContextLinkUrl', () => {
|
||||
const vars = { timestamp_start: '1720512000000', service_name: 'frontend' };
|
||||
|
||||
it('resolves a variable in the base URL path', () => {
|
||||
expect(
|
||||
resolveContextLinkUrl('https://google.com/{{timestamp_start}}', vars),
|
||||
).toBe('https://google.com/1720512000000');
|
||||
});
|
||||
|
||||
it('resolves variables in query params', () => {
|
||||
expect(
|
||||
resolveContextLinkUrl('https://x.com/d?service={{service_name}}', vars),
|
||||
).toBe('https://x.com/d?service=frontend');
|
||||
});
|
||||
|
||||
// Regression: a literal `%` must not abort resolution of the base-URL variable.
|
||||
it('keeps resolving when a param value contains a literal %', () => {
|
||||
expect(
|
||||
resolveContextLinkUrl(
|
||||
'https://google.com/{{timestamp_start}}?name=95%',
|
||||
vars,
|
||||
),
|
||||
).toBe('https://google.com/1720512000000?name=95%25');
|
||||
});
|
||||
|
||||
it('resolves both a base-URL variable and a param variable together', () => {
|
||||
expect(
|
||||
resolveContextLinkUrl(
|
||||
'https://x.com/{{service_name}}?ts={{timestamp_start}}',
|
||||
vars,
|
||||
),
|
||||
).toBe('https://x.com/frontend?ts=1720512000000');
|
||||
});
|
||||
|
||||
it('leaves an unknown variable token untouched', () => {
|
||||
expect(resolveContextLinkUrl('https://x.com/{{unknown}}', vars)).toBe(
|
||||
'https://x.com/{{unknown}}',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the base URL unchanged when there is no query string', () => {
|
||||
expect(resolveContextLinkUrl('https://x.com/plain', vars)).toBe(
|
||||
'https://x.com/plain',
|
||||
);
|
||||
});
|
||||
|
||||
it('decodes double-encoded param values before resolving', () => {
|
||||
expect(
|
||||
resolveContextLinkUrl(
|
||||
'https://x.com/d?ts=%257B%257Btimestamp_start%257D%257D',
|
||||
vars,
|
||||
),
|
||||
).toBe('https://x.com/d?ts=1720512000000');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { resolveTexts } from 'hooks/dashboard/useContextVariables';
|
||||
|
||||
// A literal `%` (e.g. `?name=95%`) isn't valid percent-encoding — fall back to the raw string.
|
||||
function safeDecodeURIComponent(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves `{{var}}` tokens in a context-link URL. V2-local rather than the shared V1
|
||||
* `processContextLinks`, which throws on a literal `%` and drops the whole URL to its template.
|
||||
*/
|
||||
export function resolveContextLinkUrl(
|
||||
url: string,
|
||||
processedVariables: Record<string, string>,
|
||||
): string {
|
||||
const [baseUrl, queryString] = url.split('?');
|
||||
const resolvedBase = resolveTexts({ texts: [baseUrl], processedVariables })
|
||||
.fullTexts[0];
|
||||
|
||||
if (!queryString) {
|
||||
return resolvedBase;
|
||||
}
|
||||
|
||||
const resolvedQuery = Array.from(new URLSearchParams(queryString).entries())
|
||||
.map(([key, value]) => {
|
||||
// Decode twice for double-encoded values; safe so a bad `%` doesn't abort.
|
||||
const decoded = safeDecodeURIComponent(safeDecodeURIComponent(value));
|
||||
const resolvedValue = resolveTexts({
|
||||
texts: [decoded],
|
||||
processedVariables,
|
||||
}).fullTexts[0];
|
||||
return `${encodeURIComponent(key)}=${encodeURIComponent(resolvedValue)}`;
|
||||
})
|
||||
.join('&');
|
||||
|
||||
return `${resolvedBase}?${resolvedQuery}`;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { processContextLinks } from 'container/NewWidget/RightContainer/ContextLinks/utils';
|
||||
import type { ContextLinkProps } from 'types/api/dashboard/getAll';
|
||||
import { resolveTexts } from 'hooks/dashboard/useContextVariables';
|
||||
|
||||
import { resolveContextLinkUrl } from './resolveContextLinkUrl';
|
||||
|
||||
/** A panel context link with its label and URL templates resolved, ready to render. */
|
||||
export interface ResolvedDrilldownLink {
|
||||
@@ -10,10 +11,8 @@ export interface ResolvedDrilldownLink {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a panel's context links for the drilldown menu. Adapts each `DashboardLinkDTO` to the V1
|
||||
* `ContextLinkProps` the shared `processContextLinks` resolver expects, substitutes variables in the
|
||||
* label + URL, and drops links without a URL. Links with `renderVariables === false` keep their raw
|
||||
* label/URL (no substitution).
|
||||
* Resolves a panel's context links for the drilldown menu: substitutes variables in each link's
|
||||
* label + URL, drops links without a URL, and skips substitution when `renderVariables === false`.
|
||||
*/
|
||||
export function resolvePanelContextLinks(
|
||||
links: DashboardLinkDTO[] | undefined,
|
||||
@@ -24,27 +23,17 @@ export function resolvePanelContextLinks(
|
||||
return [];
|
||||
}
|
||||
|
||||
const adapted: ContextLinkProps[] = usable.map((link, index) => ({
|
||||
id: String(index),
|
||||
label: link.name || link.url || '',
|
||||
url: link.url ?? '',
|
||||
}));
|
||||
|
||||
const resolved = processContextLinks(adapted, processedVariables, 50);
|
||||
|
||||
return usable.map((link, index) => {
|
||||
// `renderVariables` defaults to on; only an explicit `false` opts out of substitution.
|
||||
const rawLabel = link.name || link.url || '';
|
||||
const rawUrl = link.url ?? '';
|
||||
// Only an explicit `false` opts out; undefined defaults to substitution on.
|
||||
if (link.renderVariables === false) {
|
||||
return {
|
||||
id: String(index),
|
||||
label: link.name || link.url || '',
|
||||
url: link.url ?? '',
|
||||
};
|
||||
return { id: String(index), label: rawLabel, url: rawUrl };
|
||||
}
|
||||
return {
|
||||
id: resolved[index].id,
|
||||
label: resolved[index].label,
|
||||
url: resolved[index].url,
|
||||
id: String(index),
|
||||
label: resolveTexts({ texts: [rawLabel], processedVariables }).fullTexts[0],
|
||||
url: resolveContextLinkUrl(rawUrl, processedVariables),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { toQueryEnvelopes } from '../../queryV5/buildQueryRangeRequest';
|
||||
import {
|
||||
type AggregationView,
|
||||
getAggregationColumnKey,
|
||||
} from '../../queryV5/prepareScalarTables';
|
||||
|
||||
// Narrow view over any envelope spec: every variant carries name/disabled,
|
||||
// only builder queries have aggregations.
|
||||
interface QuerySpecView {
|
||||
name?: string;
|
||||
disabled?: boolean;
|
||||
aggregations?: AggregationView[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys every enabled query's value columns render under (one per aggregation on
|
||||
* multi-aggregation queries), derived from the panel's queries alone — lets a kind
|
||||
* switch key per-column config before any data exists. clickhouse_sql queries are
|
||||
* skipped: their columns are keyed by the response's SQL alias, unknowable here.
|
||||
*/
|
||||
export function getTableColumnKeys(
|
||||
queries: DashboardtypesQueryDTO[],
|
||||
): string[] {
|
||||
const keys = new Set<string>();
|
||||
toQueryEnvelopes(queries).forEach((envelope) => {
|
||||
if (
|
||||
envelope.type ===
|
||||
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType.clickhouse_sql
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const spec = envelope.spec as QuerySpecView | undefined;
|
||||
if (!spec?.name || spec.disabled) {
|
||||
return;
|
||||
}
|
||||
const { aggregations } = spec;
|
||||
const columnCount = Math.max(aggregations?.length ?? 0, 1);
|
||||
for (let index = 0; index < columnCount; index += 1) {
|
||||
keys.add(getAggregationColumnKey(spec.name, aggregations, index));
|
||||
}
|
||||
});
|
||||
return [...keys];
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ContextMenu from 'periscope/components/ContextMenu';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import { panelTimePreferenceLabel } from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
|
||||
import {
|
||||
getPanelTimePreference,
|
||||
panelTimePreferenceLabel,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
|
||||
import { usePanelQuery } from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
|
||||
|
||||
import type { DashboardSection } from '../../utils';
|
||||
@@ -27,7 +27,7 @@ export interface PanelActionsConfig {
|
||||
interface PanelProps {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
/** True once this panel's section enters the viewport — gates the fetch. */
|
||||
/** True once this panel enters the viewport — gates the fetch (owned by SectionGridItem). */
|
||||
isVisible?: boolean;
|
||||
/** Move/delete actions — present only in editable sectioned mode. */
|
||||
panelActions?: PanelActionsConfig;
|
||||
@@ -43,15 +43,7 @@ function Panel({
|
||||
isVisible,
|
||||
panelActions,
|
||||
}: PanelProps): JSX.Element {
|
||||
// A per-panel time preference is surfaced as a header pill. `visualization` is
|
||||
// common to every plugin-spec variant — localized cast reads it without
|
||||
// narrowing on kind.
|
||||
const timePreference = (
|
||||
panel.spec.plugin.spec as
|
||||
| { visualization?: { timePreference?: DashboardtypesTimePreferenceDTO } }
|
||||
| undefined
|
||||
)?.visualization?.timePreference;
|
||||
const timeLabel = panelTimePreferenceLabel(timePreference);
|
||||
const timeLabel = panelTimePreferenceLabel(getPanelTimePreference(panel));
|
||||
|
||||
const panelKind = panel.spec.plugin.kind;
|
||||
const panelDefinition = getPanelDefinition(panelKind);
|
||||
|
||||
@@ -89,6 +89,14 @@ function section(
|
||||
const TWO_TITLED_SECTIONS = [section(0, 'Overview'), section(1, 'Latency')];
|
||||
// Index 0 is the untitled root (free-flow) section; index 1 is a titled section.
|
||||
const TITLED_WITH_ROOT = [section(0, undefined), section(1, 'Latency')];
|
||||
// Untitled root plus two titled sections — exercises the multi-target submenu.
|
||||
const ROOT_AND_TWO_TITLED = [
|
||||
section(0, undefined),
|
||||
section(1, 'A'),
|
||||
section(2, 'B'),
|
||||
];
|
||||
// Just the free-flow root: an ungrouped board with no sections to move between.
|
||||
const ONLY_ROOT = [section(0, undefined)];
|
||||
|
||||
// Minimal panel — only its presence gates "Create Alerts"; the query→URL
|
||||
// translation it drives is covered by buildCreateAlertUrl's own tests.
|
||||
@@ -111,7 +119,9 @@ const baseArgs = {
|
||||
panelId: 'panel-1',
|
||||
panel: mockPanel,
|
||||
data: mockData,
|
||||
panelActions: { currentLayoutIndex: 0, sections: TWO_TITLED_SECTIONS },
|
||||
// Panel sits in a titled section with an untitled root present, so every
|
||||
// action — including "Move to section" (→ Dashboard root) — is available.
|
||||
panelActions: { currentLayoutIndex: 1, sections: TITLED_WITH_ROOT },
|
||||
};
|
||||
|
||||
function itemKeys(result: ReturnType<typeof usePanelActionItems>): unknown[] {
|
||||
@@ -210,7 +220,7 @@ describe('usePanelActionItems', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('move is disabled when there is no other titled section to move to', () => {
|
||||
it('hides "Move to section" when the only untitled section is not the root (index 0)', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
@@ -220,8 +230,8 @@ describe('usePanelActionItems', () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
const move = result.current.items.find((i) => 'key' in i && i.key === 'move');
|
||||
expect(move).toMatchObject({ disabled: true });
|
||||
// An untitled section only counts as the root at layoutIndex 0.
|
||||
expect(itemKeys(result.current)).not.toContain('move');
|
||||
});
|
||||
|
||||
it('edit opens the panel editor for this panel', () => {
|
||||
@@ -233,14 +243,77 @@ describe('usePanelActionItems', () => {
|
||||
expect(mockOpenEditor).toHaveBeenCalledWith('panel-1');
|
||||
});
|
||||
|
||||
it('move targets call the mutation with from/to layout indexes', () => {
|
||||
it('"Move to section" offers a single "Dashboard (root)" target', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
const move = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'move',
|
||||
) as {
|
||||
children: { key: string; onClick: () => void }[];
|
||||
};
|
||||
expect(move.children).toHaveLength(1);
|
||||
expect(move.children.map((c) => c.key)).toStrictEqual(['move-to-root']);
|
||||
});
|
||||
|
||||
it('the "Dashboard (root)" target moves the panel to the untitled root section', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
const move = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'move',
|
||||
) as {
|
||||
children: { onClick: () => void }[];
|
||||
};
|
||||
move.children[0].onClick();
|
||||
expect(mockMovePanel).toHaveBeenCalledWith({
|
||||
panelId: 'panel-1',
|
||||
fromLayoutIndex: 1,
|
||||
toLayoutIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('an ungrouped panel (in the root) can move into each titled section', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
panelActions: { currentLayoutIndex: 0, sections: ROOT_AND_TWO_TITLED },
|
||||
}),
|
||||
);
|
||||
const move = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'move',
|
||||
) as { children: { key: string; label: string }[] };
|
||||
expect(move.children.map((c) => c.key)).toStrictEqual(['move-1', 'move-2']);
|
||||
expect(move.children.map((c) => c.label)).toStrictEqual(['A', 'B']);
|
||||
});
|
||||
|
||||
it('a panel in a titled section can move to the root and the other titled sections', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
panelActions: { currentLayoutIndex: 1, sections: ROOT_AND_TWO_TITLED },
|
||||
}),
|
||||
);
|
||||
const move = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'move',
|
||||
) as { children: { key: string; label: string }[] };
|
||||
// Root leads, then the other titled section — never the current one (A).
|
||||
expect(move.children.map((c) => c.key)).toStrictEqual([
|
||||
'move-to-root',
|
||||
'move-2',
|
||||
]);
|
||||
expect(move.children.map((c) => c.label)).toStrictEqual([
|
||||
'Dashboard (root)',
|
||||
'B',
|
||||
]);
|
||||
});
|
||||
|
||||
it('moves between titled sections even when the board has no untitled root', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
panelActions: { currentLayoutIndex: 0, sections: TWO_TITLED_SECTIONS },
|
||||
}),
|
||||
);
|
||||
const move = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'move',
|
||||
) as { children: { key: string; onClick: () => void }[] };
|
||||
expect(move.children.map((c) => c.key)).toStrictEqual(['move-1']);
|
||||
move.children[0].onClick();
|
||||
expect(mockMovePanel).toHaveBeenCalledWith({
|
||||
panelId: 'panel-1',
|
||||
@@ -249,47 +322,14 @@ describe('usePanelActionItems', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('offers "Move out of section" for a panel in a titled section when an untitled root exists', () => {
|
||||
it('hides "Move to section" when the board has no sections (only the root)', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
panelActions: { currentLayoutIndex: 1, sections: TITLED_WITH_ROOT },
|
||||
panelActions: { currentLayoutIndex: 0, sections: ONLY_ROOT },
|
||||
}),
|
||||
);
|
||||
expect(itemKeys(result.current)).toContain('move-to-root');
|
||||
});
|
||||
|
||||
it('"Move out of section" moves the panel to the untitled root section', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
panelActions: { currentLayoutIndex: 1, sections: TITLED_WITH_ROOT },
|
||||
}),
|
||||
);
|
||||
const moveOut = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'move-to-root',
|
||||
);
|
||||
(moveOut as { onClick: () => void }).onClick();
|
||||
expect(mockMovePanel).toHaveBeenCalledWith({
|
||||
panelId: 'panel-1',
|
||||
fromLayoutIndex: 1,
|
||||
toLayoutIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('hides "Move out of section" when the panel already sits in the root section', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
panelActions: { currentLayoutIndex: 0, sections: TITLED_WITH_ROOT },
|
||||
}),
|
||||
);
|
||||
expect(itemKeys(result.current)).not.toContain('move-to-root');
|
||||
});
|
||||
|
||||
it('hides "Move out of section" when every section is titled (no root)', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
expect(itemKeys(result.current)).not.toContain('move-to-root');
|
||||
expect(itemKeys(result.current)).not.toContain('move');
|
||||
});
|
||||
|
||||
it('delete defers to a confirmation: the item opens the dialog, confirm runs the mutation', async () => {
|
||||
@@ -312,7 +352,7 @@ describe('usePanelActionItems', () => {
|
||||
});
|
||||
expect(mockDeletePanel).toHaveBeenCalledWith({
|
||||
panelId: 'panel-1',
|
||||
layoutIndex: 0,
|
||||
layoutIndex: 1,
|
||||
});
|
||||
expect(result.current.deleteConfirm.open).toBe(false);
|
||||
});
|
||||
@@ -325,7 +365,7 @@ describe('usePanelActionItems', () => {
|
||||
(clone as { onClick: () => void }).onClick();
|
||||
expect(mockClonePanel).toHaveBeenCalledWith({
|
||||
panelId: 'panel-1',
|
||||
layoutIndex: 0,
|
||||
layoutIndex: 1,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
cursor: grab;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Fragment, useMemo } from 'react';
|
||||
import { Info, Loader } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type {
|
||||
@@ -74,6 +74,14 @@ function PanelHeader({
|
||||
[warning],
|
||||
);
|
||||
|
||||
/**
|
||||
* Hide the entire header when there's no title, description, or status to show,
|
||||
* and the actions menu is suppressed (editor preview).
|
||||
*/
|
||||
if (!name && !description && !errorDetail && !warningDetail && hideActions) {
|
||||
return <Fragment />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx(styles.header, 'panel-drag-handle')}>
|
||||
<div className={styles.headerLeft}>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
@use '../../../../../../styles/scrollbar' as *;
|
||||
|
||||
.modal {
|
||||
:global(.ant-modal-body) {
|
||||
padding: 0px;
|
||||
}
|
||||
.dialog[data-width] {
|
||||
max-width: 85%;
|
||||
width: 85%;
|
||||
}
|
||||
|
||||
// Truncate a long panel name so the header stays on one line; the wrapping tooltip
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@signozhq/ui/dialog';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Modal } from 'antd';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import ViewPanelModalContent from './ViewPanelModalContent';
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
@@ -25,27 +33,48 @@ function ViewPanelModal({
|
||||
}: ViewPanelModalProps): JSX.Element {
|
||||
const name = panel?.spec.display.name ?? '';
|
||||
|
||||
// Render antd popups into the dialog (not document.body) so they stay inside the
|
||||
// modal's interactive, focus-trapped layer instead of being blocked by Radix.
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<Dialog
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
centered
|
||||
width="85%"
|
||||
destroyOnClose
|
||||
className={styles.modal}
|
||||
title={
|
||||
<TooltipSimple title={name} arrow>
|
||||
<Typography.Text className={styles.title}>
|
||||
{name} - (View mode)
|
||||
</Typography.Text>
|
||||
</TooltipSimple>
|
||||
}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{open && panel && panelId && (
|
||||
<ViewPanelModalContent panel={panel} panelId={panelId} onClose={onClose} />
|
||||
)}
|
||||
</Modal>
|
||||
<DialogContent
|
||||
ref={contentRef}
|
||||
position="center"
|
||||
width="extra-wide"
|
||||
className={styles.dialog}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<TooltipSimple title={name} arrow>
|
||||
<Typography.Text className={styles.title}>
|
||||
{name ? `${name} - (View mode)` : 'View mode'}
|
||||
</Typography.Text>
|
||||
</TooltipSimple>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogCloseButton />
|
||||
{open && panel && panelId && (
|
||||
<ConfigProvider
|
||||
getPopupContainer={(): HTMLElement => contentRef.current ?? document.body}
|
||||
>
|
||||
<ViewPanelModalContent
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import ContextMenu from 'periscope/components/ContextMenu';
|
||||
import ListColumnsEditor from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/ListColumnsEditor/ListColumnsEditor';
|
||||
import PanelEditorQueryBuilder from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder';
|
||||
import PreviewPane from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/PreviewPane/PreviewPane';
|
||||
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
|
||||
@@ -44,6 +45,7 @@ function ViewPanelModalContent({
|
||||
|
||||
const {
|
||||
draft,
|
||||
setSpec,
|
||||
panelDefinition,
|
||||
signal,
|
||||
queryType,
|
||||
@@ -54,7 +56,17 @@ function ViewPanelModalContent({
|
||||
buildSaveSpec,
|
||||
applyDrilldownQuery,
|
||||
} = useViewPanelMode({ panel, panelId, time: timeOverride });
|
||||
const { data, isFetching, error, refetch, cancelQuery, pagination } = query;
|
||||
const {
|
||||
data,
|
||||
isFetching,
|
||||
isPreviousData,
|
||||
error,
|
||||
refetch,
|
||||
cancelQuery,
|
||||
pagination,
|
||||
} = query;
|
||||
|
||||
const isListPanel = draft.spec.plugin.kind === 'signoz/ListPanel';
|
||||
|
||||
// Grid drill-down, but filter-by-value / breakout refine this view in place. Drills the draft
|
||||
// so it reflects in-modal edits (and the click's time range follows the per-view window).
|
||||
@@ -119,6 +131,16 @@ function ViewPanelModalContent({
|
||||
isLoadingQueries={isFetching}
|
||||
onStageRunQuery={runQuery}
|
||||
onCancelQuery={cancelQuery}
|
||||
stickyHeader={false}
|
||||
footer={
|
||||
isListPanel ? (
|
||||
<ListColumnsEditor
|
||||
spec={draft.spec}
|
||||
onChangeSpec={setSpec}
|
||||
signal={signal}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
@@ -128,6 +150,7 @@ function ViewPanelModalContent({
|
||||
panelDefinition={panelDefinition}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
isPreviousData={isPreviousData}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
|
||||
@@ -35,6 +35,8 @@ interface UseViewPanelModeArgs {
|
||||
export interface UseViewPanelModeReturn {
|
||||
/** Local editable copy of the panel — the preview renders this, not the saved panel. */
|
||||
draft: DashboardtypesPanelDTO;
|
||||
/** Update the draft's spec in place (e.g. the List columns editor). */
|
||||
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
/** Resolved renderer for the draft's current kind (registry always resolves a kind). */
|
||||
panelDefinition: RenderablePanelDefinition;
|
||||
/**
|
||||
@@ -159,6 +161,7 @@ export function useViewPanelMode({
|
||||
|
||||
return {
|
||||
draft,
|
||||
setSpec,
|
||||
panelDefinition,
|
||||
signal,
|
||||
queryType: currentQuery.queryType,
|
||||
|
||||
@@ -68,7 +68,7 @@ jest.mock(
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/VariablesBar/useVariableSelection',
|
||||
'pages/DashboardPageV2/DashboardContainer/VariablesBar/variablesUrlState',
|
||||
() => ({
|
||||
ALL_SELECTED: '__ALL__',
|
||||
variablesUrlParser: { withOptions: (): unknown => ({}) },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useDashboardStore } from '../../../../store/useDashboardStore';
|
||||
import { useScrollIntoViewStore } from '../../../../store/useScrollIntoViewStore';
|
||||
import type { DashboardSection } from '../../../../utils';
|
||||
import { useClonePanel } from '../useClonePanel';
|
||||
|
||||
@@ -47,6 +48,7 @@ describe('useClonePanel', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useDashboardStore.setState({ dashboardId: 'dash-1' });
|
||||
useScrollIntoViewStore.setState({ scrollTargetId: null });
|
||||
});
|
||||
|
||||
it('patches an add of the deep-copied spec + a new item under the same section', async () => {
|
||||
@@ -132,6 +134,21 @@ describe('useClonePanel', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('records the cloned panel as the scroll target when the toast auto-closes', async () => {
|
||||
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
|
||||
|
||||
await result.current({ panelId: 'p1', layoutIndex: 0 });
|
||||
|
||||
// The reveal is deferred to the toast's auto-close, not fired inline.
|
||||
expect(useScrollIntoViewStore.getState().scrollTargetId).toBeNull();
|
||||
const { onAutoClose } = mockToastPromise.mock.calls[0][1] as {
|
||||
onAutoClose: () => void;
|
||||
};
|
||||
onAutoClose();
|
||||
|
||||
expect(useScrollIntoViewStore.getState().scrollTargetId).toBe('cloned-id');
|
||||
});
|
||||
|
||||
it('swallows a patch rejection (toast owns the error UX) — does not throw', async () => {
|
||||
mockPatchAsync.mockRejectedValueOnce(new Error('boom'));
|
||||
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
panelRef,
|
||||
} from '../../../patchOps';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import { useScrollIntoViewStore } from '../../../store/useScrollIntoViewStore';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
|
||||
interface Params {
|
||||
@@ -32,6 +33,7 @@ export function useClonePanel({
|
||||
}: Params): (args: ClonePanelArgs) => Promise<void> {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
|
||||
|
||||
return useCallback(
|
||||
async ({ panelId, layoutIndex }: ClonePanelArgs): Promise<void> => {
|
||||
@@ -64,6 +66,9 @@ export function useClonePanel({
|
||||
success: 'Panel cloned',
|
||||
error: 'Failed to clone panel',
|
||||
position: 'top-center',
|
||||
duration: 2000,
|
||||
// Defer the reveal to the toast's auto-close so the confirmation shows first.
|
||||
onAutoClose: () => setScrollTargetId(newPanelId),
|
||||
});
|
||||
|
||||
// toast.promise owns the error UX; swallow here to avoid an unhandled
|
||||
@@ -74,6 +79,6 @@ export function useClonePanel({
|
||||
// no-op
|
||||
}
|
||||
},
|
||||
[sections, dashboardId, patchAsync],
|
||||
[sections, dashboardId, patchAsync, setScrollTargetId],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { VariableSelection } from 'pages/DashboardPageV2/DashboardContainer
|
||||
import {
|
||||
ALL_SELECTED,
|
||||
variablesUrlParser,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/VariablesBar/useVariableSelection';
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/VariablesBar/variablesUrlState';
|
||||
|
||||
interface UseDrilldownDashboardVariablesArgs {
|
||||
/** Group-by field filters from the clicked point (empty when the click has no group-by). */
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
|
||||
import { movePanelBetweenSectionsOps } from '../../../patchOps';
|
||||
import { bottomRowSlot, movePanelBetweenSectionsOps } from '../../../patchOps';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
|
||||
@@ -20,8 +20,8 @@ interface Params {
|
||||
|
||||
/**
|
||||
* Relocates a panel's item ref from one section to another. The panel itself
|
||||
* stays in `spec.panels`; only the grid item moves, dropped into a free row at
|
||||
* the bottom of the target section. Persisted as one atomic patch.
|
||||
* stays in `spec.panels`; only the grid item moves, dropped into a fresh row at
|
||||
* the bottom of the target section (`bottomRowSlot`). Persisted as one atomic patch.
|
||||
*/
|
||||
export function useMovePanelToSection({
|
||||
sections,
|
||||
@@ -52,12 +52,10 @@ export function useMovePanelToSection({
|
||||
}
|
||||
|
||||
const sourceItems = source.items.filter((i) => i.id !== panelId);
|
||||
// Place at a fresh row at the bottom of the target section.
|
||||
const nextY = target.items.reduce(
|
||||
(max, i) => Math.max(max, i.y + i.height),
|
||||
0,
|
||||
);
|
||||
const targetItems = [...target.items, { ...moved, x: 0, y: nextY }];
|
||||
// Land at the section bottom, not backfilled into a gap — least disruptive
|
||||
// to the arrangement the user already made in the target section.
|
||||
const { x, y } = bottomRowSlot(target.items);
|
||||
const targetItems = [...target.items, { ...moved, x, y }];
|
||||
|
||||
try {
|
||||
await patchAsync(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user