Compare commits

..

69 Commits

Author SHA1 Message Date
Ashwin Bhatkal
67fb4cf8a5 put back json 2026-03-31 03:51:17 +05:30
Ashwin Bhatkal
11f9daf2a7 queries review done 2026-03-31 03:39:52 +05:30
Ashwin Bhatkal
01cc509c1b panels review done 2026-03-31 03:32:33 +05:30
Ashwin Bhatkal
c17021aade variables review done 2026-03-31 02:25:35 +05:30
Ashwin Bhatkal
d9961fe527 local commit refactor 2026-03-31 01:56:00 +05:30
Ashwin Bhatkal
5035390236 local commit 2026-03-31 00:41:21 +05:30
Naman Verma
7d3612c10a feat: textbox variable 2026-03-25 13:21:46 +05:30
Naman Verma
c82cd32f61 Merge branch 'main' into nv/4172 2026-03-25 13:11:49 +05:30
Naman Verma
a3980e084c Merge branch 'main' into nv/4172 2026-03-24 16:44:50 +05:30
Naman Verma
4319dd9cef chore: doc for how to add a panel spec 2026-03-24 16:44:34 +05:30
Naman Verma
92a5e9b9c9 chore: common threshold type 2026-03-24 16:23:11 +05:30
Naman Verma
408a914129 chore: change attr name to name 2026-03-24 16:13:09 +05:30
Naman Verma
0e304b1d40 fix: normalise enum defs 2026-03-24 16:12:09 +05:30
Naman Verma
58a9be24d3 chore: single version for all schemas 2026-03-24 14:07:24 +05:30
Naman Verma
adf439fcf1 fix: proper type for selectFields 2026-03-24 13:56:14 +05:30
Naman Verma
a1a54c4bb2 fix: promql step duration schema 2026-03-24 13:53:19 +05:30
Naman Verma
3c1961d3fc fix: datasource in perses.json 2026-03-24 13:44:58 +05:30
Naman Verma
c3efa0660b fix: only allow one of metric or expr aggregation in builder query 2026-03-24 13:40:42 +05:30
Naman Verma
183dd09082 fix: functions in formula 2026-03-24 13:34:59 +05:30
Naman Verma
a351373c49 chore: common package for variables' repeated definitions 2026-03-24 13:23:53 +05:30
Naman Verma
8e7653b90d chore: common package for queries' repeated definitions 2026-03-24 13:22:49 +05:30
Naman Verma
5c40d6b68b chore: common package for panels' repeated definitions 2026-03-24 13:19:12 +05:30
Naman Verma
31115df41c chore: actually name every panel as a panel 2026-03-24 13:05:55 +05:30
Naman Verma
869c3dccb2 fix: less verbose field names in dynamic var 2026-03-24 12:57:34 +05:30
Naman Verma
c5d7a7ef8c fix: no nesting in context links 2026-03-24 12:55:40 +05:30
Naman Verma
544b87b254 chore: remove unimplemented join query schema 2026-03-24 12:48:36 +05:30
Naman Verma
e885fb98e5 chore: no need for threshold prefix inside threshold obj 2026-03-24 12:44:50 +05:30
Naman Verma
be227eec43 chore: replace yAxisUnit by unit 2026-03-24 12:41:16 +05:30
Naman Verma
13263c1f25 Merge branch 'main' into nv/4172 2026-03-23 20:01:04 +05:30
Naman Verma
ccbf410d15 fix: no more online validation 2026-03-23 16:23:51 +05:30
Naman Verma
03b98ff824 chore: remaining fields file 2026-03-23 16:10:01 +05:30
Naman Verma
2cdba0d11c chore: examples for panel types 2026-03-23 16:06:29 +05:30
Naman Verma
84d2885530 chore: a more complex example 2026-03-23 16:03:18 +05:30
Naman Verma
b82dcc6138 docs: list panel schema without upstream ref 2026-03-23 15:31:53 +05:30
Naman Verma
a14d5847b9 docs: histogram chart panel schema without upstream ref 2026-03-23 15:21:27 +05:30
Naman Verma
d184746142 docs: table chart panel schema without upstream ref 2026-03-23 15:17:21 +05:30
Naman Verma
c335e17e1d docs: pie chart panel schema without upstream ref 2026-03-23 14:33:05 +05:30
Naman Verma
433dd0b2d0 docs: number panel schema without upstream ref 2026-03-23 14:27:46 +05:30
Naman Verma
05e97e246a docs: number panel schema without upstream ref 2026-03-23 14:27:37 +05:30
Naman Verma
bddfe30f6c docs: bar chart panel schema without upstream ref 2026-03-23 14:22:00 +05:30
Naman Verma
7a01a5250d chore: object for visualization section 2026-03-23 14:16:07 +05:30
Naman Verma
09c98c830d docs: time series panel schema without upstream ref 2026-03-23 14:11:02 +05:30
Naman Verma
0fbb90cc91 fix: promql fix 2026-03-23 14:09:48 +05:30
Naman Verma
15f0787610 chore: remove upstream import 2026-03-23 13:07:48 +05:30
Naman Verma
22ebc7732c feat: promql example 2026-03-22 19:39:57 +05:30
Naman Verma
cff18edf6e chore: comment explaining when to use composite query and when not 2026-03-22 19:22:45 +05:30
Naman Verma
cb49c0bf3b feat: custom time series schema 2026-03-22 19:18:15 +05:30
Naman Verma
1cb6f94d21 fix: proper composite query schema 2026-03-19 16:18:31 +05:30
Naman Verma
68155f374b chore: folders in schemas for arranging 2026-03-19 15:15:06 +05:30
Naman Verma
696524509f chore: folders in schemas for arranging 2026-03-19 15:14:43 +05:30
Naman Verma
705cdab38c chore: rename 2026-03-19 14:24:10 +05:30
Naman Verma
ae9b881413 chore: py script not needed 2026-03-19 01:39:17 +05:30
Naman Verma
05f4e15d07 chore: rearrange specs in package.json 2026-03-19 01:39:08 +05:30
Naman Verma
1653c6d725 chore: checkpoint for half correct setup 2026-03-19 01:34:22 +05:30
Naman Verma
070b4b7061 chore: test file with way more examples 2026-03-18 22:01:16 +05:30
Naman Verma
7f4c06edd6 chore: test file with way more examples 2026-03-18 22:01:10 +05:30
Naman Verma
6bed20b5b9 fix: remove fields from variable specs that are there in ListVariable 2026-03-18 19:19:00 +05:30
Naman Verma
033bd3c9b8 feat: validation script 2026-03-18 15:52:05 +05:30
Naman Verma
d4c9a923fd chore: no commons (for now) 2026-03-18 15:28:35 +05:30
Naman Verma
387dcb529f chore: no config folder 2026-03-18 15:04:16 +05:30
Naman Verma
7a4da7bcc5 chore: no config folder 2026-03-18 15:04:02 +05:30
Naman Verma
b152fae3fa chore: remove validate file 2026-03-18 15:03:19 +05:30
Naman Verma
2ed766726c chore: remove manually written manifest and package 2026-03-18 15:02:23 +05:30
Naman Verma
8767f6a57d chore: remove stub for time series chart 2026-03-18 15:01:58 +05:30
Naman Verma
22d8c7599b chore: rm comment 2026-03-18 13:10:19 +05:30
Naman Verma
1019264272 chore: no need for PageSize type in commons, only used once 2026-03-18 13:07:31 +05:30
Naman Verma
c950d7e784 chore: no need for Signal type in commons, only used once 2026-03-18 13:05:32 +05:30
Naman Verma
1e279e6193 Merge branch 'main' into nv/4172 2026-03-18 13:03:09 +05:30
Naman Verma
d3a278c43e docs: perses schema for dashboards 2026-03-17 14:56:07 +05:30
175 changed files with 5427 additions and 6054 deletions

3
.prettierrc Normal file
View File

@@ -0,0 +1,3 @@
{
"trailingComma": "none"
}

44
.vscode/settings.json vendored
View File

@@ -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": []
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

View File

@@ -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}',

View File

@@ -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

View File

@@ -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,

View File

@@ -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' }));

View File

@@ -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 (

View File

@@ -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);

View File

@@ -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();

View File

@@ -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);
}

View File

@@ -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();

View File

@@ -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(() => {

View File

@@ -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]);

View File

@@ -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);

View File

@@ -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 () => {

View File

@@ -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}
/>

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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 = [
{

View File

@@ -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);
});

View File

@@ -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');
});
});
});

View File

@@ -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';
};

View File

@@ -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 => {

View File

@@ -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
});
});

View File

@@ -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(

View File

@@ -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>}

View File

@@ -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');
});
});

View File

@@ -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

View File

@@ -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,

View File

@@ -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']>

View File

@@ -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: {

View File

@@ -239,10 +239,7 @@ function ListView({
/>
</div>
<DownloadOptionsMenu
dataSource={DataSource.TRACES}
selectedColumns={options?.selectColumns}
/>
<DownloadOptionsMenu dataSource={DataSource.TRACES} />
<TraceExplorerControls
isLoading={isFetching}

View File

@@ -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);
});
});

View File

@@ -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: ['/'],

View File

@@ -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;
});
}

View File

@@ -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}`,

View File

@@ -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(() => {

View File

@@ -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);

View File

@@ -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: [] }),
}),
),
},

View File

@@ -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>

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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
View 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"

View File

@@ -0,0 +1,4 @@
module: "github.com/signoz"
language: {
version: "v0.12.0"
}

2124
perses/examples/current.json Normal file

File diff suppressed because it is too large Load Diff

926
perses/examples/perses.json Normal file
View 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
View 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 | — | — |

View File

@@ -0,0 +1,9 @@
{
"id": "signoz",
"name": "signoz",
"metaData": {
"buildInfo": {
"buildVersion": "0.0.1"
}
}
}

View File

@@ -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

View File

@@ -0,0 +1,4 @@
{
"kind": "SigNozDatasource",
"spec": {}
}

View File

@@ -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"

View File

@@ -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]
})

View File

@@ -0,0 +1,8 @@
{
"kind": "SigNozFormula",
"spec": {
"name": "F1",
"expression": "A / B * 100",
"legend": "Hit rate %"
}
}

View File

@@ -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]
})

View File

@@ -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": []
}
}

View 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"
}
}
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 711 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

View 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

View File

@@ -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"
}
]
}
}

View File

@@ -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
}

View File

@@ -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"
}
]
}
}

View File

@@ -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
}

View File

@@ -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": ""
}
]
}
}

View File

@@ -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
}

View File

@@ -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"
}
]
}
}

View File

@@ -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
}

View File

@@ -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"
}
]
}
}

View File

@@ -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
}

View File

@@ -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"
}
]
}
}

View File

@@ -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

View File

@@ -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"
}
]
}
}

View File

@@ -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

View File

@@ -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
})

View File

@@ -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
}
}

View File

@@ -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
})

View File

@@ -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"
}
}

View File

@@ -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
})

View File

@@ -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 %"
}
}
]
}
}

View File

@@ -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
})

View File

@@ -0,0 +1,9 @@
{
"kind": "SigNozPromQLQuery",
"spec": {
"name": "A",
"query": "rate(http_requests_total{status=\"200\"}[5m])",
"disabled": false,
"legend": "{{method}} {{path}}"
}
}

View 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

View File

@@ -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
})

View File

@@ -0,0 +1,7 @@
{
"kind": "SigNozCustomVariable",
"spec": {
"customValue": "production,staging,development",
"sort": "disabled"
}
}

View File

@@ -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?
})

View File

@@ -0,0 +1,8 @@
{
"kind": "SigNozDynamicVariable",
"spec": {
"name": "host_name",
"source": "metrics",
"sort": "asc"
}
}

View File

@@ -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
})

View File

@@ -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"
}
}

View File

@@ -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({})

View File

@@ -0,0 +1,4 @@
{
"kind": "SigNozTextboxVariable",
"spec": {}
}

2
perses/validate.sh Executable file
View 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