mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-31 17:40:25 +01:00
Compare commits
69 Commits
ns/flamegr
...
ab/4172
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67fb4cf8a5 | ||
|
|
11f9daf2a7 | ||
|
|
01cc509c1b | ||
|
|
c17021aade | ||
|
|
d9961fe527 | ||
|
|
5035390236 | ||
|
|
7d3612c10a | ||
|
|
c82cd32f61 | ||
|
|
a3980e084c | ||
|
|
4319dd9cef | ||
|
|
92a5e9b9c9 | ||
|
|
408a914129 | ||
|
|
0e304b1d40 | ||
|
|
58a9be24d3 | ||
|
|
adf439fcf1 | ||
|
|
a1a54c4bb2 | ||
|
|
3c1961d3fc | ||
|
|
c3efa0660b | ||
|
|
183dd09082 | ||
|
|
a351373c49 | ||
|
|
8e7653b90d | ||
|
|
5c40d6b68b | ||
|
|
31115df41c | ||
|
|
869c3dccb2 | ||
|
|
c5d7a7ef8c | ||
|
|
544b87b254 | ||
|
|
e885fb98e5 | ||
|
|
be227eec43 | ||
|
|
13263c1f25 | ||
|
|
ccbf410d15 | ||
|
|
03b98ff824 | ||
|
|
2cdba0d11c | ||
|
|
84d2885530 | ||
|
|
b82dcc6138 | ||
|
|
a14d5847b9 | ||
|
|
d184746142 | ||
|
|
c335e17e1d | ||
|
|
433dd0b2d0 | ||
|
|
05e97e246a | ||
|
|
bddfe30f6c | ||
|
|
7a01a5250d | ||
|
|
09c98c830d | ||
|
|
0fbb90cc91 | ||
|
|
15f0787610 | ||
|
|
22ebc7732c | ||
|
|
cff18edf6e | ||
|
|
cb49c0bf3b | ||
|
|
1cb6f94d21 | ||
|
|
68155f374b | ||
|
|
696524509f | ||
|
|
705cdab38c | ||
|
|
ae9b881413 | ||
|
|
05f4e15d07 | ||
|
|
1653c6d725 | ||
|
|
070b4b7061 | ||
|
|
7f4c06edd6 | ||
|
|
6bed20b5b9 | ||
|
|
033bd3c9b8 | ||
|
|
d4c9a923fd | ||
|
|
387dcb529f | ||
|
|
7a4da7bcc5 | ||
|
|
b152fae3fa | ||
|
|
2ed766726c | ||
|
|
8767f6a57d | ||
|
|
22d8c7599b | ||
|
|
1019264272 | ||
|
|
c950d7e784 | ||
|
|
1e279e6193 | ||
|
|
d3a278c43e |
3
.prettierrc
Normal file
3
.prettierrc
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"trailingComma": "none"
|
||||
}
|
||||
44
.vscode/settings.json
vendored
44
.vscode/settings.json
vendored
@@ -1,23 +1,25 @@
|
||||
{
|
||||
"eslint.workingDirectories": [
|
||||
"./frontend"
|
||||
],
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"prettier.requireConfig": true,
|
||||
"[go]": {
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "golang.go"
|
||||
},
|
||||
"[sql]": {
|
||||
"editor.defaultFormatter": "adpyke.vscode-sql-formatter"
|
||||
},
|
||||
"[html]": {
|
||||
"editor.defaultFormatter": "vscode.html-language-features"
|
||||
},
|
||||
"python-envs.defaultEnvManager": "ms-python.python:system",
|
||||
"python-envs.pythonProjects": []
|
||||
"eslint.workingDirectories": ["./frontend"],
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"prettier.requireConfig": true,
|
||||
"[go]": {
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "golang.go"
|
||||
},
|
||||
"[sql]": {
|
||||
"editor.defaultFormatter": "adpyke.vscode-sql-formatter"
|
||||
},
|
||||
"[html]": {
|
||||
"editor.defaultFormatter": "vscode.html-language-features"
|
||||
},
|
||||
"python-envs.defaultEnvManager": "ms-python.python:system",
|
||||
"python-envs.pythonProjects": [],
|
||||
"editor.tokenColorCustomizations": {
|
||||
"comments": "",
|
||||
"textMateRules": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.117.1
|
||||
image: signoz/signoz:v0.117.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
# - "6060:6060" # pprof port
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.117.1
|
||||
image: signoz/signoz:v0.117.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
volumes:
|
||||
|
||||
@@ -181,7 +181,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.117.1}
|
||||
image: signoz/signoz:${VERSION:-v0.117.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -109,7 +109,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.117.1}
|
||||
image: signoz/signoz:${VERSION:-v0.117.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -327,27 +327,6 @@ components:
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
AuthtypesStorableRole:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
AuthtypesTransaction:
|
||||
properties:
|
||||
object:
|
||||
@@ -363,53 +342,6 @@ components:
|
||||
config:
|
||||
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
|
||||
type: object
|
||||
AuthtypesUserRole:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
role:
|
||||
$ref: '#/components/schemas/AuthtypesStorableRole'
|
||||
roleId:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
userId:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
AuthtypesUserWithRoles:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
displayName:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
isRoot:
|
||||
type: boolean
|
||||
orgId:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
userRoles:
|
||||
items:
|
||||
$ref: '#/components/schemas/AuthtypesUserRole'
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
CloudintegrationtypesAWSAccountConfig:
|
||||
properties:
|
||||
regions:
|
||||
@@ -2674,13 +2606,6 @@ components:
|
||||
token:
|
||||
type: string
|
||||
type: object
|
||||
TypesPostableRole:
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
TypesResetPasswordToken:
|
||||
properties:
|
||||
expiresAt:
|
||||
@@ -2722,13 +2647,6 @@ components:
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
TypesUpdatableUser:
|
||||
properties:
|
||||
displayName:
|
||||
type: string
|
||||
required:
|
||||
- displayName
|
||||
type: object
|
||||
TypesUser:
|
||||
properties:
|
||||
createdAt:
|
||||
@@ -6195,7 +6113,7 @@ paths:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint lists all users
|
||||
operationId: ListUsersDeprecated
|
||||
operationId: ListUsers
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
@@ -6288,7 +6206,7 @@ paths:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns the user by id
|
||||
operationId: GetUserDeprecated
|
||||
operationId: GetUser
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@@ -6345,7 +6263,7 @@ paths:
|
||||
put:
|
||||
deprecated: false
|
||||
description: This endpoint updates the user by id
|
||||
operationId: UpdateUserDeprecated
|
||||
operationId: UpdateUser
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@@ -6414,7 +6332,7 @@ paths:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns the user I belong to
|
||||
operationId: GetMyUserDeprecated
|
||||
operationId: GetMyUser
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
@@ -7863,66 +7781,6 @@ paths:
|
||||
summary: Readiness check
|
||||
tags:
|
||||
- health
|
||||
/api/v2/roles/{id}/users:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns the users having the role by role id
|
||||
operationId: GetUsersByRoleID
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/TypesUser'
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Get users by role id
|
||||
tags:
|
||||
- users
|
||||
/api/v2/sessions:
|
||||
delete:
|
||||
deprecated: false
|
||||
@@ -8081,408 +7939,6 @@ paths:
|
||||
summary: Rotate session
|
||||
tags:
|
||||
- sessions
|
||||
/api/v2/users:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint lists all users for the organization
|
||||
operationId: ListUsers
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/TypesUser'
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: List users v2
|
||||
tags:
|
||||
- users
|
||||
/api/v2/users/{id}:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns the user by id
|
||||
operationId: GetUser
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AuthtypesUserWithRoles'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Get user by user id
|
||||
tags:
|
||||
- users
|
||||
put:
|
||||
deprecated: false
|
||||
description: This endpoint updates the user by id
|
||||
operationId: UpdateUser
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TypesUpdatableUser'
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"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
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Update user v2
|
||||
tags:
|
||||
- users
|
||||
/api/v2/users/{id}/roles:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns the user roles by user id
|
||||
operationId: GetRolesByUserID
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/AuthtypesRole'
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Get user roles
|
||||
tags:
|
||||
- users
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint assigns the role to the user roles by user id
|
||||
operationId: SetRoleByUserID
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TypesPostableRole'
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Set user roles
|
||||
tags:
|
||||
- users
|
||||
/api/v2/users/{id}/roles/{roleId}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: This endpoint removes a role from the user by user id and role
|
||||
id
|
||||
operationId: RemoveUserRoleByUserIDAndRoleID
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: roleId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Remove a role from user
|
||||
tags:
|
||||
- users
|
||||
/api/v2/users/me:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns the user I belong to
|
||||
operationId: GetMyUser
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AuthtypesUserWithRoles'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- tokenizer: []
|
||||
summary: Get my user v2
|
||||
tags:
|
||||
- users
|
||||
put:
|
||||
deprecated: false
|
||||
description: This endpoint updates the user I belong to
|
||||
operationId: UpdateMyUserV2
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TypesUpdatableUser'
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- tokenizer: []
|
||||
summary: Update my user v2
|
||||
tags:
|
||||
- users
|
||||
/api/v2/zeus/hosts:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -205,25 +205,6 @@ module.exports = {
|
||||
],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['src/**/*.{jsx,tsx,ts}'],
|
||||
excludedFiles: [
|
||||
'**/*.test.{js,jsx,ts,tsx}',
|
||||
'**/*.spec.{js,jsx,ts,tsx}',
|
||||
'**/__tests__/**/*.{js,jsx,ts,tsx}',
|
||||
],
|
||||
rules: {
|
||||
'no-restricted-properties': [
|
||||
'error',
|
||||
{
|
||||
object: 'navigator',
|
||||
property: 'clipboard',
|
||||
message:
|
||||
'Do not use navigator.clipboard directly since it does not work well with specific browsers. Use hook useCopyToClipboard from react-use library. https://streamich.github.io/react-use/?path=/story/side-effects-usecopytoclipboard--docs',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'**/*.test.{js,jsx,ts,tsx}',
|
||||
|
||||
@@ -425,39 +425,6 @@ export interface AuthtypesSessionContextDTO {
|
||||
orgs?: AuthtypesOrgSessionContextDTO[] | null;
|
||||
}
|
||||
|
||||
export interface AuthtypesStorableRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface AuthtypesTransactionDTO {
|
||||
object: AuthtypesObjectDTO;
|
||||
/**
|
||||
@@ -470,74 +437,6 @@ export interface AuthtypesUpdateableAuthDomainDTO {
|
||||
config?: AuthtypesAuthDomainConfigDTO;
|
||||
}
|
||||
|
||||
export interface AuthtypesUserRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
role?: AuthtypesStorableRoleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
roleId?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesUserWithRolesDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
displayName?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
email?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
isRoot?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
userRoles?: AuthtypesUserRoleDTO[] | null;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesAWSAccountConfigDTO {
|
||||
/**
|
||||
* @type array
|
||||
@@ -3180,13 +3079,6 @@ export interface TypesPostableResetPasswordDTO {
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface TypesPostableRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TypesResetPasswordTokenDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -3252,13 +3144,6 @@ export interface TypesStorableAPIKeyDTO {
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface TypesUpdatableUserDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface TypesUserDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -3965,7 +3850,7 @@ export type UpdateServiceAccountKeyPathParameters = {
|
||||
export type UpdateServiceAccountStatusPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type ListUsersDeprecated200 = {
|
||||
export type ListUsers200 = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
@@ -3979,10 +3864,10 @@ export type ListUsersDeprecated200 = {
|
||||
export type DeleteUserPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetUserDeprecatedPathParameters = {
|
||||
export type GetUserPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetUserDeprecated200 = {
|
||||
export type GetUser200 = {
|
||||
data: TypesDeprecatedUserDTO;
|
||||
/**
|
||||
* @type string
|
||||
@@ -3990,10 +3875,10 @@ export type GetUserDeprecated200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UpdateUserDeprecatedPathParameters = {
|
||||
export type UpdateUserPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type UpdateUserDeprecated200 = {
|
||||
export type UpdateUser200 = {
|
||||
data: TypesDeprecatedUserDTO;
|
||||
/**
|
||||
* @type string
|
||||
@@ -4001,7 +3886,7 @@ export type UpdateUserDeprecated200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetMyUserDeprecated200 = {
|
||||
export type GetMyUser200 = {
|
||||
data: TypesDeprecatedUserDTO;
|
||||
/**
|
||||
* @type string
|
||||
@@ -4298,20 +4183,6 @@ export type Readyz503 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetUsersByRoleIDPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetUsersByRoleID200 = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
data: TypesUserDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetSessionContext200 = {
|
||||
data: AuthtypesSessionContextDTO;
|
||||
/**
|
||||
@@ -4336,60 +4207,6 @@ export type RotateSession200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListUsers200 = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
data: TypesUserDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetUserPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetUser200 = {
|
||||
data: AuthtypesUserWithRolesDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UpdateUserPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetRolesByUserIDPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetRolesByUserID200 = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
data: AuthtypesRoleDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type SetRoleByUserIDPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type RemoveUserRoleByUserIDAndRoleIDPathParameters = {
|
||||
id: string;
|
||||
roleId: string;
|
||||
};
|
||||
export type GetMyUser200 = {
|
||||
data: AuthtypesUserWithRolesDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetHosts200 = {
|
||||
data: ZeustypesGettableHostDTO;
|
||||
/**
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -109,6 +109,7 @@ export function useNavigateToExplorer(): (
|
||||
widgetConfig: {
|
||||
query: preparedQuery,
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
// does this change at any time? How does it change if so?
|
||||
timePreferance: 'GLOBAL_TIME',
|
||||
},
|
||||
selectedDashboard,
|
||||
|
||||
@@ -116,12 +116,7 @@ describe.each([
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(screen.getByText('FORMAT')).toBeInTheDocument();
|
||||
expect(screen.getByText('Number of Rows')).toBeInTheDocument();
|
||||
|
||||
if (dataSource === DataSource.TRACES) {
|
||||
expect(screen.queryByText('Columns')).not.toBeInTheDocument();
|
||||
} else {
|
||||
expect(screen.getByText('Columns')).toBeInTheDocument();
|
||||
}
|
||||
expect(screen.getByText('Columns')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allows changing export format', () => {
|
||||
@@ -151,17 +146,6 @@ describe.each([
|
||||
});
|
||||
|
||||
it('allows changing columns scope', () => {
|
||||
if (dataSource === DataSource.TRACES) {
|
||||
renderWithStore(dataSource);
|
||||
fireEvent.click(screen.getByTestId(testId));
|
||||
|
||||
expect(screen.queryByRole('radio', { name: 'All' })).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('radio', { name: 'Selected' }),
|
||||
).not.toBeInTheDocument();
|
||||
return;
|
||||
}
|
||||
|
||||
renderWithStore(dataSource);
|
||||
fireEvent.click(screen.getByTestId(testId));
|
||||
|
||||
@@ -226,12 +210,7 @@ describe.each([
|
||||
mockUseQueryBuilder.mockReturnValue({ stagedQuery: mockQuery });
|
||||
renderWithStore(dataSource);
|
||||
fireEvent.click(screen.getByTestId(testId));
|
||||
|
||||
// For traces, column scope is always Selected and the radio is hidden
|
||||
if (dataSource !== DataSource.TRACES) {
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Selected' }));
|
||||
}
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Selected' }));
|
||||
fireEvent.click(screen.getByText('Export'));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -248,11 +227,6 @@ describe.each([
|
||||
});
|
||||
|
||||
it('sends no selectFields when column scope is All', async () => {
|
||||
// For traces, column scope is always Selected — this test only applies to other sources
|
||||
if (dataSource === DataSource.TRACES) {
|
||||
return;
|
||||
}
|
||||
|
||||
renderWithStore(dataSource);
|
||||
fireEvent.click(screen.getByTestId(testId));
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'All' }));
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Button, Popover, Radio, Tooltip, Typography } from 'antd';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { useExportRawData } from 'hooks/useDownloadOptionsMenu/useDownloadOptionsMenu';
|
||||
import { Download, DownloadIcon, Loader2 } from 'lucide-react';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -15,12 +14,10 @@ import './DownloadOptionsMenu.styles.scss';
|
||||
|
||||
interface DownloadOptionsMenuProps {
|
||||
dataSource: DataSource;
|
||||
selectedColumns?: TelemetryFieldKey[];
|
||||
}
|
||||
|
||||
export default function DownloadOptionsMenu({
|
||||
dataSource,
|
||||
selectedColumns,
|
||||
}: DownloadOptionsMenuProps): JSX.Element {
|
||||
const [exportFormat, setExportFormat] = useState<string>(DownloadFormats.CSV);
|
||||
const [rowLimit, setRowLimit] = useState<number>(DownloadRowCounts.TEN_K);
|
||||
@@ -38,19 +35,9 @@ export default function DownloadOptionsMenu({
|
||||
await handleExportRawData({
|
||||
format: exportFormat,
|
||||
rowLimit,
|
||||
clearSelectColumns:
|
||||
dataSource !== DataSource.TRACES &&
|
||||
columnsScope === DownloadColumnsScopes.ALL,
|
||||
selectedColumns,
|
||||
clearSelectColumns: columnsScope === DownloadColumnsScopes.ALL,
|
||||
});
|
||||
}, [
|
||||
exportFormat,
|
||||
rowLimit,
|
||||
columnsScope,
|
||||
selectedColumns,
|
||||
handleExportRawData,
|
||||
dataSource,
|
||||
]);
|
||||
}, [exportFormat, rowLimit, columnsScope, handleExportRawData]);
|
||||
|
||||
const popoverContent = useMemo(
|
||||
() => (
|
||||
@@ -85,22 +72,18 @@ export default function DownloadOptionsMenu({
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
{dataSource !== DataSource.TRACES && (
|
||||
<>
|
||||
<div className="horizontal-line" />
|
||||
<div className="horizontal-line" />
|
||||
|
||||
<div className="columns-scope">
|
||||
<Typography.Text className="title">Columns</Typography.Text>
|
||||
<Radio.Group
|
||||
value={columnsScope}
|
||||
onChange={(e): void => setColumnsScope(e.target.value)}
|
||||
>
|
||||
<Radio value={DownloadColumnsScopes.ALL}>All</Radio>
|
||||
<Radio value={DownloadColumnsScopes.SELECTED}>Selected</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="columns-scope">
|
||||
<Typography.Text className="title">Columns</Typography.Text>
|
||||
<Radio.Group
|
||||
value={columnsScope}
|
||||
onChange={(e): void => setColumnsScope(e.target.value)}
|
||||
>
|
||||
<Radio value={DownloadColumnsScopes.ALL}>All</Radio>
|
||||
<Radio value={DownloadColumnsScopes.SELECTED}>Selected</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -114,14 +97,7 @@ export default function DownloadOptionsMenu({
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
[
|
||||
exportFormat,
|
||||
rowLimit,
|
||||
columnsScope,
|
||||
isDownloading,
|
||||
handleExport,
|
||||
dataSource,
|
||||
],
|
||||
[exportFormat, rowLimit, columnsScope, isDownloading, handleExport],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Badge } from '@signozhq/badge';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
|
||||
@@ -21,7 +20,7 @@ import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
getResetPasswordToken,
|
||||
useDeleteUser,
|
||||
useUpdateUserDeprecated,
|
||||
useUpdateUser,
|
||||
} from 'api/generated/services/users';
|
||||
import { AxiosError } from 'axios';
|
||||
import { MemberRow } from 'components/MembersTable/MembersTable';
|
||||
@@ -61,7 +60,7 @@ function EditMemberDrawer({
|
||||
|
||||
const isInvited = member?.status === MemberStatus.Invited;
|
||||
|
||||
const { mutate: updateUser, isLoading: isSaving } = useUpdateUserDeprecated({
|
||||
const { mutate: updateUser, isLoading: isSaving } = useUpdateUser({
|
||||
mutation: {
|
||||
onSuccess: (): void => {
|
||||
toast.success('Member details updated successfully', { richColors: true });
|
||||
@@ -178,30 +177,26 @@ function EditMemberDrawer({
|
||||
}
|
||||
}, [member, isInvited, setLinkType, onClose]);
|
||||
|
||||
const [copyState, copyToClipboard] = useCopyToClipboard();
|
||||
const handleCopyResetLink = useCallback(async (): Promise<void> => {
|
||||
if (!resetLink) {
|
||||
return;
|
||||
}
|
||||
copyToClipboard(resetLink);
|
||||
|
||||
setHasCopiedResetLink(true);
|
||||
setTimeout(() => setHasCopiedResetLink(false), 2000);
|
||||
toast.success(
|
||||
linkType === 'invite'
|
||||
? 'Invite link copied to clipboard'
|
||||
: 'Reset link copied to clipboard',
|
||||
{ richColors: true },
|
||||
);
|
||||
}, [resetLink, copyToClipboard, linkType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (copyState.error) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(resetLink);
|
||||
setHasCopiedResetLink(true);
|
||||
setTimeout(() => setHasCopiedResetLink(false), 2000);
|
||||
toast.success(
|
||||
linkType === 'invite'
|
||||
? 'Invite link copied to clipboard'
|
||||
: 'Reset link copied to clipboard',
|
||||
{ richColors: true },
|
||||
);
|
||||
} catch {
|
||||
toast.error('Failed to copy link', {
|
||||
richColors: true,
|
||||
});
|
||||
}
|
||||
}, [copyState.error]);
|
||||
}, [resetLink, linkType]);
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
setShowDeleteConfirm(false);
|
||||
|
||||
@@ -4,10 +4,16 @@ import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
getResetPasswordToken,
|
||||
useDeleteUser,
|
||||
useUpdateUserDeprecated,
|
||||
useUpdateUser,
|
||||
} from 'api/generated/services/users';
|
||||
import { MemberStatus } from 'container/MembersSettings/utils';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
import { ROLES } from 'types/roles';
|
||||
|
||||
import EditMemberDrawer, { EditMemberDrawerProps } from '../EditMemberDrawer';
|
||||
@@ -44,7 +50,7 @@ jest.mock('@signozhq/dialog', () => ({
|
||||
|
||||
jest.mock('api/generated/services/users', () => ({
|
||||
useDeleteUser: jest.fn(),
|
||||
useUpdateUserDeprecated: jest.fn(),
|
||||
useUpdateUser: jest.fn(),
|
||||
getResetPasswordToken: jest.fn(),
|
||||
}));
|
||||
|
||||
@@ -59,16 +65,6 @@ jest.mock('@signozhq/sonner', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const mockCopyToClipboard = jest.fn();
|
||||
const mockCopyState = { value: undefined, error: undefined };
|
||||
|
||||
jest.mock('react-use', () => ({
|
||||
useCopyToClipboard: (): [typeof mockCopyState, typeof mockCopyToClipboard] => [
|
||||
mockCopyState,
|
||||
mockCopyToClipboard,
|
||||
],
|
||||
}));
|
||||
|
||||
const mockUpdateMutate = jest.fn();
|
||||
const mockDeleteMutate = jest.fn();
|
||||
const mockGetResetPasswordToken = jest.mocked(getResetPasswordToken);
|
||||
@@ -109,7 +105,7 @@ function renderDrawer(
|
||||
describe('EditMemberDrawer', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useUpdateUserDeprecated as jest.Mock).mockReturnValue({
|
||||
(useUpdateUser as jest.Mock).mockReturnValue({
|
||||
mutate: mockUpdateMutate,
|
||||
isLoading: false,
|
||||
});
|
||||
@@ -134,7 +130,7 @@ describe('EditMemberDrawer', () => {
|
||||
const onComplete = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
(useUpdateUserDeprecated as jest.Mock).mockImplementation((options) => ({
|
||||
(useUpdateUser as jest.Mock).mockImplementation((options) => ({
|
||||
mutate: mockUpdateMutate.mockImplementation(() => {
|
||||
options?.mutation?.onSuccess?.();
|
||||
}),
|
||||
@@ -243,7 +239,7 @@ describe('EditMemberDrawer', () => {
|
||||
const onComplete = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
(useUpdateUserDeprecated as jest.Mock).mockImplementation((options) => ({
|
||||
(useUpdateUser as jest.Mock).mockImplementation((options) => ({
|
||||
mutate: mockUpdateMutate.mockImplementation(() => {
|
||||
options?.mutation?.onSuccess?.();
|
||||
}),
|
||||
@@ -284,7 +280,7 @@ describe('EditMemberDrawer', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockToast = jest.mocked(toast);
|
||||
|
||||
(useUpdateUserDeprecated as jest.Mock).mockImplementation((options) => ({
|
||||
(useUpdateUser as jest.Mock).mockImplementation((options) => ({
|
||||
mutate: mockUpdateMutate.mockImplementation(() => {
|
||||
options?.mutation?.onError?.({});
|
||||
}),
|
||||
@@ -365,14 +361,32 @@ describe('EditMemberDrawer', () => {
|
||||
});
|
||||
|
||||
describe('Generate Password Reset Link', () => {
|
||||
const mockWriteText = jest.fn().mockResolvedValue(undefined);
|
||||
let clipboardSpy: jest.SpyInstance | undefined;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: (): Promise<void> => Promise.resolve() },
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockCopyToClipboard.mockClear();
|
||||
mockWriteText.mockClear();
|
||||
clipboardSpy = jest
|
||||
.spyOn(navigator.clipboard, 'writeText')
|
||||
.mockImplementation(mockWriteText);
|
||||
mockGetResetPasswordToken.mockResolvedValue({
|
||||
status: 'success',
|
||||
data: { token: 'reset-tok-abc', id: 'user-1' },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clipboardSpy?.mockRestore();
|
||||
});
|
||||
|
||||
it('calls getResetPasswordToken and opens the reset link dialog with the generated link', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
@@ -407,7 +421,7 @@ describe('EditMemberDrawer', () => {
|
||||
});
|
||||
expect(dialog).toHaveTextContent('reset-tok-abc');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^copy$/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /^copy$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
@@ -416,7 +430,7 @@ describe('EditMemberDrawer', () => {
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith(
|
||||
expect(mockWriteText).toHaveBeenCalledWith(
|
||||
expect.stringContaining('reset-tok-abc'),
|
||||
);
|
||||
expect(screen.getByRole('button', { name: /copied!/i })).toBeInTheDocument();
|
||||
|
||||
@@ -202,8 +202,19 @@ function InviteMembersModal({
|
||||
onComplete?.();
|
||||
} catch (err) {
|
||||
const apiErr = err as APIError;
|
||||
const errorMessage = apiErr?.getErrorMessage?.() ?? 'An error occurred';
|
||||
toast.error(errorMessage, { richColors: true });
|
||||
if (apiErr?.getHttpStatusCode() === 409) {
|
||||
toast.error(
|
||||
touchedRows.length === 1
|
||||
? `${touchedRows[0].email} is already a member`
|
||||
: 'Invite for one or more users already exists',
|
||||
{ richColors: true },
|
||||
);
|
||||
} else {
|
||||
const errorMessage = apiErr?.getErrorMessage?.() ?? 'An error occurred';
|
||||
toast.error(`Failed to send invites: ${errorMessage}`, {
|
||||
richColors: true,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import inviteUsers from 'api/v1/invite/bulk/create';
|
||||
import sendInvite from 'api/v1/invite/create';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import InviteMembersModal from '../InviteMembersModal';
|
||||
|
||||
const makeApiError = (message: string, code = StatusCodes.CONFLICT): APIError =>
|
||||
new APIError({
|
||||
httpStatusCode: code,
|
||||
error: { code: 'already_exists', message, url: '', errors: [] },
|
||||
});
|
||||
|
||||
jest.mock('api/v1/invite/create');
|
||||
jest.mock('api/v1/invite/bulk/create');
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
@@ -151,90 +142,6 @@ describe('InviteMembersModal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('shows BE message on single invite 409', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
mockSendInvite.mockRejectedValue(
|
||||
makeApiError('An invite already exists for this email: single@signoz.io'),
|
||||
);
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
await user.type(
|
||||
screen.getAllByPlaceholderText('john@signoz.io')[0],
|
||||
'single@signoz.io',
|
||||
);
|
||||
await user.click(screen.getAllByText('Select roles')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
'An invite already exists for this email: single@signoz.io',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows BE message on bulk invite 409', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
mockInviteUsers.mockRejectedValue(
|
||||
makeApiError('An invite already exists for this email: alice@signoz.io'),
|
||||
);
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
|
||||
await user.type(emailInputs[0], 'alice@signoz.io');
|
||||
await user.click(screen.getAllByText('Select roles')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.type(emailInputs[1], 'bob@signoz.io');
|
||||
await user.click(screen.getAllByText('Select roles')[0]);
|
||||
const editorOptions = await screen.findAllByText('Editor');
|
||||
await user.click(editorOptions[editorOptions.length - 1]);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
'An invite already exists for this email: alice@signoz.io',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows BE message on generic error', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
mockSendInvite.mockRejectedValue(
|
||||
makeApiError('Internal server error', StatusCodes.INTERNAL_SERVER_ERROR),
|
||||
);
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
await user.type(
|
||||
screen.getAllByPlaceholderText('john@signoz.io')[0],
|
||||
'single@signoz.io',
|
||||
);
|
||||
await user.click(screen.getAllByText('Select roles')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
'Internal server error',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('uses inviteUsers (bulk) when multiple rows are filled', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onComplete = jest.fn();
|
||||
|
||||
@@ -17,7 +17,6 @@ function CodeCopyBtn({
|
||||
let copiedText = '';
|
||||
if (children && Array.isArray(children)) {
|
||||
setIsSnippetCopied(true);
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
navigator.clipboard.writeText(children[0].props.children[0]).finally(() => {
|
||||
copiedText = (children[0].props.children[0] as string).slice(0, 200); // slicing is done due to the limitation in accepted char length in attributes
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -401,7 +401,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
|
||||
const textToCopy = selectedTexts.join(', ');
|
||||
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
navigator.clipboard.writeText(textToCopy).catch(console.error);
|
||||
}, [selectedChips, selectedValues]);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { DialogWrapper } from '@signozhq/dialog';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
@@ -106,23 +105,19 @@ function AddKeyModal(): JSX.Element {
|
||||
});
|
||||
}
|
||||
|
||||
const [copyState, copyToClipboard] = useCopyToClipboard();
|
||||
const handleCopy = useCallback(async (): Promise<void> => {
|
||||
if (!createdKey?.key) {
|
||||
return;
|
||||
}
|
||||
|
||||
copyToClipboard(createdKey.key);
|
||||
setHasCopied(true);
|
||||
setTimeout(() => setHasCopied(false), 2000);
|
||||
toast.success('Key copied to clipboard', { richColors: true });
|
||||
}, [copyToClipboard, createdKey?.key]);
|
||||
|
||||
useEffect(() => {
|
||||
if (copyState.error) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdKey.key);
|
||||
setHasCopied(true);
|
||||
setTimeout(() => setHasCopied(false), 2000);
|
||||
toast.success('Key copied to clipboard', { richColors: true });
|
||||
} catch {
|
||||
toast.error('Failed to copy key', { richColors: true });
|
||||
}
|
||||
}, [copyState.error]);
|
||||
}, [createdKey]);
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
setIsAddKeyOpen(null);
|
||||
|
||||
@@ -9,16 +9,6 @@ jest.mock('@signozhq/sonner', () => ({
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
const mockCopyToClipboard = jest.fn();
|
||||
const mockCopyState = { value: undefined, error: undefined };
|
||||
|
||||
jest.mock('react-use', () => ({
|
||||
useCopyToClipboard: (): [typeof mockCopyState, typeof mockCopyToClipboard] => [
|
||||
mockCopyState,
|
||||
mockCopyToClipboard,
|
||||
],
|
||||
}));
|
||||
|
||||
const mockToast = jest.mocked(toast);
|
||||
|
||||
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/sa-1/keys';
|
||||
@@ -45,9 +35,16 @@ function renderModal(): ReturnType<typeof render> {
|
||||
}
|
||||
|
||||
describe('AddKeyModal', () => {
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: jest.fn().mockResolvedValue(undefined) },
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockCopyToClipboard.mockClear();
|
||||
server.use(
|
||||
rest.post(SA_KEYS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json(createdKeyResponse)),
|
||||
@@ -93,6 +90,9 @@ describe('AddKeyModal', () => {
|
||||
|
||||
it('copy button writes key to clipboard and shows toast.success', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const writeTextSpy = jest
|
||||
.spyOn(navigator.clipboard, 'writeText')
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
renderModal();
|
||||
|
||||
@@ -115,12 +115,14 @@ describe('AddKeyModal', () => {
|
||||
await user.click(copyBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith('snz_abc123xyz456secret');
|
||||
expect(writeTextSpy).toHaveBeenCalledWith('snz_abc123xyz456secret');
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Key copied to clipboard',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
writeTextSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('Cancel button closes the modal', async () => {
|
||||
|
||||
@@ -16,9 +16,9 @@ function AverageResolutionCard({
|
||||
}: TotalTriggeredCardProps): JSX.Element {
|
||||
return (
|
||||
<StatsCard
|
||||
displayValue={formatTime(+currentAvgResolutionTime)}
|
||||
totalCurrentCount={+currentAvgResolutionTime}
|
||||
totalPastCount={+pastAvgResolutionTime}
|
||||
displayValue={formatTime(currentAvgResolutionTime)}
|
||||
totalCurrentCount={currentAvgResolutionTime}
|
||||
totalPastCount={pastAvgResolutionTime}
|
||||
title="Avg. Resolution Time"
|
||||
timeSeries={timeSeries}
|
||||
/>
|
||||
|
||||
@@ -800,10 +800,14 @@
|
||||
|
||||
.ant-table-cell:has(.top-services-item-latency) {
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
background: rgba(171, 189, 255, 0.04);
|
||||
}
|
||||
|
||||
.ant-table-cell:has(.top-services-item-latency-title) {
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
background: rgba(171, 189, 255, 0.04);
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr:hover > td {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
.api-quick-filters-header {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--bg-slate-400);
|
||||
border-right: 1px solid var(--bg-slate-400);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -25,7 +26,6 @@
|
||||
width: 100%;
|
||||
|
||||
.toolbar {
|
||||
border-top: 0px;
|
||||
border-bottom: 1px solid var(--bg-slate-400);
|
||||
}
|
||||
|
||||
@@ -220,18 +220,6 @@
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.api-quick-filter-left-section {
|
||||
.api-quick-filters-header {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
|
||||
.api-module-right-section {
|
||||
.toolbar {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
|
||||
.no-filtered-domains-message-container {
|
||||
.no-filtered-domains-message-content {
|
||||
.no-filtered-domains-message {
|
||||
|
||||
@@ -577,6 +577,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
open={isPanelNameModalOpen}
|
||||
title="New Section"
|
||||
rootClassName="section-naming"
|
||||
// This is not required on the specification of a custom footer
|
||||
onOk={(): void => handleAddRow()}
|
||||
onCancel={(): void => {
|
||||
setIsPanelNameModalOpen(false);
|
||||
|
||||
@@ -464,10 +464,14 @@ function GeneralSettings({
|
||||
onModalToggleHandler(type);
|
||||
};
|
||||
|
||||
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
|
||||
const {
|
||||
isCloudUser: isCloudUserVal,
|
||||
isEnterpriseSelfHostedUser,
|
||||
} = useGetTenantLicense();
|
||||
|
||||
const isAdmin = user.role === USER_ROLES.ADMIN;
|
||||
const showCustomDomainSettings = isCloudUserVal && isAdmin;
|
||||
const showCustomDomainSettings =
|
||||
(isCloudUserVal || isEnterpriseSelfHostedUser) && isAdmin;
|
||||
|
||||
const renderConfig = [
|
||||
{
|
||||
|
||||
@@ -38,6 +38,7 @@ jest.mock('hooks/useComponentPermission', () => ({
|
||||
jest.mock('hooks/useGetTenantLicense', () => ({
|
||||
useGetTenantLicense: jest.fn(() => ({
|
||||
isCloudUser: false,
|
||||
isEnterpriseSelfHostedUser: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -388,6 +389,7 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
beforeEach(() => {
|
||||
(useGetTenantLicense as jest.Mock).mockReturnValue({
|
||||
isCloudUser: true,
|
||||
isEnterpriseSelfHostedUser: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -409,14 +411,15 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Non-cloud user rendering', () => {
|
||||
describe('Enterprise Self-Hosted User Rendering', () => {
|
||||
beforeEach(() => {
|
||||
(useGetTenantLicense as jest.Mock).mockReturnValue({
|
||||
isCloudUser: false,
|
||||
isEnterpriseSelfHostedUser: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not render CustomDomainSettings or GeneralSettingsCloud', () => {
|
||||
it('should render CustomDomainSettings but not GeneralSettingsCloud', () => {
|
||||
render(
|
||||
<GeneralSettings
|
||||
metricsTtlValuesPayload={mockMetricsRetention}
|
||||
@@ -429,14 +432,12 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('custom-domain-settings'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('custom-domain-settings')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('general-settings-cloud'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Save buttons should be visible for non-cloud users (these are from retentions)
|
||||
// Save buttons should be visible for self-hosted
|
||||
const saveButtons = screen.getAllByRole('button', { name: /save/i });
|
||||
expect(saveButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { errorDetails } from '../utils';
|
||||
|
||||
function makeAPIError(
|
||||
message: string,
|
||||
code = 'SOME_CODE',
|
||||
errors: { message: string }[] = [],
|
||||
): APIError {
|
||||
return new APIError({
|
||||
httpStatusCode: 500,
|
||||
error: { code, message, url: '', errors },
|
||||
});
|
||||
}
|
||||
|
||||
describe('errorDetails', () => {
|
||||
describe('when passed an APIError', () => {
|
||||
it('returns the error message', () => {
|
||||
const error = makeAPIError('something went wrong');
|
||||
expect(errorDetails(error)).toBe('something went wrong');
|
||||
});
|
||||
|
||||
it('appends details when errors array is non-empty', () => {
|
||||
const error = makeAPIError('query failed', 'QUERY_ERROR', [
|
||||
{ message: 'field X is invalid' },
|
||||
{ message: 'field Y is missing' },
|
||||
]);
|
||||
const result = errorDetails(error);
|
||||
expect(result).toContain('query failed');
|
||||
expect(result).toContain('field X is invalid');
|
||||
expect(result).toContain('field Y is missing');
|
||||
});
|
||||
|
||||
it('does not append details when errors array is empty', () => {
|
||||
const error = makeAPIError('simple error', 'CODE', []);
|
||||
const result = errorDetails(error);
|
||||
expect(result).toBe('simple error');
|
||||
expect(result).not.toContain('Details');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when passed a plain Error (not an APIError)', () => {
|
||||
it('does not throw', () => {
|
||||
const error = new Error('timeout exceeded');
|
||||
expect(() => errorDetails(error)).not.toThrow();
|
||||
});
|
||||
|
||||
it('returns the plain error message', () => {
|
||||
const error = new Error('timeout exceeded');
|
||||
expect(errorDetails(error)).toBe('timeout exceeded');
|
||||
});
|
||||
|
||||
it('returns fallback when plain Error has no message', () => {
|
||||
const error = new Error('');
|
||||
expect(errorDetails(error)).toBe('Unknown error occurred');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fallback behaviour', () => {
|
||||
it('returns "Unknown error occurred" when message is undefined', () => {
|
||||
const error = makeAPIError('');
|
||||
expect(errorDetails(error)).toBe('Unknown error occurred');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -249,14 +249,13 @@ export const handleGraphClick = async ({
|
||||
}
|
||||
};
|
||||
|
||||
export const errorDetails = (error: APIError | Error): string => {
|
||||
const { message, errors } =
|
||||
(error instanceof APIError ? error.getErrorDetails()?.error : null) || {};
|
||||
export const errorDetails = (error: APIError): string => {
|
||||
const { message, errors } = error.getErrorDetails()?.error || {};
|
||||
|
||||
const details =
|
||||
errors && errors.length > 0
|
||||
errors?.length > 0
|
||||
? `\n\nDetails: ${errors.map((e) => e.message).join('\n')}`
|
||||
: '';
|
||||
const errorDetails = `${message ?? error.message} ${details}`;
|
||||
return errorDetails.trim() || 'Unknown error occurred';
|
||||
const errorDetails = `${message} ${details}`;
|
||||
return errorDetails || 'Unknown error occurred';
|
||||
};
|
||||
|
||||
@@ -48,7 +48,6 @@ import DashboardEmptyState from './DashboardEmptyState/DashboardEmptyState';
|
||||
import GridCard from './GridCard';
|
||||
import { Card, CardContainer, ReactGridLayout } from './styles';
|
||||
import {
|
||||
applyRowCollapse,
|
||||
hasColumnWidthsChanged,
|
||||
removeUndefinedValuesFromLayout,
|
||||
} from './utils';
|
||||
@@ -269,10 +268,13 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedWidgets = selectedDashboard?.data?.widgets?.map((e) =>
|
||||
e.id === currentSelectRowId ? { ...e, title: newTitle } : e,
|
||||
currentWidget.title = newTitle;
|
||||
const updatedWidgets = selectedDashboard?.data?.widgets?.filter(
|
||||
(e) => e.id !== currentSelectRowId,
|
||||
);
|
||||
|
||||
updatedWidgets?.push(currentWidget);
|
||||
|
||||
const updatedSelectedDashboard: Props = {
|
||||
id: selectedDashboard.id,
|
||||
data: {
|
||||
@@ -314,13 +316,88 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||
if (!selectedDashboard) {
|
||||
return;
|
||||
}
|
||||
const { updatedLayout, updatedPanelMap } = applyRowCollapse(
|
||||
id,
|
||||
dashboardLayout,
|
||||
currentPanelMap,
|
||||
);
|
||||
setCurrentPanelMap((prev) => ({ ...prev, ...updatedPanelMap }));
|
||||
setDashboardLayout(sortLayout(updatedLayout));
|
||||
const rowProperties = { ...currentPanelMap[id] };
|
||||
const updatedPanelMap = { ...currentPanelMap };
|
||||
|
||||
let updatedDashboardLayout = [...dashboardLayout];
|
||||
if (rowProperties.collapsed === true) {
|
||||
rowProperties.collapsed = false;
|
||||
const widgetsInsideTheRow = rowProperties.widgets;
|
||||
let maxY = 0;
|
||||
widgetsInsideTheRow.forEach((w) => {
|
||||
maxY = Math.max(maxY, w.y + w.h);
|
||||
});
|
||||
const currentRowWidget = dashboardLayout.find((w) => w.i === id);
|
||||
if (currentRowWidget && widgetsInsideTheRow.length) {
|
||||
maxY -= currentRowWidget.h + currentRowWidget.y;
|
||||
}
|
||||
|
||||
const idxCurrentRow = dashboardLayout.findIndex((w) => w.i === id);
|
||||
|
||||
for (let j = idxCurrentRow + 1; j < dashboardLayout.length; j++) {
|
||||
updatedDashboardLayout[j].y += maxY;
|
||||
if (updatedPanelMap[updatedDashboardLayout[j].i]) {
|
||||
updatedPanelMap[updatedDashboardLayout[j].i].widgets = updatedPanelMap[
|
||||
updatedDashboardLayout[j].i
|
||||
].widgets.map((w) => ({
|
||||
...w,
|
||||
y: w.y + maxY,
|
||||
}));
|
||||
}
|
||||
}
|
||||
updatedDashboardLayout = [...updatedDashboardLayout, ...widgetsInsideTheRow];
|
||||
} else {
|
||||
rowProperties.collapsed = true;
|
||||
const currentIdx = dashboardLayout.findIndex((w) => w.i === id);
|
||||
|
||||
let widgetsInsideTheRow: Layout[] = [];
|
||||
let isPanelMapUpdated = false;
|
||||
for (let j = currentIdx + 1; j < dashboardLayout.length; j++) {
|
||||
if (currentPanelMap[dashboardLayout[j].i]) {
|
||||
rowProperties.widgets = widgetsInsideTheRow;
|
||||
widgetsInsideTheRow = [];
|
||||
isPanelMapUpdated = true;
|
||||
break;
|
||||
} else {
|
||||
widgetsInsideTheRow.push(dashboardLayout[j]);
|
||||
}
|
||||
}
|
||||
if (!isPanelMapUpdated) {
|
||||
rowProperties.widgets = widgetsInsideTheRow;
|
||||
}
|
||||
let maxY = 0;
|
||||
widgetsInsideTheRow.forEach((w) => {
|
||||
maxY = Math.max(maxY, w.y + w.h);
|
||||
});
|
||||
const currentRowWidget = dashboardLayout[currentIdx];
|
||||
if (currentRowWidget && widgetsInsideTheRow.length) {
|
||||
maxY -= currentRowWidget.h + currentRowWidget.y;
|
||||
}
|
||||
for (let j = currentIdx + 1; j < updatedDashboardLayout.length; j++) {
|
||||
updatedDashboardLayout[j].y += maxY;
|
||||
if (updatedPanelMap[updatedDashboardLayout[j].i]) {
|
||||
updatedPanelMap[updatedDashboardLayout[j].i].widgets = updatedPanelMap[
|
||||
updatedDashboardLayout[j].i
|
||||
].widgets.map((w) => ({
|
||||
...w,
|
||||
y: w.y + maxY,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
updatedDashboardLayout = updatedDashboardLayout.filter(
|
||||
(widget) => !rowProperties.widgets.some((w: Layout) => w.i === widget.i),
|
||||
);
|
||||
}
|
||||
setCurrentPanelMap((prev) => ({
|
||||
...prev,
|
||||
...updatedPanelMap,
|
||||
[id]: {
|
||||
...rowProperties,
|
||||
},
|
||||
}));
|
||||
|
||||
setDashboardLayout(sortLayout(updatedDashboardLayout));
|
||||
};
|
||||
|
||||
const handleDragStop: ItemCallback = (_, oldItem, newItem): void => {
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
import { Layout } from 'react-grid-layout';
|
||||
|
||||
import { applyRowCollapse, PanelMap } from '../utils';
|
||||
|
||||
// Helper to produce deeply-frozen objects that mimic what zustand/immer returns.
|
||||
function freeze<T>(obj: T): T {
|
||||
return JSON.parse(JSON.stringify(obj), (_, v) =>
|
||||
typeof v === 'object' && v !== null ? Object.freeze(v) : v,
|
||||
) as T;
|
||||
}
|
||||
|
||||
// ─── fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
const ROW_ID = 'row1';
|
||||
|
||||
/** A layout with one row followed by two widgets. */
|
||||
function makeLayout(): Layout[] {
|
||||
return [
|
||||
{ i: ROW_ID, x: 0, y: 0, w: 12, h: 1 },
|
||||
{ i: 'w1', x: 0, y: 1, w: 6, h: 4 },
|
||||
{ i: 'w2', x: 6, y: 1, w: 6, h: 4 },
|
||||
];
|
||||
}
|
||||
|
||||
/** panelMap where the row is expanded (collapsed = false, widgets = []). */
|
||||
function makeExpandedPanelMap(): PanelMap {
|
||||
return {
|
||||
[ROW_ID]: { collapsed: false, widgets: [] },
|
||||
};
|
||||
}
|
||||
|
||||
/** panelMap where the row is collapsed (widgets stored inside). */
|
||||
function makeCollapsedPanelMap(): PanelMap {
|
||||
return {
|
||||
[ROW_ID]: {
|
||||
collapsed: true,
|
||||
widgets: [
|
||||
{ i: 'w1', x: 0, y: 1, w: 6, h: 4 },
|
||||
{ i: 'w2', x: 6, y: 1, w: 6, h: 4 },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── frozen-input guard (regression for zustand/immer read-only bug) ──────────
|
||||
|
||||
describe('applyRowCollapse – does not mutate frozen inputs', () => {
|
||||
it('does not throw when collapsing a row with frozen layout + panelMap', () => {
|
||||
expect(() =>
|
||||
applyRowCollapse(
|
||||
ROW_ID,
|
||||
freeze(makeLayout()),
|
||||
freeze(makeExpandedPanelMap()),
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('does not throw when expanding a row with frozen layout + panelMap', () => {
|
||||
// Collapsed layout only has the row item; widgets live in panelMap.
|
||||
const collapsedLayout = freeze([{ i: ROW_ID, x: 0, y: 0, w: 12, h: 1 }]);
|
||||
expect(() =>
|
||||
applyRowCollapse(ROW_ID, collapsedLayout, freeze(makeCollapsedPanelMap())),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('leaves the original layout array untouched after collapse', () => {
|
||||
const layout = makeLayout();
|
||||
const originalY = layout[1].y; // w1.y before collapse
|
||||
applyRowCollapse(ROW_ID, layout, makeExpandedPanelMap());
|
||||
expect(layout[1].y).toBe(originalY);
|
||||
});
|
||||
|
||||
it('leaves the original panelMap untouched after collapse', () => {
|
||||
const panelMap = makeExpandedPanelMap();
|
||||
applyRowCollapse(ROW_ID, makeLayout(), panelMap);
|
||||
expect(panelMap[ROW_ID].collapsed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── collapse behaviour ───────────────────────────────────────────────────────
|
||||
|
||||
describe('applyRowCollapse – collapsing a row', () => {
|
||||
it('sets collapsed = true on the row entry', () => {
|
||||
const { updatedPanelMap } = applyRowCollapse(
|
||||
ROW_ID,
|
||||
makeLayout(),
|
||||
makeExpandedPanelMap(),
|
||||
);
|
||||
expect(updatedPanelMap[ROW_ID].collapsed).toBe(true);
|
||||
});
|
||||
|
||||
it('stores the child widgets inside the panelMap entry', () => {
|
||||
const { updatedPanelMap } = applyRowCollapse(
|
||||
ROW_ID,
|
||||
makeLayout(),
|
||||
makeExpandedPanelMap(),
|
||||
);
|
||||
const ids = updatedPanelMap[ROW_ID].widgets.map((w) => w.i);
|
||||
expect(ids).toContain('w1');
|
||||
expect(ids).toContain('w2');
|
||||
});
|
||||
|
||||
it('removes child widgets from the returned layout', () => {
|
||||
const { updatedLayout } = applyRowCollapse(
|
||||
ROW_ID,
|
||||
makeLayout(),
|
||||
makeExpandedPanelMap(),
|
||||
);
|
||||
const ids = updatedLayout.map((l) => l.i);
|
||||
expect(ids).not.toContain('w1');
|
||||
expect(ids).not.toContain('w2');
|
||||
expect(ids).toContain(ROW_ID);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── expand behaviour ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('applyRowCollapse – expanding a row', () => {
|
||||
it('sets collapsed = false on the row entry', () => {
|
||||
const collapsedLayout: Layout[] = [{ i: ROW_ID, x: 0, y: 0, w: 12, h: 1 }];
|
||||
const { updatedPanelMap } = applyRowCollapse(
|
||||
ROW_ID,
|
||||
collapsedLayout,
|
||||
makeCollapsedPanelMap(),
|
||||
);
|
||||
expect(updatedPanelMap[ROW_ID].collapsed).toBe(false);
|
||||
});
|
||||
|
||||
it('restores child widgets to the returned layout', () => {
|
||||
const collapsedLayout: Layout[] = [{ i: ROW_ID, x: 0, y: 0, w: 12, h: 1 }];
|
||||
const { updatedLayout } = applyRowCollapse(
|
||||
ROW_ID,
|
||||
collapsedLayout,
|
||||
makeCollapsedPanelMap(),
|
||||
);
|
||||
const ids = updatedLayout.map((l) => l.i);
|
||||
expect(ids).toContain('w1');
|
||||
expect(ids).toContain('w2');
|
||||
});
|
||||
|
||||
it('restored child widgets appear in both the layout and the panelMap entry', () => {
|
||||
const collapsedLayout: Layout[] = [{ i: ROW_ID, x: 0, y: 0, w: 12, h: 1 }];
|
||||
const { updatedLayout, updatedPanelMap } = applyRowCollapse(
|
||||
ROW_ID,
|
||||
collapsedLayout,
|
||||
makeCollapsedPanelMap(),
|
||||
);
|
||||
// The previously-stored widgets should now be back in the live layout.
|
||||
expect(updatedLayout.map((l) => l.i)).toContain('w1');
|
||||
// The panelMap entry still holds a reference to them (stale until next collapse).
|
||||
expect(updatedPanelMap[ROW_ID].widgets.map((w) => w.i)).toContain('w1');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── y-offset adjustment ──────────────────────────────────────────────────────
|
||||
|
||||
describe('applyRowCollapse – y-offset adjustments for rows below', () => {
|
||||
it('shifts items below a second row down when the first row expands', () => {
|
||||
const ROW2 = 'row2';
|
||||
// Layout: row1 (y=0,h=1) | w1 (y=1,h=4) | row2 (y=5,h=1) | w3 (y=6,h=2)
|
||||
const layout: Layout[] = [
|
||||
{ i: ROW_ID, x: 0, y: 0, w: 12, h: 1 },
|
||||
{ i: 'w1', x: 0, y: 1, w: 12, h: 4 },
|
||||
{ i: ROW2, x: 0, y: 5, w: 12, h: 1 },
|
||||
{ i: 'w3', x: 0, y: 6, w: 12, h: 2 },
|
||||
];
|
||||
const panelMap: PanelMap = {
|
||||
[ROW_ID]: {
|
||||
collapsed: true,
|
||||
widgets: [{ i: 'w1', x: 0, y: 1, w: 12, h: 4 }],
|
||||
},
|
||||
[ROW2]: { collapsed: false, widgets: [] },
|
||||
};
|
||||
// Expanding row1 should push row2 and w3 down by the height of w1 (4).
|
||||
const collapsedLayout = layout.filter((l) => l.i !== 'w1');
|
||||
const { updatedLayout } = applyRowCollapse(ROW_ID, collapsedLayout, panelMap);
|
||||
|
||||
const row2Item = updatedLayout.find((l) => l.i === ROW2);
|
||||
expect(row2Item?.y).toBe(5 + 4); // shifted by maxY = 4
|
||||
});
|
||||
});
|
||||
@@ -4,122 +4,6 @@ import { isEmpty, isEqual } from 'lodash-es';
|
||||
import { Dashboard, Widgets } from 'types/api/dashboard/getAll';
|
||||
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export type PanelMap = Record<
|
||||
string,
|
||||
{ widgets: Layout[]; collapsed: boolean }
|
||||
>;
|
||||
|
||||
export interface RowCollapseResult {
|
||||
updatedLayout: Layout[];
|
||||
updatedPanelMap: PanelMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function that computes the new layout and panelMap after toggling a
|
||||
* row's collapsed state. All inputs are treated as immutable — no input object
|
||||
* is mutated, so it is safe to pass frozen objects from the zustand store.
|
||||
*/
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export function applyRowCollapse(
|
||||
id: string,
|
||||
dashboardLayout: Layout[],
|
||||
currentPanelMap: PanelMap,
|
||||
): RowCollapseResult {
|
||||
// Deep-copy the row's own properties so we can mutate our local copy.
|
||||
const rowProperties = {
|
||||
...currentPanelMap[id],
|
||||
widgets: [...(currentPanelMap[id]?.widgets ?? [])],
|
||||
};
|
||||
|
||||
// Shallow-copy each entry's widgets array so inner .map() calls are safe.
|
||||
const updatedPanelMap: PanelMap = Object.fromEntries(
|
||||
Object.entries(currentPanelMap).map(([k, v]) => [
|
||||
k,
|
||||
{ ...v, widgets: [...v.widgets] },
|
||||
]),
|
||||
);
|
||||
|
||||
let updatedDashboardLayout = [...dashboardLayout];
|
||||
|
||||
if (rowProperties.collapsed === true) {
|
||||
// ── EXPAND ──────────────────────────────────────────────────────────────
|
||||
rowProperties.collapsed = false;
|
||||
const widgetsInsideTheRow = rowProperties.widgets;
|
||||
|
||||
let maxY = 0;
|
||||
widgetsInsideTheRow.forEach((w) => {
|
||||
maxY = Math.max(maxY, w.y + w.h);
|
||||
});
|
||||
const currentRowWidget = dashboardLayout.find((w) => w.i === id);
|
||||
if (currentRowWidget && widgetsInsideTheRow.length) {
|
||||
maxY -= currentRowWidget.h + currentRowWidget.y;
|
||||
}
|
||||
|
||||
const idxCurrentRow = dashboardLayout.findIndex((w) => w.i === id);
|
||||
for (let j = idxCurrentRow + 1; j < dashboardLayout.length; j++) {
|
||||
updatedDashboardLayout[j] = {
|
||||
...updatedDashboardLayout[j],
|
||||
y: updatedDashboardLayout[j].y + maxY,
|
||||
};
|
||||
if (updatedPanelMap[updatedDashboardLayout[j].i]) {
|
||||
updatedPanelMap[updatedDashboardLayout[j].i].widgets = updatedPanelMap[
|
||||
updatedDashboardLayout[j].i
|
||||
].widgets.map((w) => ({ ...w, y: w.y + maxY }));
|
||||
}
|
||||
}
|
||||
updatedDashboardLayout = [...updatedDashboardLayout, ...widgetsInsideTheRow];
|
||||
} else {
|
||||
// ── COLLAPSE ─────────────────────────────────────────────────────────────
|
||||
rowProperties.collapsed = true;
|
||||
const currentIdx = dashboardLayout.findIndex((w) => w.i === id);
|
||||
|
||||
let widgetsInsideTheRow: Layout[] = [];
|
||||
let isPanelMapUpdated = false;
|
||||
for (let j = currentIdx + 1; j < dashboardLayout.length; j++) {
|
||||
if (currentPanelMap[dashboardLayout[j].i]) {
|
||||
rowProperties.widgets = widgetsInsideTheRow;
|
||||
widgetsInsideTheRow = [];
|
||||
isPanelMapUpdated = true;
|
||||
break;
|
||||
} else {
|
||||
widgetsInsideTheRow.push(dashboardLayout[j]);
|
||||
}
|
||||
}
|
||||
if (!isPanelMapUpdated) {
|
||||
rowProperties.widgets = widgetsInsideTheRow;
|
||||
}
|
||||
|
||||
let maxY = 0;
|
||||
widgetsInsideTheRow.forEach((w) => {
|
||||
maxY = Math.max(maxY, w.y + w.h);
|
||||
});
|
||||
const currentRowWidget = dashboardLayout[currentIdx];
|
||||
if (currentRowWidget && widgetsInsideTheRow.length) {
|
||||
maxY -= currentRowWidget.h + currentRowWidget.y;
|
||||
}
|
||||
|
||||
for (let j = currentIdx + 1; j < updatedDashboardLayout.length; j++) {
|
||||
updatedDashboardLayout[j] = {
|
||||
...updatedDashboardLayout[j],
|
||||
y: updatedDashboardLayout[j].y + maxY,
|
||||
};
|
||||
if (updatedPanelMap[updatedDashboardLayout[j].i]) {
|
||||
updatedPanelMap[updatedDashboardLayout[j].i].widgets = updatedPanelMap[
|
||||
updatedDashboardLayout[j].i
|
||||
].widgets.map((w) => ({ ...w, y: w.y + maxY }));
|
||||
}
|
||||
}
|
||||
|
||||
updatedDashboardLayout = updatedDashboardLayout.filter(
|
||||
(widget) => !rowProperties.widgets.some((w: Layout) => w.i === widget.i),
|
||||
);
|
||||
}
|
||||
|
||||
updatedPanelMap[id] = { ...rowProperties };
|
||||
|
||||
return { updatedLayout: updatedDashboardLayout, updatedPanelMap };
|
||||
}
|
||||
|
||||
export const removeUndefinedValuesFromLayout = (layout: Layout[]): Layout[] =>
|
||||
layout.map((obj) =>
|
||||
Object.fromEntries(
|
||||
|
||||
@@ -217,7 +217,7 @@ function K8sVolumesList({
|
||||
{
|
||||
dataSource: currentQuery.builder.queryData[0].dataSource,
|
||||
aggregateAttribute: GetK8sEntityToAggregateAttribute(
|
||||
K8sCategory.VOLUMES,
|
||||
K8sCategory.NODES,
|
||||
dotMetricsEnabled,
|
||||
),
|
||||
aggregateOperator: 'noop',
|
||||
@@ -228,7 +228,7 @@ function K8sVolumesList({
|
||||
queryKey: [currentQuery.builder.queryData[0].dataSource, 'noop'],
|
||||
},
|
||||
true,
|
||||
K8sCategory.VOLUMES,
|
||||
K8sCategory.NODES,
|
||||
);
|
||||
|
||||
const query = useMemo(() => {
|
||||
@@ -597,7 +597,7 @@ function K8sVolumesList({
|
||||
isLoadingGroupByFilters={isLoadingGroupByFilters}
|
||||
handleGroupByChange={handleGroupByChange}
|
||||
selectedGroupBy={groupBy}
|
||||
entity={K8sCategory.VOLUMES}
|
||||
entity={K8sCategory.NODES}
|
||||
showAutoRefresh={!selectedVolumeData}
|
||||
/>
|
||||
{isError && <Typography>{data?.error || 'Something went wrong'}</Typography>}
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
import setupCommonMocks from '../commonMocks';
|
||||
|
||||
setupCommonMocks();
|
||||
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import K8sVolumesList from 'container/InfraMonitoringK8s/Volumes/K8sVolumesList';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { IAppContext, IUser } from 'providers/App/types';
|
||||
import store from 'store';
|
||||
import { LicenseResModel } from 'types/api/licensesV3/getActive';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
cacheTime: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const SERVER_URL = 'http://localhost/api';
|
||||
|
||||
describe('K8sVolumesList - useGetAggregateKeys Category Regression', () => {
|
||||
let requestsMade: Array<{
|
||||
url: string;
|
||||
params: URLSearchParams;
|
||||
body?: any;
|
||||
}> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
requestsMade = [];
|
||||
queryClient.clear();
|
||||
|
||||
server.use(
|
||||
rest.get(`${SERVER_URL}/v3/autocomplete/attribute_keys`, (req, res, ctx) => {
|
||||
const url = req.url.toString();
|
||||
const params = req.url.searchParams;
|
||||
|
||||
requestsMade.push({
|
||||
url,
|
||||
params,
|
||||
});
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
attributeKeys: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
rest.post(`${SERVER_URL}/v1/pvcs/list`, (req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
type: 'list',
|
||||
records: [],
|
||||
groups: null,
|
||||
total: 0,
|
||||
sentAnyHostMetricsData: false,
|
||||
isSendingK8SAgentMetrics: false,
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('should call aggregate keys API with k8s_volume_capacity', async () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<K8sVolumesList
|
||||
isFiltersVisible={false}
|
||||
handleFilterVisibilityChange={jest.fn()}
|
||||
quickFiltersLastUpdated={-1}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestsMade.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Find the attribute_keys request
|
||||
const attributeKeysRequest = requestsMade.find((req) =>
|
||||
req.url.includes('/autocomplete/attribute_keys'),
|
||||
);
|
||||
|
||||
expect(attributeKeysRequest).toBeDefined();
|
||||
|
||||
const aggregateAttribute = attributeKeysRequest?.params.get(
|
||||
'aggregateAttribute',
|
||||
);
|
||||
|
||||
expect(aggregateAttribute).toBe('k8s_volume_capacity');
|
||||
});
|
||||
|
||||
it('should call aggregate keys API with k8s.volume.capacity when dotMetrics enabled', async () => {
|
||||
jest
|
||||
.spyOn(await import('providers/App/App'), 'useAppContext')
|
||||
.mockReturnValue({
|
||||
featureFlags: [
|
||||
{
|
||||
name: FeatureKeys.DOT_METRICS_ENABLED,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: 0,
|
||||
route: '',
|
||||
},
|
||||
],
|
||||
user: { role: 'ADMIN' } as IUser,
|
||||
activeLicense: (null as unknown) as LicenseResModel,
|
||||
} as IAppContext);
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<K8sVolumesList
|
||||
isFiltersVisible={false}
|
||||
handleFilterVisibilityChange={jest.fn()}
|
||||
quickFiltersLastUpdated={-1}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestsMade.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const attributeKeysRequest = requestsMade.find((req) =>
|
||||
req.url.includes('/autocomplete/attribute_keys'),
|
||||
);
|
||||
|
||||
expect(attributeKeysRequest).toBeDefined();
|
||||
|
||||
const aggregateAttribute = attributeKeysRequest?.params.get(
|
||||
'aggregateAttribute',
|
||||
);
|
||||
|
||||
expect(aggregateAttribute).toBe('k8s.volume.capacity');
|
||||
});
|
||||
});
|
||||
@@ -92,10 +92,7 @@ function LogsActionsContainer({
|
||||
/>
|
||||
</div>
|
||||
<div className="download-options-container">
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.LOGS}
|
||||
selectedColumns={options?.selectColumns}
|
||||
/>
|
||||
<DownloadOptionsMenu dataSource={DataSource.LOGS} />
|
||||
</div>
|
||||
<div className="format-options-container">
|
||||
<LogsFormatOptionsMenu
|
||||
|
||||
@@ -42,15 +42,8 @@ function LogsPanelComponent({
|
||||
setPageSize(value);
|
||||
setOffset(0);
|
||||
setRequestData((prev) => {
|
||||
const newQueryData = {
|
||||
...prev.query,
|
||||
builder: {
|
||||
...prev.query.builder,
|
||||
queryData: prev.query.builder.queryData.map((qd, i) =>
|
||||
i === 0 ? { ...qd, pageSize: value } : qd,
|
||||
),
|
||||
},
|
||||
};
|
||||
const newQueryData = { ...prev.query };
|
||||
newQueryData.builder.queryData[0].pageSize = value;
|
||||
return {
|
||||
...prev,
|
||||
query: newQueryData,
|
||||
|
||||
@@ -16,8 +16,10 @@ export interface NewWidgetProps {
|
||||
}
|
||||
|
||||
export interface WidgetGraphProps {
|
||||
// this should be inside the querier plugin definition
|
||||
selectedLogFields: Widgets['selectedLogFields'];
|
||||
setSelectedLogFields?: Dispatch<SetStateAction<Widgets['selectedLogFields']>>;
|
||||
// this should be inside the querier plugin definition
|
||||
selectedTracesFields: Widgets['selectedTracesFields'];
|
||||
setSelectedTracesFields?: Dispatch<
|
||||
SetStateAction<Widgets['selectedTracesFields']>
|
||||
|
||||
@@ -42,19 +42,11 @@ function Panel({
|
||||
};
|
||||
}
|
||||
|
||||
updatedQuery.builder.queryData[0].pageSize = 10;
|
||||
const initialDataSource = updatedQuery.builder.queryData[0].dataSource;
|
||||
const updatedQueryForList = {
|
||||
...updatedQuery,
|
||||
builder: {
|
||||
...updatedQuery.builder,
|
||||
queryData: updatedQuery.builder.queryData.map((qd, i) =>
|
||||
i === 0 ? { ...qd, pageSize: 10 } : qd,
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
query: updatedQueryForList,
|
||||
query: updatedQuery,
|
||||
graphType: PANEL_TYPES.LIST,
|
||||
selectedTime: widget.timePreferance || 'GLOBAL_TIME',
|
||||
tableParams: {
|
||||
|
||||
@@ -239,10 +239,7 @@ function ListView({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
selectedColumns={options?.selectColumns}
|
||||
/>
|
||||
<DownloadOptionsMenu dataSource={DataSource.TRACES} />
|
||||
|
||||
<TraceExplorerControls
|
||||
isLoading={isFetching}
|
||||
|
||||
@@ -137,7 +137,7 @@ describe('useIsPanelWaitingOnVariable', () => {
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for DYNAMIC variable with allSelected=true that is loading but has a selectedValue', () => {
|
||||
it('should return true for DYNAMIC variable with allSelected=true that is loading', () => {
|
||||
setFetchStates({ dyn: 'loading' });
|
||||
setDashboardVariables({
|
||||
variables: {
|
||||
@@ -152,10 +152,10 @@ describe('useIsPanelWaitingOnVariable', () => {
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['dyn']));
|
||||
expect(result.current).toBe(false);
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for DYNAMIC variable with allSelected=true that is waiting but has a selectedValue', () => {
|
||||
it('should return true for DYNAMIC variable with allSelected=true that is waiting', () => {
|
||||
setFetchStates({ dyn: 'waiting' });
|
||||
setDashboardVariables({
|
||||
variables: {
|
||||
@@ -170,7 +170,7 @@ describe('useIsPanelWaitingOnVariable', () => {
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['dyn']));
|
||||
expect(result.current).toBe(false);
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for DYNAMIC variable with allSelected=true that is idle', () => {
|
||||
@@ -313,39 +313,4 @@ describe('useIsPanelWaitingOnVariable', () => {
|
||||
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a']));
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should find variable by name when store key differs from variable name', () => {
|
||||
setFetchStates({ myVar: 'loading' });
|
||||
setDashboardVariables({
|
||||
variables: {
|
||||
'uuid-abc-123': makeVariable({
|
||||
id: 'uuid-abc-123',
|
||||
name: 'myVar',
|
||||
selectedValue: undefined,
|
||||
}),
|
||||
},
|
||||
variableTypes: { myVar: 'QUERY' },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['myVar']));
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should respect selectedValue when store key differs from variable name', () => {
|
||||
// When the variable has a value, it should not block even if loading
|
||||
setFetchStates({ myVar: 'loading' });
|
||||
setDashboardVariables({
|
||||
variables: {
|
||||
'uuid-abc-123': makeVariable({
|
||||
id: 'uuid-abc-123',
|
||||
name: 'myVar',
|
||||
selectedValue: 'production',
|
||||
}),
|
||||
},
|
||||
variableTypes: { myVar: 'QUERY' },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['myVar']));
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -226,41 +226,6 @@ describe('useVariablesFromUrl', () => {
|
||||
expect(urlVariables.undefinedVar).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return empty object when URL param contains a bare % that makes decodeURIComponent throw', () => {
|
||||
// Simulate a URL where the variables param is a raw JSON string containing a literal %
|
||||
// (e.g. a metric value like "cpu_usage_50%"). URLSearchParams.get() returns the value
|
||||
// as-is when it was set directly; if it contains a bare %, decodeURIComponent throws a URIError.
|
||||
const rawJson = JSON.stringify({ threshold: '50%' });
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: [`/?${QueryParams.variables}=${rawJson}`],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useVariablesFromUrl(), {
|
||||
wrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
<Router history={history}>{children}</Router>
|
||||
),
|
||||
});
|
||||
|
||||
// Should parse successfully via the raw fallback, not throw or return {}
|
||||
const vars = result.current.getUrlVariables();
|
||||
expect(vars).toEqual({ threshold: '50%' });
|
||||
});
|
||||
|
||||
it('should return empty object when URL param is completely unparseable', () => {
|
||||
// A value that fails both decodeURIComponent and JSON.parse
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: [`/?${QueryParams.variables}=not-json-%ZZ`],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useVariablesFromUrl(), {
|
||||
wrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
<Router history={history}>{children}</Router>
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.current.getUrlVariables()).toEqual({});
|
||||
});
|
||||
|
||||
it('should update variables with array values correctly', () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/'],
|
||||
|
||||
@@ -133,19 +133,21 @@ export function useVariableFetchState(
|
||||
export function useIsPanelWaitingOnVariable(variableNames: string[]): boolean {
|
||||
const states = useVariableFetchSelector((s) => s.states);
|
||||
const dashboardVariables = useDashboardVariablesSelector((s) => s.variables);
|
||||
const variableTypesMap = useDashboardVariablesSelector((s) => s.variableTypes);
|
||||
|
||||
return variableNames.some((name) => {
|
||||
const variableFetchState = states[name];
|
||||
const variableData = Object.values(dashboardVariables).find(
|
||||
(v) => v.name === name,
|
||||
);
|
||||
const { selectedValue } = variableData || {};
|
||||
const { selectedValue, allSelected } = dashboardVariables?.[name] || {};
|
||||
|
||||
const isVariableInFetchingOrWaitingState =
|
||||
variableFetchState === 'loading' ||
|
||||
variableFetchState === 'revalidating' ||
|
||||
variableFetchState === 'waiting';
|
||||
|
||||
if (variableTypesMap[name] === 'DYNAMIC' && allSelected) {
|
||||
return isVariableInFetchingOrWaitingState;
|
||||
}
|
||||
|
||||
return isEmpty(selectedValue) ? isVariableInFetchingOrWaitingState : false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -32,14 +32,7 @@ const useVariablesFromUrl = (): UseVariablesFromUrlReturn => {
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = ((): string => {
|
||||
try {
|
||||
return decodeURIComponent(variablesParam);
|
||||
} catch {
|
||||
return variablesParam;
|
||||
}
|
||||
})();
|
||||
return JSON.parse(decoded);
|
||||
return JSON.parse(decodeURIComponent(variablesParam));
|
||||
} catch (error) {
|
||||
Sentry.captureEvent({
|
||||
message: `Failed to parse dashboard variables from URL: ${error}`,
|
||||
|
||||
@@ -52,44 +52,37 @@ export const useGetQueryRange: UseGetQueryRange = (
|
||||
!firstQueryData?.filters?.items.some((filter) => filter.key?.key === 'id') &&
|
||||
firstQueryData?.orderBy[0].columnName === 'timestamp';
|
||||
|
||||
if (
|
||||
isListWithSingleTimestampOrder &&
|
||||
firstQueryData?.dataSource === DataSource.LOGS
|
||||
) {
|
||||
return {
|
||||
...requestData,
|
||||
graphType:
|
||||
requestData.graphType === PANEL_TYPES.BAR
|
||||
? PANEL_TYPES.TIME_SERIES
|
||||
: requestData.graphType,
|
||||
query: {
|
||||
...requestData.query,
|
||||
builder: {
|
||||
...requestData.query.builder,
|
||||
queryData: [
|
||||
{
|
||||
...firstQueryData,
|
||||
orderBy: [
|
||||
...(firstQueryData?.orderBy || []),
|
||||
{
|
||||
columnName: 'id',
|
||||
order: firstQueryData?.orderBy[0]?.order,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
const modifiedRequestData = {
|
||||
...requestData,
|
||||
graphType:
|
||||
requestData.graphType === PANEL_TYPES.BAR
|
||||
? PANEL_TYPES.TIME_SERIES
|
||||
: requestData.graphType,
|
||||
};
|
||||
|
||||
// If the query is a list with a single timestamp order, we need to add the id column to the order by clause
|
||||
if (
|
||||
isListWithSingleTimestampOrder &&
|
||||
firstQueryData?.dataSource === DataSource.LOGS
|
||||
) {
|
||||
modifiedRequestData.query.builder = {
|
||||
...requestData.query.builder,
|
||||
queryData: [
|
||||
{
|
||||
...firstQueryData,
|
||||
orderBy: [
|
||||
...(firstQueryData?.orderBy || []),
|
||||
{
|
||||
columnName: 'id',
|
||||
order: firstQueryData?.orderBy[0]?.order,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return modifiedRequestData;
|
||||
}, [requestData]);
|
||||
|
||||
const queryKey = useMemo(() => {
|
||||
|
||||
@@ -39,7 +39,6 @@ export function useCopyToClipboard(
|
||||
|
||||
const copyToClipboard = useCallback(
|
||||
(text: string, id?: ID): void => {
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useCallback, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { message } from 'antd';
|
||||
import { downloadExportData } from 'api/v1/download/downloadExportData';
|
||||
import { prepareQueryRangePayloadV5, TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { prepareQueryRangePayloadV5 } from 'api/v5/v5';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { AppState } from 'store/reducers';
|
||||
@@ -14,7 +14,6 @@ interface ExportOptions {
|
||||
format: string;
|
||||
rowLimit: number;
|
||||
clearSelectColumns: boolean;
|
||||
selectedColumns?: TelemetryFieldKey[];
|
||||
}
|
||||
|
||||
interface UseExportRawDataProps {
|
||||
@@ -43,7 +42,6 @@ export function useExportRawData({
|
||||
format,
|
||||
rowLimit,
|
||||
clearSelectColumns,
|
||||
selectedColumns,
|
||||
}: ExportOptions): Promise<void> => {
|
||||
if (!stagedQuery) {
|
||||
return;
|
||||
@@ -52,12 +50,6 @@ export function useExportRawData({
|
||||
try {
|
||||
setIsDownloading(true);
|
||||
|
||||
const selectColumnsOverride = clearSelectColumns
|
||||
? {}
|
||||
: selectedColumns?.length
|
||||
? { selectColumns: selectedColumns }
|
||||
: {};
|
||||
|
||||
const exportQuery = {
|
||||
...stagedQuery,
|
||||
builder: {
|
||||
@@ -67,7 +59,7 @@ export function useExportRawData({
|
||||
groupBy: [],
|
||||
having: { expression: '' },
|
||||
limit: rowLimit,
|
||||
...selectColumnsOverride,
|
||||
...(clearSelectColumns && { selectColumns: [] }),
|
||||
})),
|
||||
queryTraceOperator: (stagedQuery.builder.queryTraceOperator || []).map(
|
||||
(traceOp) => ({
|
||||
@@ -75,7 +67,7 @@ export function useExportRawData({
|
||||
groupBy: [],
|
||||
having: { expression: '' },
|
||||
limit: rowLimit,
|
||||
...selectColumnsOverride,
|
||||
...(clearSelectColumns && { selectColumns: [] }),
|
||||
}),
|
||||
),
|
||||
},
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useMemo } from 'react';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import SeverityCriticalIcon from 'assets/AlertHistory/SeverityCriticalIcon';
|
||||
import SeverityErrorIcon from 'assets/AlertHistory/SeverityErrorIcon';
|
||||
import SeverityInfoIcon from 'assets/AlertHistory/SeverityInfoIcon';
|
||||
@@ -7,50 +5,34 @@ import SeverityWarningIcon from 'assets/AlertHistory/SeverityWarningIcon';
|
||||
|
||||
import './AlertSeverity.styles.scss';
|
||||
|
||||
const severityConfig: Record<string, Record<string, string | JSX.Element>> = {
|
||||
critical: {
|
||||
text: 'Critical',
|
||||
className: 'alert-severity--critical',
|
||||
icon: <SeverityCriticalIcon />,
|
||||
},
|
||||
error: {
|
||||
text: 'Error',
|
||||
className: 'alert-severity--error',
|
||||
icon: <SeverityErrorIcon />,
|
||||
},
|
||||
warning: {
|
||||
text: 'Warning',
|
||||
className: 'alert-severity--warning',
|
||||
icon: <SeverityWarningIcon />,
|
||||
},
|
||||
info: {
|
||||
text: 'Info',
|
||||
className: 'alert-severity--info',
|
||||
icon: <SeverityInfoIcon />,
|
||||
},
|
||||
};
|
||||
|
||||
export default function AlertSeverity({
|
||||
severity,
|
||||
}: {
|
||||
severity: string;
|
||||
}): JSX.Element {
|
||||
const severityDetails = useMemo(() => {
|
||||
if (severityConfig[severity]) {
|
||||
return severityConfig[severity];
|
||||
}
|
||||
|
||||
Sentry.captureEvent({
|
||||
message: `Received unknown severity on Alert Details: ${severity}`,
|
||||
level: 'error',
|
||||
});
|
||||
|
||||
return {
|
||||
text: severity,
|
||||
const severityConfig: Record<string, Record<string, string | JSX.Element>> = {
|
||||
critical: {
|
||||
text: 'Critical',
|
||||
className: 'alert-severity--critical',
|
||||
icon: <SeverityCriticalIcon />,
|
||||
},
|
||||
error: {
|
||||
text: 'Error',
|
||||
className: 'alert-severity--error',
|
||||
icon: <SeverityErrorIcon />,
|
||||
},
|
||||
warning: {
|
||||
text: 'Warning',
|
||||
className: 'alert-severity--warning',
|
||||
icon: <SeverityWarningIcon />,
|
||||
},
|
||||
info: {
|
||||
text: 'Info',
|
||||
className: 'alert-severity--info',
|
||||
icon: <SeverityInfoIcon />,
|
||||
};
|
||||
}, [severity]);
|
||||
},
|
||||
};
|
||||
const severityDetails = severityConfig[severity];
|
||||
return (
|
||||
<div className={`alert-severity ${severityDetails.className}`}>
|
||||
<div className="alert-severity__icon">{severityDetails.icon}</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ import AlertHistory from 'container/AlertHistory';
|
||||
import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants';
|
||||
import { AlertDetailsTab, TimelineFilter } from 'container/AlertHistory/types';
|
||||
import { urlKey } from 'container/AllError/utils';
|
||||
import { RelativeTimeMap } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import useAxiosError from 'hooks/useAxiosError';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
@@ -46,8 +47,6 @@ import { PayloadProps } from 'types/api/alerts/get';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { nanoToMilli } from 'utils/timeUtils';
|
||||
|
||||
const DEFAULT_TIME_RANGE = '30m';
|
||||
|
||||
export const useAlertHistoryQueryParams = (): {
|
||||
ruleId: string | null;
|
||||
startTime: number;
|
||||
@@ -62,8 +61,8 @@ export const useAlertHistoryQueryParams = (): {
|
||||
const relativeTimeParam = params.get(QueryParams.relativeTime);
|
||||
|
||||
const relativeTime =
|
||||
(relativeTimeParam === 'null' ? null : relativeTimeParam) ||
|
||||
DEFAULT_TIME_RANGE;
|
||||
(relativeTimeParam === 'null' ? null : relativeTimeParam) ??
|
||||
RelativeTimeMap['6hr'];
|
||||
|
||||
const intStartTime = parseInt(startTime || '0', 10);
|
||||
const intEndTime = parseInt(endTime || '0', 10);
|
||||
|
||||
@@ -56,8 +56,8 @@ export interface AlertRuleStats {
|
||||
totalPastTriggers: number;
|
||||
currentTriggersSeries: CurrentTriggersSeries;
|
||||
pastTriggersSeries: CurrentTriggersSeries | null;
|
||||
currentAvgResolutionTime: string;
|
||||
pastAvgResolutionTime: string;
|
||||
currentAvgResolutionTime: number;
|
||||
pastAvgResolutionTime: number;
|
||||
currentAvgResolutionTimeSeries: CurrentTriggersSeries;
|
||||
pastAvgResolutionTimeSeries: any | null;
|
||||
}
|
||||
|
||||
@@ -118,7 +118,6 @@ export interface IBaseWidget {
|
||||
opacity: string;
|
||||
nullZeroValues: string;
|
||||
timePreferance: timePreferenceType;
|
||||
stepSize?: number;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption; // number of decimals or 'full precision'
|
||||
stackedBarChart?: boolean;
|
||||
|
||||
@@ -112,12 +112,6 @@ export function formatEpochTimestamp(epoch: number): string {
|
||||
*/
|
||||
|
||||
export function formatTime(seconds: number): string {
|
||||
seconds = +seconds;
|
||||
|
||||
if (Number.isNaN(seconds)) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const days = seconds / 86400;
|
||||
|
||||
if (days >= 1) {
|
||||
|
||||
131
perses/common/common.cue
Normal file
131
perses/common/common.cue
Normal file
@@ -0,0 +1,131 @@
|
||||
package common
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Shared types
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
// ! What is used for - Got answer
|
||||
// Which one of these will be there if this exists
|
||||
#TelemetryFieldKey: {
|
||||
name: string
|
||||
key?: string
|
||||
description?: string
|
||||
unit?: string
|
||||
signal?: string
|
||||
fieldContext?: string
|
||||
fieldDataType?: string
|
||||
materialized?: bool
|
||||
isIndexed?: bool
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Panel types
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
#ContextLinkProps: {
|
||||
url: string
|
||||
label: string
|
||||
}
|
||||
|
||||
// is this used on the frontend or just for query building?
|
||||
#TimePreference: *"globalTime" | "last5Min" | "last15Min" | "last30Min" | "last1Hr" | "last6Hr" | "last1Day" | "last3Days" | "last1Week" | "last1Month"
|
||||
|
||||
// is the default option the one in the front
|
||||
#PrecisionOption: *2 | 0 | 1 | 3 | 4 | "full"
|
||||
|
||||
// how is this undefined and null? Would like to avoid undefined if possible
|
||||
// also bools can required always?
|
||||
#Axes: {
|
||||
softMin?: number | *null
|
||||
softMax?: number | *null
|
||||
isLogScale?: bool | *false
|
||||
}
|
||||
|
||||
#LegendPosition: *"bottom" | "right"
|
||||
|
||||
#ThresholdWithLabel: {
|
||||
value: number
|
||||
unit?: string
|
||||
// is this required? I think it is auto on the frontend and it can be overridden when set
|
||||
color: string
|
||||
// What is this?
|
||||
format: "Text" | "Background"
|
||||
// What would be label if undefined here? There would be some default noe
|
||||
label?: string
|
||||
}
|
||||
|
||||
// where is this used
|
||||
#ComparisonThreshold: {
|
||||
value: number
|
||||
operator: ">" | "<" | ">=" | "<=" | "="
|
||||
unit?: string
|
||||
color: string
|
||||
format: "Text" | "Background"
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Query types
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
#QueryName: =~"^[A-Za-z][A-Za-z0-9_]*$"
|
||||
|
||||
// where used?
|
||||
#Limit: int & >=0 & <=10000
|
||||
|
||||
#Offset: int & >=0
|
||||
|
||||
#ReduceTo: "sum" | "count" | "avg" | "min" | "max" | "last" | "median"
|
||||
|
||||
// what is close?
|
||||
// where is this used?
|
||||
#MetricAggregation: close({
|
||||
metricName: string & !=""
|
||||
timeAggregation: "latest" | "sum" | "avg" | "min" | "max" | "count" | "rate" | "increase"
|
||||
spaceAggregation: "sum" | "avg" | "min" | "max" | "count" | "p50" | "p75" | "p90" | "p95" | "p99"
|
||||
reduceTo?: #ReduceTo
|
||||
// should unspecified be the default?
|
||||
temporality?: "delta" | "cumulative" | "unspecified"
|
||||
})
|
||||
|
||||
#ExpressionAggregation: close({
|
||||
expression: string & !=""
|
||||
alias?: string
|
||||
})
|
||||
|
||||
#Aggregation: #MetricAggregation | #ExpressionAggregation
|
||||
|
||||
// Can this be extended with common struct? this also has - HavingExpression
|
||||
#FilterExpression: close({
|
||||
expression: string
|
||||
})
|
||||
|
||||
#GroupByItem: close({
|
||||
name: string & !=""
|
||||
fieldDataType?: string
|
||||
fieldContext?: string
|
||||
})
|
||||
|
||||
#OrderByItem: close({
|
||||
columnName: string & !=""
|
||||
order: "asc" | "desc"
|
||||
})
|
||||
|
||||
#HavingExpression: close({
|
||||
expression: string
|
||||
})
|
||||
|
||||
// Is the definition used for above?
|
||||
#Function: close({
|
||||
name: "cutOffMin" | "cutOffMax" | "clampMin" | "clampMax" |
|
||||
"absolute" | "runningDiff" | "log2" | "log10" |
|
||||
"cumulativeSum" | "ewma3" | "ewma5" | "ewma7" |
|
||||
"median3" | "median5" | "median7" | "timeShift" |
|
||||
"anomaly" | "fillZero"
|
||||
args?: [...close({value: number | string | bool})]
|
||||
})
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Variable types
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
#VariableSortOrder: *"disabled" | "asc" | "desc"
|
||||
4
perses/cue.mod/module.cue
Normal file
4
perses/cue.mod/module.cue
Normal file
@@ -0,0 +1,4 @@
|
||||
module: "github.com/signoz"
|
||||
language: {
|
||||
version: "v0.12.0"
|
||||
}
|
||||
2124
perses/examples/current.json
Normal file
2124
perses/examples/current.json
Normal file
File diff suppressed because it is too large
Load Diff
926
perses/examples/perses.json
Normal file
926
perses/examples/perses.json
Normal file
@@ -0,0 +1,926 @@
|
||||
{
|
||||
"kind": "Dashboard",
|
||||
"metadata": {
|
||||
"name": "the-everything-dashboard",
|
||||
"project": "signoz" // should this be an id? What is this project exactly?
|
||||
// where would createdAt, updatedAt, createdBy, updatedBy be used?
|
||||
// where would dashboardID or organizationID go
|
||||
// where is locked going to come in
|
||||
// can we have public path here as well? default time range and
|
||||
|
||||
// where would version go?
|
||||
// where would image value go?
|
||||
// where would tags go?
|
||||
|
||||
// we have uploadedGrafana. What is uploadedGrafana used for?
|
||||
|
||||
// ! all above looks like we need a plugin for the root dashboard as well
|
||||
|
||||
// how is panelMap and widgets translated to layout? Is layout the same as before?
|
||||
// where is timePreferance set up
|
||||
// collapsed?
|
||||
// ? https://github.com/react-grid-layout/react-grid-layout?tab=readme-ov-file#layout-item
|
||||
},
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "The everything dashboard", // this is duplicate?
|
||||
"description": "Trying to cover as many concepts here as possible"
|
||||
},
|
||||
"duration": "1h", // is this required?
|
||||
"datasources": {
|
||||
"SigNozDatasource": {
|
||||
"default": true,
|
||||
"plugin": {
|
||||
"kind": "SigNozDatasource",
|
||||
"spec": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"variables": [
|
||||
{
|
||||
// what is a ListVariable
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"name": "serviceName",
|
||||
"display": {
|
||||
"name": "serviceName"
|
||||
},
|
||||
"allowAllValue": true,
|
||||
"allowMultiple": false,
|
||||
"plugin": {
|
||||
"kind": "SigNozDynamicVariable",
|
||||
"spec": {
|
||||
"name": "service.name",
|
||||
"source": "Metrics",
|
||||
"sort": "disabled"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"name": "statusCodesFromQuery",
|
||||
"display": {
|
||||
"name": "statusCodesFromQuery"
|
||||
},
|
||||
"allowAllValue": true,
|
||||
"allowMultiple": true,
|
||||
"plugin": {
|
||||
"kind": "SigNozQueryVariable",
|
||||
"spec": {
|
||||
// missing defaultValue for these list variables
|
||||
"queryValue": "SELECT JSONExtractString(labels, 'http.status_code') AS status_code FROM signoz_metrics.distributed_time_series_v4_1day WHERE status_code != '' GROUP BY status_code",
|
||||
"sort": "asc"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"name": "limit",
|
||||
"display": {
|
||||
"name": "limit"
|
||||
},
|
||||
"allowAllValue": false,
|
||||
"allowMultiple": false,
|
||||
"plugin": {
|
||||
"kind": "SigNozCustomVariable",
|
||||
"spec": {
|
||||
// defaultValue missing
|
||||
"customValue": "1,10,20,40,80,160,200",
|
||||
"sort": "disabled"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
// do we need uuid for id here? Do we need id at all?
|
||||
// migration will require cleanup from localstorage as it is all saved with ids right now
|
||||
"kind": "TextVariable",
|
||||
"spec": {
|
||||
"name": "textboxvar",
|
||||
"display": {
|
||||
"name": "textboxvar"
|
||||
},
|
||||
"value": "defaultvaluegoeshere",
|
||||
"plugin": {
|
||||
// just to confirm, this would be the type right?
|
||||
"kind": "SigNozTextboxVariable",
|
||||
"spec": {
|
||||
// How should selectedValue be handled on share?
|
||||
// we should have an option for selectedValue here
|
||||
// should go in for all variables
|
||||
// should be auto filled with the default or with the queried value. All for multi, first for single
|
||||
// if selectedValue goes in, then we will need allSelected as well
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"panels": {
|
||||
// need metadata inside this for section creation
|
||||
// maybe another key just for sections. much easier
|
||||
"24e2697b": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "total resp size",
|
||||
"description": ""
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "SigNozTimeSeriesPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"fillSpans": true
|
||||
},
|
||||
"formatting": {
|
||||
"unit": "By",
|
||||
"decimalPrecision": 3
|
||||
},
|
||||
"axes": {
|
||||
"softMax": 800,
|
||||
"isLogScale": true
|
||||
},
|
||||
"legend": {
|
||||
"position": "right",
|
||||
"customColors": {
|
||||
"{service.name=\"sampleapp-gateway\"}": "#9ea5f7"
|
||||
}
|
||||
},
|
||||
"contextLinks": [
|
||||
{
|
||||
"label": "View service details",
|
||||
"url": "http://localhost:8080/{{_service.name}}?dfddf=%7B%7Blimit%7D%7D"
|
||||
}
|
||||
],
|
||||
"thresholds": [
|
||||
{
|
||||
"value": 1024,
|
||||
"unit": "By",
|
||||
"color": "Red",
|
||||
"format": "Text",
|
||||
"label": "upper limit"
|
||||
},
|
||||
{
|
||||
"value": 100,
|
||||
"unit": "By",
|
||||
"color": "Orange",
|
||||
"format": "Text",
|
||||
"label": "kinda bad"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozBuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"expression": "A",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "http.server.response.body.size.sum",
|
||||
"reduceTo": "sum",
|
||||
"spaceAggregation": "sum",
|
||||
"timeAggregation": "rate"
|
||||
}
|
||||
],
|
||||
"filter": {
|
||||
"expression": "http.response.status_code IN $statusCodesFromQuery"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "service.name",
|
||||
"fieldDataType": "string",
|
||||
"fieldContext": "tag"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"ff2f72f1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "fraction of calls",
|
||||
"description": ""
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "SigNozTimeSeriesPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"fillSpans": true
|
||||
},
|
||||
"formatting": {
|
||||
"decimalPrecision": 1
|
||||
},
|
||||
"thresholds": [
|
||||
{
|
||||
"value": 1,
|
||||
"color": "Blue",
|
||||
"format": "Background",
|
||||
"label": "max possible"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozCompositeQuery",
|
||||
"spec": {
|
||||
"queries": [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"expression": "A",
|
||||
"disabled": true,
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "signoz_calls_total",
|
||||
"reduceTo": "sum",
|
||||
"spaceAggregation": "sum",
|
||||
"timeAggregation": "rate"
|
||||
}
|
||||
],
|
||||
"filter": {
|
||||
"expression": "service.name IN $serviceName AND http.status_code IN $statusCodesFromQuery"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "B",
|
||||
"signal": "metrics",
|
||||
"expression": "B",
|
||||
"disabled": true,
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "signoz_calls_total",
|
||||
"reduceTo": "sum",
|
||||
"spaceAggregation": "sum",
|
||||
"timeAggregation": "rate"
|
||||
}
|
||||
],
|
||||
"filter": {
|
||||
"expression": "service.name in $serviceName"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "builder_formula",
|
||||
"spec": {
|
||||
"name": "F1",
|
||||
"expression": "A / B"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"011605e7": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "total resp size"
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "SigNozBarChartPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"stackedBarChart": false
|
||||
},
|
||||
"formatting": {
|
||||
"unit": "By"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozBuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"expression": "A",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "http.server.response.body.size.sum",
|
||||
"reduceTo": "sum",
|
||||
"spaceAggregation": "sum",
|
||||
"timeAggregation": "rate"
|
||||
}
|
||||
],
|
||||
"filter": {
|
||||
"expression": "http.response.status_code IN $statusCodesFromQuery"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "service.name",
|
||||
"fieldDataType": "string",
|
||||
"fieldContext": "tag"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"e23516fc": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "num traces for service"
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "SigNozNumberPanel",
|
||||
"spec": {
|
||||
"formatting": {
|
||||
"unit": "none",
|
||||
"decimalPrecision": 1
|
||||
},
|
||||
"thresholds": [
|
||||
{
|
||||
"value": 1200000,
|
||||
"operator": ">",
|
||||
"unit": "none",
|
||||
"color": "Red",
|
||||
"format": "Text"
|
||||
},
|
||||
{
|
||||
"value": 1200000,
|
||||
"operator": "<=",
|
||||
"unit": "none",
|
||||
"color": "Green",
|
||||
"format": "Text"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozBuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"expression": "A",
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count() "
|
||||
}
|
||||
],
|
||||
"filter": {
|
||||
"expression": "service.name = $serviceName "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"130c8d6b": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "num logs for service"
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "SigNozNumberPanel",
|
||||
"spec": {
|
||||
"formatting": {
|
||||
"unit": "none",
|
||||
"decimalPrecision": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozBuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"expression": "A",
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count() "
|
||||
}
|
||||
],
|
||||
"filter": {
|
||||
"expression": "service.name = $serviceName "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"246f7c6d": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "num traces for service per resp code"
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "SigNozPieChartPanel",
|
||||
"spec": {
|
||||
"formatting": {
|
||||
"decimalPrecision": 1
|
||||
},
|
||||
"legend": {
|
||||
"customColors": {
|
||||
"\"201\"": "#2bc051",
|
||||
"\"400\"": "#cc462e",
|
||||
"\"500\"": "#ff0000"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozBuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"expression": "A",
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count() "
|
||||
}
|
||||
],
|
||||
"filter": {
|
||||
"expression": "service.name = $serviceName isEntryPoint = 'true'"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "http.response.status_code",
|
||||
"fieldDataType": "float64",
|
||||
"fieldContext": "tag"
|
||||
}
|
||||
],
|
||||
"legend": "\"{{http.response.status_code}}\""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"21f7d4d0": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "average latency per service"
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "SigNozTablePanel",
|
||||
"spec": {
|
||||
"formatting": {
|
||||
"columnUnits": {
|
||||
"A": "s"
|
||||
}
|
||||
},
|
||||
"thresholds": [
|
||||
{
|
||||
"value": 1,
|
||||
"operator": ">",
|
||||
"unit": "min",
|
||||
"color": "Red",
|
||||
"format": "Text",
|
||||
"tableOptions": "A"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozClickHouseSQL",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"query": "WITH\n __spatial_aggregation_cte AS\n (\n SELECT\n toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts,\n `service.name`,\n le,\n sum(value) / 60 AS value\n FROM signoz_metrics.distributed_samples_v4 AS points\n INNER JOIN\n (\n SELECT\n fingerprint,\n JSONExtractString(labels, 'service.name') AS `service.name`,\n JSONExtractString(labels, 'le') AS le\n FROM signoz_metrics.time_series_v4\n WHERE (metric_name IN ('signoz_latency.bucket')) AND (LOWER(temporality) LIKE LOWER('delta')) AND (__normalized = 0)\n GROUP BY\n fingerprint,\n `service.name`,\n le\n ) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint\n WHERE metric_name IN ('signoz_latency.bucket')\n GROUP BY\n ts,\n `service.name`,\n le\n ),\n __histogramCTE AS\n (\n SELECT\n ts,\n `service.name`,\n histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.9) AS value\n FROM __spatial_aggregation_cte\n GROUP BY\n `service.name`,\n ts\n ORDER BY\n `service.name` ASC,\n ts ASC\n )\nSELECT\n `service.name` AS service,\n avg(value) AS A\nFROM __histogramCTE\nGROUP BY `service.name`"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"ad5fd556": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "logs from service"
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "SigNozListPanel",
|
||||
"spec": {
|
||||
"selectedLogFields": [
|
||||
{
|
||||
"name": "timestamp",
|
||||
"type": "log",
|
||||
"dataType": ""
|
||||
},
|
||||
{
|
||||
"name": "body",
|
||||
"type": "log",
|
||||
"dataType": ""
|
||||
},
|
||||
{
|
||||
"name": "error",
|
||||
"type": "",
|
||||
"dataType": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "LogQuery",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozBuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"expression": "A",
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count() "
|
||||
}
|
||||
],
|
||||
"filter": {
|
||||
"expression": "service.name = $serviceName"
|
||||
},
|
||||
"groupBy": [],
|
||||
"order": [
|
||||
{
|
||||
"columnName": "timestamp",
|
||||
"order": "desc"
|
||||
},
|
||||
{
|
||||
"columnName": "id",
|
||||
"order": "desc"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"f07b59ee": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "response size buckets"
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "SigNozHistogramPanel",
|
||||
"spec": {
|
||||
"histogramBuckets": {
|
||||
"bucketCount": 60,
|
||||
"mergeAllActiveQueries": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozBuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"expression": "A",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "http.server.response.body.size.bucket",
|
||||
"reduceTo": "avg",
|
||||
"spaceAggregation": "p90",
|
||||
"timeAggregation": "rate"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"e1a41831": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "trace operator",
|
||||
"description": ""
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "SigNozTimeSeriesPanel",
|
||||
"spec": {
|
||||
"legend": {
|
||||
"position": "right"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozCompositeQuery",
|
||||
"spec": {
|
||||
"queries": [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"expression": "A",
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count() "
|
||||
}
|
||||
],
|
||||
"filter": {
|
||||
"expression": "service.name = 'sampleapp-gateway' "
|
||||
},
|
||||
"legend": "Gateway"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "B",
|
||||
"signal": "traces",
|
||||
"expression": "B",
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count() "
|
||||
}
|
||||
],
|
||||
"filter": {
|
||||
"expression": "http.response.status_code = 200"
|
||||
},
|
||||
"legend": "$serviceName"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "builder_trace_operator",
|
||||
"spec": {
|
||||
"name": "T1",
|
||||
"expression": "A -> B ",
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count()",
|
||||
"alias": "request_count"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"f0d70491": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "no results in this promql",
|
||||
"description": ""
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "SigNozTimeSeriesPanel",
|
||||
"spec": {}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozCompositeQuery",
|
||||
"spec": {
|
||||
"queries": [
|
||||
{
|
||||
"type": "promql",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"query": "sum(rate(flask_exporter_info[5m]))"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "promql",
|
||||
"spec": {
|
||||
"name": "B",
|
||||
"query": "sum(increase(flask_exporter_info[5m]))"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"0e6eb4ca": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "no results in this promql",
|
||||
"description": ""
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "SigNozTimeSeriesPanel",
|
||||
"spec": {}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozPromQLQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"query": "sum(rate(flask_exporter_info[5m]))"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"layouts": [
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
// ! where is row header?
|
||||
// w: 12,
|
||||
// minW: 12,
|
||||
// minH: 1,
|
||||
// maxH: 1,
|
||||
// x: 0,
|
||||
// h: 1,
|
||||
// y: 0,
|
||||
// These above properties are required for the headers only
|
||||
// need collapsed state for a section
|
||||
"items": [
|
||||
{
|
||||
// we need id here. could be injected from the frontend
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
// how easy would it be to introduce more here? like maxW, minW, static, etc. Do we want to introduce those from the start?
|
||||
// "moved" might need experimenting
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/24e2697b"
|
||||
}
|
||||
},
|
||||
{
|
||||
"x": 6,
|
||||
"y": 0,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/ff2f72f1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 6,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/011605e7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"x": 6,
|
||||
"y": 6,
|
||||
"width": 6,
|
||||
"height": 3,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/e23516fc"
|
||||
}
|
||||
},
|
||||
{
|
||||
"x": 6,
|
||||
"y": 9,
|
||||
"width": 6,
|
||||
"height": 3,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/130c8d6b"
|
||||
}
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 12,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/246f7c6d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"x": 6,
|
||||
"y": 12,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/21f7d4d0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 18,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/ad5fd556"
|
||||
}
|
||||
},
|
||||
{
|
||||
"x": 6,
|
||||
"y": 18,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/f07b59ee"
|
||||
}
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 24,
|
||||
"width": 12,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/e1a41831"
|
||||
}
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 30,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/f0d70491"
|
||||
}
|
||||
},
|
||||
{
|
||||
"x": 6,
|
||||
"y": 30,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/0e6eb4ca"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
140
perses/howToDefineAPanel.md
Normal file
140
perses/howToDefineAPanel.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# Adding a new panel plugin
|
||||
|
||||
This guide is for developers adding a new panel kind to the Perses schema. It covers the file structure you need to create, the shared types available in `common.cue`, and how to compose them into your panel's spec.
|
||||
|
||||
---
|
||||
|
||||
## 1. File structure
|
||||
|
||||
Create a new directory under `schemas-wrapper/schemas/` named after your panel:
|
||||
|
||||
```
|
||||
schemas-wrapper/schemas/your-panel-name/
|
||||
├── your-panel-name.cue # Schema definition
|
||||
└── example.json # Example JSON that validates against the schema
|
||||
```
|
||||
|
||||
The CUE file must use `package model` and declare a `kind` and `spec`:
|
||||
|
||||
```cue
|
||||
package model
|
||||
|
||||
import "github.com/signoz/common"
|
||||
|
||||
kind: "YourPanelName"
|
||||
spec: close({
|
||||
// your fields here
|
||||
})
|
||||
```
|
||||
|
||||
Register the panel in `schemas-wrapper/schemas/package.json` by adding an entry to the `plugins` array:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"name": "YourPanelName"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Available shared types from `common.cue`
|
||||
|
||||
These types are defined in `common/common.cue` and can be used via `import "github.com/signoz/common"`. Use them instead of redefining the same structures in your panel.
|
||||
|
||||
## 3. Defining your spec
|
||||
|
||||
Your spec is a `close({})` block containing the fields relevant to your panel. Pick from the shared types above and add panel-specific types as needed. Every field should be optional (suffixed with `?`) since a panel should be valid with just defaults.
|
||||
|
||||
### Typical patterns
|
||||
|
||||
**Panel with graphs** (has axes, legend, thresholds as reference lines):
|
||||
|
||||
```cue
|
||||
spec: close({
|
||||
visualization?: #Visualization
|
||||
formatting?: #Formatting
|
||||
axes?: common.#Axes
|
||||
legend?: #Legend
|
||||
contextLinks?: [...common.#ContextLinkProps]
|
||||
thresholds?: [...common.#ThresholdWithLabel]
|
||||
})
|
||||
```
|
||||
|
||||
**Panel with a single value** (no axes/legend, thresholds as conditional formatting):
|
||||
|
||||
```cue
|
||||
spec: close({
|
||||
visualization?: #Visualization
|
||||
formatting?: #Formatting
|
||||
contextLinks?: [...common.#ContextLinkProps]
|
||||
thresholds?: [...common.#ComparisonThreshold]
|
||||
})
|
||||
```
|
||||
|
||||
**Panel with custom data structure** (panel-specific fields only):
|
||||
|
||||
```cue
|
||||
spec: close({
|
||||
yourCustomConfig?: #YourCustomConfig
|
||||
contextLinks?: [...common.#ContextLinkProps]
|
||||
})
|
||||
```
|
||||
|
||||
### Defining panel-local types
|
||||
|
||||
Types that are specific to your panel (not reusable) should be defined in your CUE file, not in common. For example, `#Visualization` varies per panel because each panel has different rendering options:
|
||||
|
||||
```cue
|
||||
#Visualization: {
|
||||
timePreference?: common.#TimePreference
|
||||
yourCustomFlag?: bool | *false
|
||||
}
|
||||
```
|
||||
|
||||
If your panel needs a threshold type that extends a common one, embed it:
|
||||
|
||||
```cue
|
||||
#YourThreshold: {
|
||||
common.#ComparisonThreshold
|
||||
extraField: string
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Writing the example JSON
|
||||
|
||||
Create an `example.json` that exercises all fields in your spec. This file is used for validation — `./validate.sh` will check it against your schema.
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "YourPanelName",
|
||||
"spec": {
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Also add at least one panel using your new kind to `examples/perses.json` so it gets validated as part of a full dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 5. Validation
|
||||
|
||||
Run `./validate.sh` from the `perses/` directory. It lints `examples/perses.json` against all registered schemas. If your schema or example has issues, the error will point to the specific field.
|
||||
|
||||
---
|
||||
|
||||
## Quick reference: existing panels and what they use
|
||||
|
||||
| Field | Time-series | Bar-chart | Number | Pie | Table | Histogram | List |
|
||||
|-------|:-----------:|:---------:|:------:|:---:|:-----:|:---------:|:----:|
|
||||
| `visualization` | yes | yes | yes | yes | yes | — | — |
|
||||
| `formatting` | yes | yes | yes | yes | yes | — | — |
|
||||
| `axes` | yes | yes | — | — | — | — | — |
|
||||
| `legend` | yes | yes | — | yes | — | yes | — |
|
||||
| `contextLinks` | yes | yes | yes | yes | yes | yes | — |
|
||||
| `thresholds` | yes | yes | yes | — | yes | — | — |
|
||||
9
perses/schemas-wrapper/schemas/mf-manifest.json
Normal file
9
perses/schemas-wrapper/schemas/mf-manifest.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "signoz",
|
||||
"name": "signoz",
|
||||
"metaData": {
|
||||
"buildInfo": {
|
||||
"buildVersion": "0.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package model
|
||||
|
||||
kind: "SigNozDatasource"
|
||||
|
||||
// SigNoz has a single built-in backend — the frontend already knows
|
||||
// the API endpoint, so there is no connection config to validate.
|
||||
// Add fields here if SigNoz ever supports multiple backends or
|
||||
// configurable API versions.
|
||||
spec: close({})
|
||||
|
||||
// this is required to override the default that always requires a spec
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"kind": "SigNozDatasource",
|
||||
"spec": {}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package model
|
||||
|
||||
// Fields from IBaseWidget that are NOT in any dedicated panel CUE file.
|
||||
// This file exists as a reference only and will be removed in the end.
|
||||
// Needs to be removed from package.json as well.
|
||||
|
||||
kind: "SigNozIgnoredFieldsChart"
|
||||
spec: close({
|
||||
opacity?: string // not wired to chart rendering
|
||||
nullZeroValues?: string // not wired to chart rendering
|
||||
stepSize?: number
|
||||
columnWidths?: [string]: number // not a config choice — persisted user-resized column widths
|
||||
// "Chart Appearance" section in UI — could not find this section in the app.
|
||||
// This is behind a boolean flag right now. Can help reproduce locally
|
||||
// These 4 fields are gated behind panelTypeVs* constants (TIME_SERIES only).
|
||||
lineInterpolation?: #LineInterpolation
|
||||
showPoints?: bool
|
||||
lineStyle?: #LineStyle
|
||||
fillMode?: #FillMode
|
||||
})
|
||||
|
||||
#LineInterpolation: "linear" | "spline" | "stepAfter" | "stepBefore"
|
||||
|
||||
#LineStyle: "solid" | "dashed"
|
||||
|
||||
#FillMode: "solid" | "gradient" | "none"
|
||||
@@ -0,0 +1,17 @@
|
||||
package model
|
||||
|
||||
import "github.com/signoz/common"
|
||||
|
||||
// Source: pkg/types/querybuildertypes/querybuildertypesv5/formula.go — QueryBuilderFormula
|
||||
kind: "SigNozFormula"
|
||||
spec: close({
|
||||
name: common.#QueryName
|
||||
expression: string
|
||||
disabled?: bool | *false
|
||||
legend?: string
|
||||
limit?: common.#Limit
|
||||
having?: common.#HavingExpression
|
||||
stepInterval?: number
|
||||
order?: [...common.#OrderByItem]
|
||||
functions?: [...common.#Function]
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"kind": "SigNozFormula",
|
||||
"spec": {
|
||||
"name": "F1",
|
||||
"expression": "A / B * 100",
|
||||
"legend": "Hit rate %"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package model
|
||||
|
||||
import "github.com/signoz/common"
|
||||
|
||||
// Source: pkg/types/querybuildertypes/querybuildertypesv5/trace_operator.go — QueryBuilderTraceOperator
|
||||
// SigNozTraceOperator composes multiple trace BuilderQueries using
|
||||
// relational operators (=>, ->, &&, ||, NOT) to query trace relationships.
|
||||
// Signal is implicitly "traces" — all referenced queries must be trace queries.
|
||||
kind: "SigNozTraceOperator"
|
||||
spec: close({
|
||||
name: common.#QueryName
|
||||
// Operator expression composing trace queries, e.g. "A => B && C".
|
||||
expression: string & !=""
|
||||
disabled?: bool | *false
|
||||
|
||||
// Which query's spans to return (must be a query referenced in expression).
|
||||
returnSpansFrom?: common.#QueryName
|
||||
|
||||
aggregations?: [...common.#ExpressionAggregation]
|
||||
filter?: common.#FilterExpression
|
||||
groupBy?: [...common.#GroupByItem]
|
||||
order?: [...common.#OrderByItem]
|
||||
limit?: common.#Limit
|
||||
offset?: common.#Offset
|
||||
cursor?: string
|
||||
functions?: [...common.#Function]
|
||||
stepInterval?: number
|
||||
having?: common.#HavingExpression
|
||||
legend?: string
|
||||
selectFields?: [...common.#TelemetryFieldKey]
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"kind": "SigNozTraceOperator",
|
||||
"spec": {
|
||||
"name": "T1",
|
||||
"expression": "A => B",
|
||||
"returnSpansFrom": "A",
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count()",
|
||||
"alias": "request_count"
|
||||
}
|
||||
],
|
||||
"filter": {
|
||||
"expression": "service.name = 'frontend'"
|
||||
},
|
||||
"groupBy": [],
|
||||
"order": []
|
||||
}
|
||||
}
|
||||
136
perses/schemas-wrapper/schemas/package.json
Normal file
136
perses/schemas-wrapper/schemas/package.json
Normal file
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"name": "@signoz/signoz-schemas",
|
||||
"version": "0.0.1",
|
||||
"description": "SigNoz schema plugins",
|
||||
"perses": {
|
||||
"schemasPath": ".",
|
||||
"plugins": [
|
||||
{
|
||||
"kind": "Datasource",
|
||||
"spec": {
|
||||
"name": "SigNozDatasource"
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"name": "SigNozTimeSeriesPanel"
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"name": "SigNozBarChartPanel"
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"name": "SigNozNumberPanel"
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"name": "SigNozPieChartPanel"
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"name": "SigNozTablePanel"
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"name": "SigNozHistogramPanel"
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"name": "SigNozListPanel"
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"name": "SigNozIgnoredFieldsChart"
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"name": "SigNozBuilderQuery"
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"name": "SigNozClickHouseSQL"
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"name": "SigNozPromQLQuery"
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"name": "SigNozCompositeQuery"
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"name": "SigNozTraceOperator"
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"name": "SigNozFormula"
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "LogQuery",
|
||||
"spec": {
|
||||
"name": "SigNozBuilderQuery"
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "TraceQuery",
|
||||
"spec": {
|
||||
"name": "SigNozBuilderQuery"
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "Variable",
|
||||
"spec": {
|
||||
"name": "SigNozCustomVariable"
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "Variable",
|
||||
"spec": {
|
||||
"name": "SigNozDynamicVariable"
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "Variable",
|
||||
"spec": {
|
||||
"name": "SigNozQueryVariable"
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "Variable",
|
||||
"spec": {
|
||||
"name": "SigNozTextboxVariable"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
perses/schemas-wrapper/schemas/panels/Image.jpeg
Normal file
BIN
perses/schemas-wrapper/schemas/panels/Image.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 711 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 149 KiB |
20
perses/schemas-wrapper/schemas/panels/review.md
Normal file
20
perses/schemas-wrapper/schemas/panels/review.md
Normal file
@@ -0,0 +1,20 @@
|
||||
which panel type only caters to specific signals?
|
||||
metrics has no option for list panel type. also, no other querying types
|
||||
|
||||
should the panel definition be linked to the query type?
|
||||
|
||||
| type | QB | P | CH |
|
||||
| ----------- | --- | --- | --- |
|
||||
| Time Series | ✅ | ✅ | ✅ |
|
||||
| Number | ✅ | ✅ | ✅ |
|
||||
| Table | ✅ | ❌ | ✅ |
|
||||
| List | ✅ | ❌ | ❌ |
|
||||
| Bar | ✅ | ✅ | ✅ |
|
||||
| Pie | ✅ | ❌ | ✅ |
|
||||
| Histogram | ✅ | ✅ | ✅ |
|
||||
|
||||
- unsure where this is used
|
||||
stepSize - can be removed
|
||||
renderColumnCell - used though - only table
|
||||
customColumTitles - used though - only table
|
||||
hiddenColumns - used though - only table
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"kind": "SigNozBarChartPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"timePreference": "globalTime",
|
||||
"fillSpans": false,
|
||||
"stackedBarChart": false
|
||||
},
|
||||
"formatting": {
|
||||
"unit": "By",
|
||||
"decimalPrecision": 2
|
||||
},
|
||||
"axes": {
|
||||
"softMin": 0,
|
||||
"softMax": 1000,
|
||||
"isLogScale": false
|
||||
},
|
||||
"legend": {
|
||||
"position": "bottom",
|
||||
"customColors": {
|
||||
"series-A": "#9ea5f7"
|
||||
}
|
||||
},
|
||||
"contextLinks": [
|
||||
{
|
||||
"label": "View details",
|
||||
"url": "http://localhost:8080/{{_service.name}}"
|
||||
}
|
||||
],
|
||||
"thresholds": [
|
||||
{
|
||||
"value": 500,
|
||||
"unit": "By",
|
||||
"color": "Red",
|
||||
"format": "Text",
|
||||
"label": "upper limit"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package model
|
||||
|
||||
import "github.com/signoz/common"
|
||||
|
||||
kind: "SigNozBarChartPanel"
|
||||
spec: close({
|
||||
// need to put more thought into this being optional?
|
||||
visualization?: #Visualization
|
||||
formatting?: #Formatting
|
||||
axes?: common.#Axes
|
||||
legend?: #Legend
|
||||
contextLinks?: [...common.#ContextLinkProps]
|
||||
thresholds?: [...common.#ThresholdWithLabel]
|
||||
})
|
||||
|
||||
#Visualization: {
|
||||
timePreference?: common.#TimePreference
|
||||
fillSpans?: bool | *false
|
||||
stackedBarChart?: bool | *true
|
||||
}
|
||||
|
||||
#Formatting: {
|
||||
unit?: string | *""
|
||||
decimalPrecision?: common.#PrecisionOption
|
||||
}
|
||||
|
||||
// can this be more composable?
|
||||
#Legend: {
|
||||
position?: common.#LegendPosition
|
||||
customColors?: [string]: string
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"kind": "SigNozHistogramPanel",
|
||||
"spec": {
|
||||
"histogramBuckets": {
|
||||
"bucketCount": 60,
|
||||
"bucketWidth": 100,
|
||||
"mergeAllActiveQueries": true
|
||||
},
|
||||
"legend": {
|
||||
"customColors": {
|
||||
"series-A": "#9ea5f7"
|
||||
}
|
||||
},
|
||||
"contextLinks": [
|
||||
{
|
||||
"label": "View histogram details",
|
||||
"url": "http://localhost:8080/histogram"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package model
|
||||
|
||||
import "github.com/signoz/common"
|
||||
|
||||
kind: "SigNozHistogramPanel"
|
||||
spec: close({
|
||||
histogramBuckets?: #HistogramBuckets
|
||||
legend?: #Legend
|
||||
contextLinks?: [...common.#ContextLinkProps]
|
||||
})
|
||||
|
||||
#HistogramBuckets: {
|
||||
bucketCount?: number | *30
|
||||
bucketWidth?: number | *0
|
||||
mergeAllActiveQueries?: bool | *false
|
||||
}
|
||||
|
||||
#Legend: {
|
||||
customColors?: [string]: string
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"kind": "SigNozListPanel",
|
||||
"spec": {
|
||||
"selectedLogFields": [
|
||||
{
|
||||
"name": "timestamp",
|
||||
"type": "log",
|
||||
"dataType": ""
|
||||
},
|
||||
{
|
||||
"name": "body",
|
||||
"type": "log",
|
||||
"dataType": ""
|
||||
},
|
||||
{
|
||||
"name": "error",
|
||||
"type": "",
|
||||
"dataType": "string"
|
||||
}
|
||||
],
|
||||
"selectedTracesFields": [
|
||||
{
|
||||
"name": "service.name",
|
||||
"signal": "traces",
|
||||
"fieldContext": "resource",
|
||||
"fieldDataType": "string"
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"signal": "traces",
|
||||
"fieldContext": "span",
|
||||
"fieldDataType": "string"
|
||||
},
|
||||
{
|
||||
"name": "duration_nano",
|
||||
"signal": "traces",
|
||||
"fieldContext": "span",
|
||||
"fieldDataType": ""
|
||||
},
|
||||
{
|
||||
"name": "http_method",
|
||||
"signal": "traces",
|
||||
"fieldContext": "span",
|
||||
"fieldDataType": ""
|
||||
},
|
||||
{
|
||||
"name": "response_status_code",
|
||||
"signal": "traces",
|
||||
"fieldContext": "span",
|
||||
"fieldDataType": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package model
|
||||
|
||||
import "github.com/signoz/common"
|
||||
|
||||
kind: "SigNozListPanel"
|
||||
spec: close({
|
||||
selectedLogFields?: [...#LogField]
|
||||
selectedTracesFields?: [...common.#TelemetryFieldKey]
|
||||
})
|
||||
|
||||
#LogField: {
|
||||
name: string
|
||||
type: string
|
||||
dataType: string
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"kind": "SigNozNumberPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"timePreference": "globalTime"
|
||||
},
|
||||
"formatting": {
|
||||
"unit": "none",
|
||||
"decimalPrecision": 1
|
||||
},
|
||||
"contextLinks": [
|
||||
{
|
||||
"label": "View details",
|
||||
"url": "http://localhost:8080/details"
|
||||
}
|
||||
],
|
||||
"thresholds": [
|
||||
{
|
||||
"value": 1200000,
|
||||
"operator": ">",
|
||||
"unit": "none",
|
||||
"color": "Red",
|
||||
"format": "Text"
|
||||
},
|
||||
{
|
||||
"value": 1200000,
|
||||
"operator": "<=",
|
||||
"unit": "none",
|
||||
"color": "Green",
|
||||
"format": "Text"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package model
|
||||
|
||||
import "github.com/signoz/common"
|
||||
|
||||
kind: "SigNozNumberPanel"
|
||||
spec: close({
|
||||
visualization?: #Visualization
|
||||
formatting?: #Formatting
|
||||
contextLinks?: [...common.#ContextLinkProps]
|
||||
thresholds?: [...common.#ComparisonThreshold]
|
||||
})
|
||||
|
||||
#Visualization: {
|
||||
timePreference?: common.#TimePreference
|
||||
}
|
||||
|
||||
// where to store if alerts can be added or not?
|
||||
|
||||
#Formatting: {
|
||||
// mainly need to discuss optional fields
|
||||
unit?: string | *""
|
||||
decimalPrecision?: common.#PrecisionOption
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"kind": "SigNozPieChartPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"timePreference": "globalTime"
|
||||
},
|
||||
"formatting": {
|
||||
"unit": "none",
|
||||
"decimalPrecision": 1
|
||||
},
|
||||
"legend": {
|
||||
"customColors": {
|
||||
"\"201\"": "#2bc051",
|
||||
"\"400\"": "#cc462e",
|
||||
"\"500\"": "#ff0000"
|
||||
}
|
||||
},
|
||||
"contextLinks": [
|
||||
{
|
||||
"label": "View breakdown",
|
||||
"url": "http://localhost:8080/breakdown"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package model
|
||||
|
||||
import "github.com/signoz/common"
|
||||
|
||||
kind: "SigNozPieChartPanel"
|
||||
spec: close({
|
||||
visualization?: #Visualization
|
||||
formatting?: #Formatting
|
||||
legend?: #Legend
|
||||
contextLinks?: [...common.#ContextLinkProps]
|
||||
})
|
||||
|
||||
#Visualization: {
|
||||
timePreference?: common.#TimePreference
|
||||
}
|
||||
|
||||
#Formatting: {
|
||||
unit?: string | *""
|
||||
decimalPrecision?: common.#PrecisionOption
|
||||
}
|
||||
|
||||
#Legend: {
|
||||
customColors?: [string]: string
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"kind": "SigNozTablePanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"timePreference": "globalTime"
|
||||
},
|
||||
"formatting": {
|
||||
"columnUnits": {
|
||||
"A": "s"
|
||||
},
|
||||
"decimalPrecision": 2
|
||||
},
|
||||
"contextLinks": [
|
||||
{
|
||||
"label": "View trace",
|
||||
"url": "http://localhost:8080/trace/{{traceID}}"
|
||||
}
|
||||
],
|
||||
"thresholds": [
|
||||
{
|
||||
"value": 1,
|
||||
"operator": ">",
|
||||
"unit": "min",
|
||||
"color": "Red",
|
||||
"format": "Text",
|
||||
"tableOptions": "A"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package model
|
||||
|
||||
import "github.com/signoz/common"
|
||||
|
||||
kind: "SigNozTablePanel"
|
||||
spec: close({
|
||||
visualization?: #Visualization
|
||||
formatting?: #Formatting
|
||||
contextLinks?: [...common.#ContextLinkProps]
|
||||
thresholds?: [...#TableThreshold]
|
||||
})
|
||||
|
||||
#Visualization: {
|
||||
timePreference?: common.#TimePreference
|
||||
}
|
||||
|
||||
#Formatting: {
|
||||
columnUnits?: [string]: string
|
||||
decimalPrecision?: common.#PrecisionOption
|
||||
}
|
||||
|
||||
#TableThreshold: {
|
||||
common.#ComparisonThreshold
|
||||
tableOptions: string
|
||||
}
|
||||
|
||||
// missing
|
||||
columnWidths
|
||||
customColumnTitles
|
||||
hiddenColumns
|
||||
renderColumnCell - can be FE
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"kind": "SigNozTimeSeriesPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"timePreference": "globalTime",
|
||||
"fillSpans": true
|
||||
},
|
||||
"formatting": {
|
||||
"unit": "By",
|
||||
"decimalPrecision": 3
|
||||
},
|
||||
"axes": {
|
||||
"softMin": 0,
|
||||
"softMax": 800,
|
||||
"isLogScale": true
|
||||
},
|
||||
"legend": {
|
||||
"position": "right",
|
||||
"customColors": {
|
||||
"{service.name=\"sampleapp-gateway\"}": "#9ea5f7"
|
||||
}
|
||||
},
|
||||
"contextLinks": [
|
||||
{
|
||||
"label": "View service details",
|
||||
"url": "http://localhost:8080/{{_service.name}}?dfddf=%7B%7Blimit%7D%7D"
|
||||
}
|
||||
],
|
||||
"thresholds": [
|
||||
{
|
||||
"value": 1024,
|
||||
"unit": "By",
|
||||
"color": "Red",
|
||||
"format": "Text",
|
||||
"label": "upper limit"
|
||||
},
|
||||
{
|
||||
"value": 100,
|
||||
"unit": "By",
|
||||
"color": "Orange",
|
||||
"format": "Text",
|
||||
"label": "kinda bad"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package model
|
||||
|
||||
import "github.com/signoz/common"
|
||||
|
||||
kind: "SigNozTimeSeriesPanel"
|
||||
spec: close({
|
||||
visualization?: #Visualization
|
||||
formatting?: #Formatting
|
||||
axes?: common.#Axes
|
||||
legend?: #Legend
|
||||
contextLinks?: [...common.#ContextLinkProps]
|
||||
thresholds?: [...common.#ThresholdWithLabel]
|
||||
})
|
||||
|
||||
#Visualization: {
|
||||
timePreference?: common.#TimePreference
|
||||
fillSpans?: bool | *false
|
||||
}
|
||||
|
||||
#Formatting: {
|
||||
unit?: string | *""
|
||||
decimalPrecision?: common.#PrecisionOption
|
||||
}
|
||||
|
||||
#Legend: {
|
||||
position?: common.#LegendPosition
|
||||
// why call it customColors?
|
||||
customColors?: [string]: string
|
||||
}
|
||||
|
||||
// chart appearance
|
||||
- fill mode
|
||||
- line style
|
||||
- line interpolation
|
||||
- show points
|
||||
- span gaps
|
||||
@@ -0,0 +1,35 @@
|
||||
package model
|
||||
|
||||
import "github.com/signoz/common"
|
||||
|
||||
// Source: pkg/types/querybuildertypes/querybuildertypesv5/builder_query.go — QueryBuilderQuery
|
||||
kind: "SigNozBuilderQuery"
|
||||
spec: close({
|
||||
name: common.#QueryName
|
||||
signal: "metrics" | "logs" | "traces"
|
||||
expression: string
|
||||
disabled?: bool | *false
|
||||
|
||||
aggregations?: [...common.#Aggregation]
|
||||
filter?: common.#FilterExpression
|
||||
groupBy?: [...common.#GroupByItem]
|
||||
order?: [...common.#OrderByItem]
|
||||
selectFields?: [...common.#TelemetryFieldKey]
|
||||
limit?: common.#Limit
|
||||
limitBy?: #LimitBy
|
||||
offset?: common.#Offset
|
||||
cursor?: string
|
||||
having?: common.#HavingExpression
|
||||
// secondaryAggregations not added — not yet implemented.
|
||||
functions?: [...common.#Function]
|
||||
legend?: string
|
||||
stepInterval?: number
|
||||
reduceTo?: common.#ReduceTo
|
||||
pageSize?: int & >=1
|
||||
source?: string
|
||||
})
|
||||
|
||||
#LimitBy: close({
|
||||
keys: [...string]
|
||||
value: string
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"kind": "SigNozBuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"expression": "A",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "redis_keyspace_hits",
|
||||
"timeAggregation": "rate",
|
||||
"spaceAggregation": "sum",
|
||||
"reduceTo": "sum"
|
||||
}
|
||||
],
|
||||
"filter": {
|
||||
"expression": "host_name IN $host_name"
|
||||
},
|
||||
"groupBy": [],
|
||||
"order": [],
|
||||
"disabled": false,
|
||||
"legend": "Hit/s across all hosts",
|
||||
"stepInterval": 60
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package model
|
||||
|
||||
import "github.com/signoz/common"
|
||||
|
||||
// Source: pkg/types/querybuildertypes/querybuildertypesv5/clickhouse_query.go — ClickHouseQuery
|
||||
kind: "SigNozClickHouseSQL"
|
||||
spec: close({
|
||||
name: common.#QueryName
|
||||
query: string & !=""
|
||||
// required / optional
|
||||
disabled?: bool | *false
|
||||
legend?: string
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"kind": "SigNozClickHouseSQL",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"query": "SELECT toStartOfInterval(timestamp, INTERVAL 1 MINUTE) AS ts, count() AS total FROM signoz_logs.distributed_logs GROUP BY ts ORDER BY ts",
|
||||
"disabled": false,
|
||||
"legend": "Log count"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
bq "github.com/signoz/schemas-wrapper/schemas/signoz-builder-query:model"
|
||||
f "github.com/signoz/schemas-wrapper/schemas/signoz-formula:model"
|
||||
to "github.com/signoz/schemas-wrapper/schemas/signoz-trace-operator:model"
|
||||
pql "github.com/signoz/schemas-wrapper/schemas/signoz-promql:model"
|
||||
ch "github.com/signoz/schemas-wrapper/schemas/signoz-clickhouse-sql:model"
|
||||
)
|
||||
|
||||
// Source: pkg/types/querybuildertypes/querybuildertypesv5/req.go — CompositeQuery
|
||||
// SigNozCompositeQuery groups multiple query plugins into a single
|
||||
// query request. Each entry is a typed envelope whose spec is
|
||||
// validated by the corresponding plugin schema.
|
||||
|
||||
// this is to be used when there are multiple queries in a panel
|
||||
// in most cases, there will be only one query, and there it is a better idea to
|
||||
// use the corresponding kind for that query instead of this composite query
|
||||
kind: "SigNozCompositeQuery"
|
||||
spec: close({
|
||||
queries: [...#QueryEnvelope]
|
||||
})
|
||||
|
||||
// QueryEnvelope wraps a single query plugin with a type discriminator.
|
||||
|
||||
// need to verify the types internally are marked required where necessary, so that we can make them optional here and not have to worry about it at the top level
|
||||
#QueryEnvelope:
|
||||
close({
|
||||
type: "builder_query",
|
||||
spec: bq.spec
|
||||
}) |
|
||||
close({
|
||||
type: "builder_formula",
|
||||
spec: f.spec
|
||||
}) |
|
||||
close({
|
||||
type: "builder_trace_operator",
|
||||
spec: to.spec
|
||||
}) |
|
||||
close({
|
||||
type: "promql",
|
||||
spec: pql.spec
|
||||
}) |
|
||||
close({
|
||||
type: "clickhouse_sql",
|
||||
spec: ch.spec
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"kind": "SigNozCompositeQuery",
|
||||
"spec": {
|
||||
"queries": [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"expression": "A",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "redis_keyspace_hits",
|
||||
"timeAggregation": "rate",
|
||||
"spaceAggregation": "sum",
|
||||
"reduceTo": "sum"
|
||||
}
|
||||
],
|
||||
"filter": {
|
||||
"expression": "host_name IN $host_name"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "B",
|
||||
"signal": "metrics",
|
||||
"expression": "B",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "redis_keyspace_misses",
|
||||
"timeAggregation": "rate",
|
||||
"spaceAggregation": "sum",
|
||||
"reduceTo": "sum"
|
||||
}
|
||||
],
|
||||
"filter": {
|
||||
"expression": "host_name IN $host_name"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "builder_formula",
|
||||
"spec": {
|
||||
"name": "F1",
|
||||
"expression": "A / (A + B) * 100",
|
||||
"legend": "Hit rate %"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package model
|
||||
|
||||
import "github.com/signoz/common"
|
||||
|
||||
// Source: pkg/types/querybuildertypes/querybuildertypesv5/prom_query.go — PromQuery
|
||||
kind: "SigNozPromQLQuery"
|
||||
spec: close({
|
||||
name: common.#QueryName
|
||||
query: string & !=""
|
||||
disabled?: bool | *false
|
||||
legend?: string
|
||||
// where are the below items coming from? Don't see types for it. How to arrive at this?
|
||||
step?: =~"^([0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$" | number
|
||||
stats?: bool
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"kind": "SigNozPromQLQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"query": "rate(http_requests_total{status=\"200\"}[5m])",
|
||||
"disabled": false,
|
||||
"legend": "{{method}} {{path}}"
|
||||
}
|
||||
}
|
||||
1
perses/schemas-wrapper/schemas/review.md
Normal file
1
perses/schemas-wrapper/schemas/review.md
Normal file
@@ -0,0 +1 @@
|
||||
creating folders based on type is helpful instead of a flat folder structure. It is how you would visualise the dashboard as components
|
||||
@@ -0,0 +1,10 @@
|
||||
package model
|
||||
|
||||
import "github.com/signoz/common"
|
||||
|
||||
// defaultValue lives on the Perses ListVariable wrapper (spec level).
|
||||
kind: "SigNozCustomVariable"
|
||||
spec: close({
|
||||
customValue: =~"^[^,]+(,[^,]+)*$"
|
||||
sort?: common.#VariableSortOrder
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"kind": "SigNozCustomVariable",
|
||||
"spec": {
|
||||
"customValue": "production,staging,development",
|
||||
"sort": "disabled"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package model
|
||||
|
||||
import "github.com/signoz/common"
|
||||
|
||||
// defaultValue lives on the Perses ListVariable wrapper (spec level).
|
||||
kind: "SigNozDynamicVariable"
|
||||
spec: close({
|
||||
name: string
|
||||
source: string
|
||||
sort?: common.#VariableSortOrder
|
||||
// where does attribute go here?
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"kind": "SigNozDynamicVariable",
|
||||
"spec": {
|
||||
"name": "host_name",
|
||||
"source": "metrics",
|
||||
"sort": "asc"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package model
|
||||
|
||||
import "github.com/signoz/common"
|
||||
|
||||
// defaultValue lives on the Perses ListVariable wrapper (spec level).
|
||||
kind: "SigNozQueryVariable"
|
||||
spec: close({
|
||||
queryValue: string
|
||||
sort?: common.#VariableSortOrder
|
||||
// This should not be optional
|
||||
// what happens to the existing https://perses.dev/perses/docs/dac/go/variable/#sortingby inside the ListVariable spec? Do we move that to the plugin spec level?
|
||||
// same for other list variable cues
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"kind": "SigNozQueryVariable",
|
||||
"spec": {
|
||||
"queryValue": "SELECT DISTINCT host_name FROM signoz_metrics.distributed_time_series_v4_1day WHERE metric_name = 'redis_cpu_time'",
|
||||
"sort": "asc"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package model
|
||||
|
||||
// Shouldn't the below line be TextVariable?
|
||||
// are any of the variables inside this required? How would that validation work?
|
||||
// defaultValue lives on the Perses ListVariable wrapper (spec level).
|
||||
kind: "SigNozTextboxVariable"
|
||||
spec: close({})
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"kind": "SigNozTextboxVariable",
|
||||
"spec": {}
|
||||
}
|
||||
2
perses/validate.sh
Executable file
2
perses/validate.sh
Executable file
@@ -0,0 +1,2 @@
|
||||
percli lint -f ./examples/perses.json --plugin.path ./schemas-wrapper
|
||||
rm ./schemas-wrapper/plugin-modules.json
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user