mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-10 08:30:33 +01:00
Compare commits
228 Commits
feat/expor
...
feat/llm-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec3810a406 | ||
|
|
5c497f5816 | ||
|
|
bfa8cff52d | ||
|
|
412614c82f | ||
|
|
e0f1a59078 | ||
|
|
4483e4947d | ||
|
|
2eeda8c24a | ||
|
|
90d9ab9116 | ||
|
|
3689de90a7 | ||
|
|
d7102b8efb | ||
|
|
4195a1a0e3 | ||
|
|
cc2c3527f2 | ||
|
|
7c86db3f61 | ||
|
|
5c1b520841 | ||
|
|
a81bfe9272 | ||
|
|
e1dd7d52eb | ||
|
|
b0a14754ca | ||
|
|
2c04dbc18a | ||
|
|
1644e35d9c | ||
|
|
379331ee77 | ||
|
|
e3e2c52873 | ||
|
|
4d079a8338 | ||
|
|
15c6c68f42 | ||
|
|
fbba9d9d05 | ||
|
|
91938925ec | ||
|
|
68d8089e1e | ||
|
|
a032155991 | ||
|
|
a079990f26 | ||
|
|
0f84ef2b97 | ||
|
|
91919cc7ed | ||
|
|
385a5c1d23 | ||
|
|
3c25d88f63 | ||
|
|
df22c40df6 | ||
|
|
2b9b500dc0 | ||
|
|
2241e0f98b | ||
|
|
afd3ddf947 | ||
|
|
ed41a1cd8b | ||
|
|
47072d316a | ||
|
|
2ce340f8af | ||
|
|
10ceb1c7fe | ||
|
|
1d178c2936 | ||
|
|
bc9ba7b039 | ||
|
|
90307c913e | ||
|
|
14e5ff1be4 | ||
|
|
902952f249 | ||
|
|
696f2a3155 | ||
|
|
817ed96d9a | ||
|
|
e250bd75ac | ||
|
|
bc746d48c4 | ||
|
|
9e0a662de4 | ||
|
|
9b8834003f | ||
|
|
abc2881b7b | ||
|
|
bd7da902fb | ||
|
|
431ef9ae61 | ||
|
|
d2ff4114fd | ||
|
|
48dc9a7f69 | ||
|
|
93682947aa | ||
|
|
1fcb9b85b7 | ||
|
|
72c99b12ed | ||
|
|
2cd3ba3582 | ||
|
|
d15c4fc15a | ||
|
|
8e5cfcf197 | ||
|
|
3bf84f22fc | ||
|
|
f851b12c48 | ||
|
|
43f599b2a2 | ||
|
|
43ea643f17 | ||
|
|
bec8b61c53 | ||
|
|
4690018428 | ||
|
|
62decea54c | ||
|
|
878daa931b | ||
|
|
e3f9332890 | ||
|
|
66a49b84a7 | ||
|
|
5dd71bbc2f | ||
|
|
05d73ff074 | ||
|
|
82d18654fa | ||
|
|
63dde6b4c7 | ||
|
|
31ffcd43ab | ||
|
|
d8cc5ac727 | ||
|
|
a81a46bc4d | ||
|
|
5244ea1060 | ||
|
|
1d6137100f | ||
|
|
ce725f655d | ||
|
|
c49bf131e9 | ||
|
|
bcb019048d | ||
|
|
fa6d2df271 | ||
|
|
2394c4789e | ||
|
|
9643d16c7c | ||
|
|
b1a582bd83 | ||
|
|
ef8711bd51 | ||
|
|
af41ac54b7 | ||
|
|
ef537e9841 | ||
|
|
a3957949c0 | ||
|
|
b59e13a50d | ||
|
|
acd3aaaccd | ||
|
|
ed89f8ba96 | ||
|
|
eec621e6c6 | ||
|
|
0325b76c21 | ||
|
|
7e246e5c0a | ||
|
|
697dbc87bb | ||
|
|
b3db9fe7ee | ||
|
|
0fe112225e | ||
|
|
a562e126ff | ||
|
|
e3f47fbdab | ||
|
|
646fd45ea4 | ||
|
|
8babcb1496 | ||
|
|
ce436ebf47 | ||
|
|
0daba7643c | ||
|
|
a9e953f33f | ||
|
|
b727eed230 | ||
|
|
769343b8af | ||
|
|
fc94bca621 | ||
|
|
f512127eee | ||
|
|
d600fb9d66 | ||
|
|
7a4947d250 | ||
|
|
8586a4833e | ||
|
|
3a65de230b | ||
|
|
c18b624205 | ||
|
|
76fad144e2 | ||
|
|
68e2e15caf | ||
|
|
95e31e4624 | ||
|
|
31443c25b1 | ||
|
|
471ac4979c | ||
|
|
078446aaef | ||
|
|
6092a8e1de | ||
|
|
bc60bda32e | ||
|
|
dbc69ecef5 | ||
|
|
b4c5fe387b | ||
|
|
e2451576c5 | ||
|
|
96379b833c | ||
|
|
1615204566 | ||
|
|
f6f4285711 | ||
|
|
b2e0ade9e6 | ||
|
|
62f828fae3 | ||
|
|
8d5ad341ea | ||
|
|
3fe8d760fb | ||
|
|
6da8d54b40 | ||
|
|
e3edac6292 | ||
|
|
ef90f0a277 | ||
|
|
2739c69333 | ||
|
|
d834f26459 | ||
|
|
38345d960a | ||
|
|
d93a78edea | ||
|
|
4d397e20de | ||
|
|
c6ff80a285 | ||
|
|
659ae5f1ab | ||
|
|
c967c72c38 | ||
|
|
9c1e01d3e6 | ||
|
|
f2f992d4e3 | ||
|
|
9b53687b38 | ||
|
|
d9d92c5ae9 | ||
|
|
1e982261a7 | ||
|
|
d2e89789fa | ||
|
|
2d1d8b2df7 | ||
|
|
2d3359c97e | ||
|
|
f10cd84147 | ||
|
|
6e25f09677 | ||
|
|
bcc1ff2444 | ||
|
|
3babbae36d | ||
|
|
a358fc717b | ||
|
|
feb6edc057 | ||
|
|
c9afc7a375 | ||
|
|
074915e22b | ||
|
|
eaef369eb1 | ||
|
|
a13969c8bc | ||
|
|
3f0d07c28c | ||
|
|
f2b3c4125c | ||
|
|
96cc6ec588 | ||
|
|
c6705a72a7 | ||
|
|
7a93fe8192 | ||
|
|
da82731195 | ||
|
|
63e46ad947 | ||
|
|
39f8419a9e | ||
|
|
64a9725e12 | ||
|
|
44d76c1306 | ||
|
|
b9ac79898e | ||
|
|
c136834ab5 | ||
|
|
dd0155065f | ||
|
|
08bc6ec75b | ||
|
|
e47a0cd954 | ||
|
|
2026349f6d | ||
|
|
836a00cdfa | ||
|
|
38f1506472 | ||
|
|
7692f99f30 | ||
|
|
c3ba316ccf | ||
|
|
eeb8353a17 | ||
|
|
47b5fff3f6 | ||
|
|
a0940af68f | ||
|
|
72713d996d | ||
|
|
93a1caa9cc | ||
|
|
bb8900113a | ||
|
|
1fb195b383 | ||
|
|
c2761c24df | ||
|
|
a6b77993c6 | ||
|
|
59d2468fb1 | ||
|
|
44d8d55b2f | ||
|
|
8fdce2b9ad | ||
|
|
1c06a57361 | ||
|
|
dfd3bce670 | ||
|
|
9725a268cc | ||
|
|
ad73f98d0f | ||
|
|
0b9a0c4ac8 | ||
|
|
2323495c4a | ||
|
|
e1a18d36fe | ||
|
|
1b16aea459 | ||
|
|
9bd5d233ba | ||
|
|
b992f38bca | ||
|
|
5d89b1a348 | ||
|
|
e6daf07a06 | ||
|
|
fda1bf17dc | ||
|
|
5c062be23a | ||
|
|
585894b382 | ||
|
|
74e560409c | ||
|
|
79a7efe8e2 | ||
|
|
75a2208e9f | ||
|
|
86db724069 | ||
|
|
22a8f295f0 | ||
|
|
34e712d212 | ||
|
|
a17cfcc131 | ||
|
|
8697461476 | ||
|
|
47e33e0610 | ||
|
|
0829155247 | ||
|
|
92b8fa86e7 | ||
|
|
31e1b21740 | ||
|
|
dcad825615 | ||
|
|
6b508655bd | ||
|
|
c3f1df3ed0 | ||
|
|
a19c8f3cc6 | ||
|
|
b1aaf3ec9a |
@@ -515,6 +515,13 @@ components:
|
||||
url:
|
||||
type: string
|
||||
type: object
|
||||
AuthtypesDeprecatedPostableUserRole:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
AuthtypesGettableAuthDomain:
|
||||
properties:
|
||||
authNProviderInfo:
|
||||
@@ -660,17 +667,20 @@ components:
|
||||
type: string
|
||||
userRoles:
|
||||
items:
|
||||
$ref: '#/components/schemas/AuthtypesPostableUserRole'
|
||||
$ref: '#/components/schemas/AuthtypesDeprecatedPostableUserRole'
|
||||
type: array
|
||||
required:
|
||||
- email
|
||||
type: object
|
||||
AuthtypesPostableUserRole:
|
||||
properties:
|
||||
id:
|
||||
roleId:
|
||||
type: string
|
||||
userId:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- userId
|
||||
- roleId
|
||||
type: object
|
||||
AuthtypesRelation:
|
||||
enum:
|
||||
@@ -7430,6 +7440,13 @@ components:
|
||||
enum:
|
||||
- basic
|
||||
type: string
|
||||
ServiceaccounttypesDeprecatedPostableServiceAccountRole:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
ServiceaccounttypesGettableFactorAPIKey:
|
||||
properties:
|
||||
createdAt:
|
||||
@@ -7486,10 +7503,13 @@ components:
|
||||
type: object
|
||||
ServiceaccounttypesPostableServiceAccountRole:
|
||||
properties:
|
||||
id:
|
||||
roleId:
|
||||
type: string
|
||||
serviceAccountId:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- serviceAccountId
|
||||
- roleId
|
||||
type: object
|
||||
ServiceaccounttypesServiceAccount:
|
||||
properties:
|
||||
@@ -12252,6 +12272,188 @@ paths:
|
||||
summary: Update route policy
|
||||
tags:
|
||||
- routepolicies
|
||||
/api/v1/service_account_roles:
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint assigns a role to a service account
|
||||
operationId: CreateServiceAccountRole
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccountRole'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TypesIdentifiable'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: Created
|
||||
"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:
|
||||
- serviceaccount:attach
|
||||
- role:attach
|
||||
- tokenizer:
|
||||
- serviceaccount:attach
|
||||
- role:attach
|
||||
summary: Create service account role
|
||||
tags:
|
||||
- serviceaccount
|
||||
/api/v1/service_account_roles/{id}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: This endpoint revokes a role from a service account
|
||||
operationId: DeleteServiceAccountRole
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
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:
|
||||
- serviceaccount:detach
|
||||
- role:detach
|
||||
- tokenizer:
|
||||
- serviceaccount:detach
|
||||
- role:detach
|
||||
summary: Delete service account role
|
||||
tags:
|
||||
- serviceaccount
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint gets an existing service account role
|
||||
operationId: GetServiceAccountRole
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesServiceAccountRole'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"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:
|
||||
- serviceaccount:read
|
||||
- tokenizer:
|
||||
- serviceaccount:read
|
||||
summary: Get service account role
|
||||
tags:
|
||||
- serviceaccount
|
||||
/api/v1/service_accounts:
|
||||
get:
|
||||
deprecated: false
|
||||
@@ -12821,9 +13023,9 @@ paths:
|
||||
tags:
|
||||
- serviceaccount
|
||||
post:
|
||||
deprecated: false
|
||||
deprecated: true
|
||||
description: This endpoint assigns a role to a service account
|
||||
operationId: CreateServiceAccountRole
|
||||
operationId: CreateServiceAccountRoleDeprecated
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@@ -12834,7 +13036,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccountRole'
|
||||
$ref: '#/components/schemas/ServiceaccounttypesDeprecatedPostableServiceAccountRole'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
@@ -12886,9 +13088,9 @@ paths:
|
||||
- serviceaccount
|
||||
/api/v1/service_accounts/{id}/roles/{rid}:
|
||||
delete:
|
||||
deprecated: false
|
||||
deprecated: true
|
||||
description: This endpoint revokes a role from service account
|
||||
operationId: DeleteServiceAccountRole
|
||||
operationId: DeleteServiceAccountRoleDeprecated
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@@ -21990,6 +22192,184 @@ paths:
|
||||
summary: Rotate session
|
||||
tags:
|
||||
- sessions
|
||||
/api/v2/user_roles:
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint assigns a role to a user
|
||||
operationId: CreateUserRole
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthtypesPostableUserRole'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TypesIdentifiable'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: Created
|
||||
"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: Create user role
|
||||
tags:
|
||||
- users
|
||||
/api/v2/user_roles/{id}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: This endpoint revokes a role from a user
|
||||
operationId: DeleteUserRole
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
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: Delete user role
|
||||
tags:
|
||||
- users
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint gets an existing user role
|
||||
operationId: GetUserRole
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AuthtypesUserRole'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"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 role
|
||||
tags:
|
||||
- users
|
||||
/api/v2/users:
|
||||
get:
|
||||
deprecated: false
|
||||
@@ -22443,7 +22823,7 @@ paths:
|
||||
tags:
|
||||
- users
|
||||
post:
|
||||
deprecated: false
|
||||
deprecated: true
|
||||
description: This endpoint assigns the role to the user roles by user id
|
||||
operationId: SetRoleByUserID
|
||||
parameters:
|
||||
@@ -22494,7 +22874,7 @@ paths:
|
||||
- users
|
||||
/api/v2/users/{id}/roles/{roleId}:
|
||||
delete:
|
||||
deprecated: false
|
||||
deprecated: true
|
||||
description: This endpoint removes a role from the user by user id and role
|
||||
id
|
||||
operationId: RemoveUserRoleByUserIDAndRoleID
|
||||
|
||||
@@ -519,7 +519,7 @@ func (module *module) getOrCreateAPIKey(ctx context.Context, orgID valuer.UUID,
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
err = module.serviceAccount.SetRoleByName(ctx, orgID, serviceAccount.ID, authtypes.SigNozViewerRoleName)
|
||||
_, err = module.serviceAccount.SetRoleByName(ctx, orgID, serviceAccount.ID, authtypes.SigNozViewerRoleName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ func (ah *APIHandler) getOrCreateCloudIntegrationServiceAccount(ctx context.Cont
|
||||
if err != nil {
|
||||
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration service account: %w", err))
|
||||
}
|
||||
err = ah.Signoz.Modules.ServiceAccount.SetRoleByName(ctx, orgId, cloudIntegrationServiceAccount.ID, authtypes.SigNozViewerRoleName)
|
||||
_, err = ah.Signoz.Modules.ServiceAccount.SetRoleByName(ctx, orgId, cloudIntegrationServiceAccount.ID, authtypes.SigNozViewerRoleName)
|
||||
if err != nil {
|
||||
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration service account: %w", err))
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ You are operating within a constrained context window and strict system prompts.
|
||||
- Never create barrel files.
|
||||
- When writing new css, prefer CSS Modules
|
||||
- Use ./docs/css-modules-guide.md as reference on how to write good CSS Modules.
|
||||
- When writing code that could need authorization checks, read ./src/lib/authz/README.md
|
||||
|
||||
3. FORCED VERIFICATION: Your internal tools mark file writes as successful even if the code does not compile. You are FORBIDDEN from reporting a task as complete until you have:
|
||||
- Run `pnpm tsgo --noEmit`
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
|
||||
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
|
||||
"LLM_OBSERVABILITY_BASE": "SigNoz | LLM Observability",
|
||||
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
|
||||
}
|
||||
"LLM_OBSERVABILITY_OVERVIEW": "SigNoz | LLM Observability Overview",
|
||||
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration",
|
||||
"LLM_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | LLM Observability Attribute Mapping"
|
||||
}
|
||||
@@ -87,6 +87,7 @@
|
||||
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
|
||||
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
|
||||
"LLM_OBSERVABILITY_BASE": "SigNoz | LLM Observability",
|
||||
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
|
||||
}
|
||||
"LLM_OBSERVABILITY_OVERVIEW": "SigNoz | LLM Observability Overview",
|
||||
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration",
|
||||
"LLM_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | LLM Observability Attribute Mapping"
|
||||
}
|
||||
@@ -329,10 +329,3 @@ export const LLMObservabilityPage = Loadable(
|
||||
/* webpackChunkName: "LLM Observability Page" */ 'pages/LLMObservability'
|
||||
),
|
||||
);
|
||||
|
||||
export const LLMObservabilityModelPricingPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "LLM Observability Model Pricing Page" */ 'pages/LLMObservabilityModelPricing'
|
||||
),
|
||||
);
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
LicensePage,
|
||||
ListAllALertsPage,
|
||||
LLMObservabilityPage,
|
||||
LLMObservabilityModelPricingPage,
|
||||
LiveLogs,
|
||||
Login,
|
||||
Logs,
|
||||
@@ -515,17 +514,24 @@ const routes: AppRoutes[] = [
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LLM_OBSERVABILITY_BASE,
|
||||
path: ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
|
||||
exact: true,
|
||||
component: LLMObservabilityPage,
|
||||
key: 'LLM_OBSERVABILITY_BASE',
|
||||
key: 'LLM_OBSERVABILITY_ATTRIBUTE_MAPPING',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
|
||||
path: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
|
||||
exact: true,
|
||||
component: LLMObservabilityModelPricingPage,
|
||||
key: 'LLM_OBSERVABILITY_MODEL_PRICING',
|
||||
component: LLMObservabilityPage,
|
||||
key: 'LLM_OBSERVABILITY_OVERVIEW',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
|
||||
exact: true,
|
||||
component: LLMObservabilityPage,
|
||||
key: 'LLM_OBSERVABILITY_CONFIGURATION',
|
||||
isPrivate: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -22,12 +22,16 @@ import type {
|
||||
CreateServiceAccountKey201,
|
||||
CreateServiceAccountKeyPathParameters,
|
||||
CreateServiceAccountRole201,
|
||||
CreateServiceAccountRolePathParameters,
|
||||
CreateServiceAccountRoleDeprecated201,
|
||||
CreateServiceAccountRoleDeprecatedPathParameters,
|
||||
DeleteServiceAccountPathParameters,
|
||||
DeleteServiceAccountRoleDeprecatedPathParameters,
|
||||
DeleteServiceAccountRolePathParameters,
|
||||
GetMyServiceAccount200,
|
||||
GetServiceAccount200,
|
||||
GetServiceAccountPathParameters,
|
||||
GetServiceAccountRole200,
|
||||
GetServiceAccountRolePathParameters,
|
||||
GetServiceAccountRoles200,
|
||||
GetServiceAccountRolesPathParameters,
|
||||
ListServiceAccountKeys200,
|
||||
@@ -35,6 +39,7 @@ import type {
|
||||
ListServiceAccounts200,
|
||||
RenderErrorResponseDTO,
|
||||
RevokeServiceAccountKeyPathParameters,
|
||||
ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO,
|
||||
ServiceaccounttypesPostableFactorAPIKeyDTO,
|
||||
ServiceaccounttypesPostableServiceAccountDTO,
|
||||
ServiceaccounttypesPostableServiceAccountRoleDTO,
|
||||
@@ -46,6 +51,272 @@ import type {
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* This endpoint assigns a role to a service account
|
||||
* @summary Create service account role
|
||||
*/
|
||||
export const createServiceAccountRole = (
|
||||
serviceaccounttypesPostableServiceAccountRoleDTO?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateServiceAccountRole201>({
|
||||
url: `/api/v1/service_account_roles`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: serviceaccounttypesPostableServiceAccountRoleDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateServiceAccountRoleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
TError,
|
||||
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
TError,
|
||||
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createServiceAccountRole'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createServiceAccountRole(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateServiceAccountRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>
|
||||
>;
|
||||
export type CreateServiceAccountRoleMutationBody =
|
||||
| BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>
|
||||
| undefined;
|
||||
export type CreateServiceAccountRoleMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create service account role
|
||||
*/
|
||||
export const useCreateServiceAccountRole = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
TError,
|
||||
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
TError,
|
||||
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateServiceAccountRoleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint revokes a role from a service account
|
||||
* @summary Delete service account role
|
||||
*/
|
||||
export const deleteServiceAccountRole = (
|
||||
{ id }: DeleteServiceAccountRolePathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/service_account_roles/${id}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteServiceAccountRoleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteServiceAccountRole'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteServiceAccountRole(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteServiceAccountRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>
|
||||
>;
|
||||
|
||||
export type DeleteServiceAccountRoleMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Delete service account role
|
||||
*/
|
||||
export const useDeleteServiceAccountRole = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getDeleteServiceAccountRoleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint gets an existing service account role
|
||||
* @summary Get service account role
|
||||
*/
|
||||
export const getServiceAccountRole = (
|
||||
{ id }: GetServiceAccountRolePathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetServiceAccountRole200>({
|
||||
url: `/api/v1/service_account_roles/${id}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetServiceAccountRoleQueryKey = ({
|
||||
id,
|
||||
}: GetServiceAccountRolePathParameters) => {
|
||||
return [`/api/v1/service_account_roles/${id}`] as const;
|
||||
};
|
||||
|
||||
export const getGetServiceAccountRoleQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getServiceAccountRole>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetServiceAccountRolePathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getServiceAccountRole>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetServiceAccountRoleQueryKey({ id });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getServiceAccountRole>>
|
||||
> = ({ signal }) => getServiceAccountRole({ id }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getServiceAccountRole>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetServiceAccountRoleQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getServiceAccountRole>>
|
||||
>;
|
||||
export type GetServiceAccountRoleQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get service account role
|
||||
*/
|
||||
|
||||
export function useGetServiceAccountRole<
|
||||
TData = Awaited<ReturnType<typeof getServiceAccountRole>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetServiceAccountRolePathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getServiceAccountRole>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetServiceAccountRoleQueryOptions({ id }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get service account role
|
||||
*/
|
||||
export const invalidateGetServiceAccountRole = async (
|
||||
queryClient: QueryClient,
|
||||
{ id }: GetServiceAccountRolePathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetServiceAccountRoleQueryKey({ id }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint lists the service accounts for an organisation
|
||||
* @summary List service accounts
|
||||
@@ -984,45 +1255,46 @@ export const invalidateGetServiceAccountRoles = async (
|
||||
|
||||
/**
|
||||
* This endpoint assigns a role to a service account
|
||||
* @deprecated
|
||||
* @summary Create service account role
|
||||
*/
|
||||
export const createServiceAccountRole = (
|
||||
{ id }: CreateServiceAccountRolePathParameters,
|
||||
serviceaccounttypesPostableServiceAccountRoleDTO?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>,
|
||||
export const createServiceAccountRoleDeprecated = (
|
||||
{ id }: CreateServiceAccountRoleDeprecatedPathParameters,
|
||||
serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateServiceAccountRole201>({
|
||||
return GeneratedAPIInstance<CreateServiceAccountRoleDeprecated201>({
|
||||
url: `/api/v1/service_accounts/${id}/roles`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: serviceaccounttypesPostableServiceAccountRoleDTO,
|
||||
data: serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateServiceAccountRoleMutationOptions = <
|
||||
export const getCreateServiceAccountRoleDeprecatedMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createServiceAccountRole'];
|
||||
const mutationKey = ['createServiceAccountRoleDeprecated'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
@@ -1032,62 +1304,66 @@ export const getCreateServiceAccountRoleMutationOptions = <
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
|
||||
{
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return createServiceAccountRole(pathParams, data);
|
||||
return createServiceAccountRoleDeprecated(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateServiceAccountRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>
|
||||
export type CreateServiceAccountRoleDeprecatedMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>
|
||||
>;
|
||||
export type CreateServiceAccountRoleMutationBody =
|
||||
| BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>
|
||||
export type CreateServiceAccountRoleDeprecatedMutationBody =
|
||||
| BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>
|
||||
| undefined;
|
||||
export type CreateServiceAccountRoleMutationError =
|
||||
export type CreateServiceAccountRoleDeprecatedMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Create service account role
|
||||
*/
|
||||
export const useCreateServiceAccountRole = <
|
||||
export const useCreateServiceAccountRoleDeprecated = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateServiceAccountRoleMutationOptions(options));
|
||||
return useMutation(
|
||||
getCreateServiceAccountRoleDeprecatedMutationOptions(options),
|
||||
);
|
||||
};
|
||||
/**
|
||||
* This endpoint revokes a role from service account
|
||||
* @deprecated
|
||||
* @summary Delete service account role
|
||||
*/
|
||||
export const deleteServiceAccountRole = (
|
||||
{ id, rid }: DeleteServiceAccountRolePathParameters,
|
||||
export const deleteServiceAccountRoleDeprecated = (
|
||||
{ id, rid }: DeleteServiceAccountRoleDeprecatedPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
@@ -1097,23 +1373,23 @@ export const deleteServiceAccountRole = (
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteServiceAccountRoleMutationOptions = <
|
||||
export const getDeleteServiceAccountRoleDeprecatedMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteServiceAccountRole'];
|
||||
const mutationKey = ['deleteServiceAccountRoleDeprecated'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
@@ -1123,44 +1399,47 @@ export const getDeleteServiceAccountRoleMutationOptions = <
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters }
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteServiceAccountRole(pathParams);
|
||||
return deleteServiceAccountRoleDeprecated(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteServiceAccountRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>
|
||||
export type DeleteServiceAccountRoleDeprecatedMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>
|
||||
>;
|
||||
|
||||
export type DeleteServiceAccountRoleMutationError =
|
||||
export type DeleteServiceAccountRoleDeprecatedMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Delete service account role
|
||||
*/
|
||||
export const useDeleteServiceAccountRole = <
|
||||
export const useDeleteServiceAccountRoleDeprecated = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getDeleteServiceAccountRoleMutationOptions(options));
|
||||
return useMutation(
|
||||
getDeleteServiceAccountRoleDeprecatedMutationOptions(options),
|
||||
);
|
||||
};
|
||||
/**
|
||||
* This endpoint gets my service account
|
||||
|
||||
@@ -2048,6 +2048,13 @@ export interface AuthtypesAuthNSupportDTO {
|
||||
password?: AuthtypesPasswordAuthNSupportDTO[] | null;
|
||||
}
|
||||
|
||||
export interface AuthtypesDeprecatedPostableUserRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesGettableAuthDomainDTO {
|
||||
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
|
||||
config?: AuthtypesAuthDomainConfigDTO;
|
||||
@@ -2287,13 +2294,6 @@ export interface AuthtypesPostableRotateTokenDTO {
|
||||
refreshToken?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableUserRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableUserDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -2310,7 +2310,18 @@ export interface AuthtypesPostableUserDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
userRoles?: AuthtypesPostableUserRoleDTO[];
|
||||
userRoles?: AuthtypesDeprecatedPostableUserRoleDTO[];
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableUserRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
roleId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesRoleDTO {
|
||||
@@ -8508,6 +8519,13 @@ export interface RuletypesRuleDTO {
|
||||
export enum RuletypesThresholdKindDTO {
|
||||
basic = 'basic',
|
||||
}
|
||||
export interface ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -8577,7 +8595,11 @@ export interface ServiceaccounttypesPostableServiceAccountRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
roleId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
serviceAccountId: string;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesServiceAccountDTO {
|
||||
@@ -8847,6 +8869,44 @@ export interface SpantypesGettableSpanMapperGroupsDTO {
|
||||
items: SpantypesSpanMapperGroupDTO[];
|
||||
}
|
||||
|
||||
export type SpantypesSpanMapperTestSpanDTOAttributesAnyOf = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type SpantypesSpanMapperTestSpanDTOAttributes =
|
||||
SpantypesSpanMapperTestSpanDTOAttributesAnyOf | null;
|
||||
|
||||
export type SpantypesSpanMapperTestSpanDTOResourceAnyOf = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type SpantypesSpanMapperTestSpanDTOResource =
|
||||
SpantypesSpanMapperTestSpanDTOResourceAnyOf | null;
|
||||
|
||||
export interface SpantypesSpanMapperTestSpanDTO {
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
attributes?: SpantypesSpanMapperTestSpanDTOAttributes;
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
resource?: SpantypesSpanMapperTestSpanDTOResource;
|
||||
}
|
||||
|
||||
export interface SpantypesGettableSpanMapperTestDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
spans: SpantypesSpanMapperTestSpanDTO[] | null;
|
||||
}
|
||||
|
||||
export enum SpantypesSpanAggregationTypeDTO {
|
||||
span_count = 'span_count',
|
||||
execution_time_percentage = 'execution_time_percentage',
|
||||
@@ -9142,6 +9202,33 @@ export interface SpantypesPostableSpanMapperGroupDTO {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SpantypesPostableSpanMapperTestGroupDTO {
|
||||
condition: SpantypesSpanMapperGroupConditionDTO | null;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
mappers?: SpantypesPostableSpanMapperDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SpantypesPostableSpanMapperTestDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
groups: SpantypesPostableSpanMapperTestGroupDTO[] | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
spans: SpantypesSpanMapperTestSpanDTO[] | null;
|
||||
}
|
||||
|
||||
export interface SpantypesSpanAggregationDTO {
|
||||
aggregation: SpantypesSpanAggregationTypeDTO;
|
||||
field: TelemetrytypesTelemetryFieldKeyDTO;
|
||||
@@ -10343,6 +10430,28 @@ export type UpdateRoutePolicy200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateServiceAccountRole201 = {
|
||||
data: TypesIdentifiableDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteServiceAccountRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetServiceAccountRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetServiceAccountRole200 = {
|
||||
data: ServiceaccounttypesServiceAccountRoleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListServiceAccounts200 = {
|
||||
/**
|
||||
* @type array
|
||||
@@ -10426,10 +10535,10 @@ export type GetServiceAccountRoles200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateServiceAccountRolePathParameters = {
|
||||
export type CreateServiceAccountRoleDeprecatedPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type CreateServiceAccountRole201 = {
|
||||
export type CreateServiceAccountRoleDeprecated201 = {
|
||||
data: TypesIdentifiableDTO;
|
||||
/**
|
||||
* @type string
|
||||
@@ -10437,7 +10546,7 @@ export type CreateServiceAccountRole201 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteServiceAccountRolePathParameters = {
|
||||
export type DeleteServiceAccountRoleDeprecatedPathParameters = {
|
||||
id: string;
|
||||
rid: string;
|
||||
};
|
||||
@@ -10509,6 +10618,14 @@ export type UpdateSpanMapperPathParameters = {
|
||||
groupId: string;
|
||||
mapperId: string;
|
||||
};
|
||||
export type TestSpanMappers200 = {
|
||||
data: SpantypesGettableSpanMapperTestDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetStats200Data = { [key: string]: unknown };
|
||||
|
||||
export type GetStats200 = {
|
||||
@@ -11597,6 +11714,28 @@ export type RotateSession200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateUserRole201 = {
|
||||
data: TypesIdentifiableDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteUserRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetUserRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetUserRole200 = {
|
||||
data: AuthtypesUserRoleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListUsers200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -30,8 +30,10 @@ import type {
|
||||
RenderErrorResponseDTO,
|
||||
SpantypesPostableSpanMapperDTO,
|
||||
SpantypesPostableSpanMapperGroupDTO,
|
||||
SpantypesPostableSpanMapperTestDTO,
|
||||
SpantypesUpdatableSpanMapperDTO,
|
||||
SpantypesUpdatableSpanMapperGroupDTO,
|
||||
TestSpanMappers200,
|
||||
UpdateSpanMapperGroupPathParameters,
|
||||
UpdateSpanMapperPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -780,3 +782,86 @@ export const useUpdateSpanMapper = <
|
||||
> => {
|
||||
return useMutation(getUpdateSpanMapperMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Tests how span mappers would transform sample spans
|
||||
* @summary Test span mappers against sample spans
|
||||
*/
|
||||
export const testSpanMappers = (
|
||||
spantypesPostableSpanMapperTestDTO?: BodyType<SpantypesPostableSpanMapperTestDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<TestSpanMappers200>({
|
||||
url: `/api/v1/span_mapper_groups/test`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: spantypesPostableSpanMapperTestDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getTestSpanMappersMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['testSpanMappers'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return testSpanMappers(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type TestSpanMappersMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>
|
||||
>;
|
||||
export type TestSpanMappersMutationBody =
|
||||
| BodyType<SpantypesPostableSpanMapperTestDTO>
|
||||
| undefined;
|
||||
export type TestSpanMappersMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Test span mappers against sample spans
|
||||
*/
|
||||
export const useTestSpanMappers = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getTestSpanMappersMutationOptions(options));
|
||||
};
|
||||
|
||||
@@ -19,12 +19,15 @@ import type {
|
||||
|
||||
import type {
|
||||
AuthtypesPostableUserDTO,
|
||||
AuthtypesPostableUserRoleDTO,
|
||||
CreateInvite201,
|
||||
CreateResetPasswordToken201,
|
||||
CreateResetPasswordTokenPathParameters,
|
||||
CreateUser201,
|
||||
CreateUserRole201,
|
||||
DeleteUserDeprecatedPathParameters,
|
||||
DeleteUserPathParameters,
|
||||
DeleteUserRolePathParameters,
|
||||
GetMyUser200,
|
||||
GetMyUserDeprecated200,
|
||||
GetResetPasswordToken200,
|
||||
@@ -37,6 +40,8 @@ import type {
|
||||
GetUserDeprecated200,
|
||||
GetUserDeprecatedPathParameters,
|
||||
GetUserPathParameters,
|
||||
GetUserRole200,
|
||||
GetUserRolePathParameters,
|
||||
GetUsersByRoleID200,
|
||||
GetUsersByRoleIDPathParameters,
|
||||
ListUsers200,
|
||||
@@ -1154,6 +1159,267 @@ export const invalidateGetUsersByRoleID = async (
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint assigns a role to a user
|
||||
* @summary Create user role
|
||||
*/
|
||||
export const createUserRole = (
|
||||
authtypesPostableUserRoleDTO?: BodyType<AuthtypesPostableUserRoleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateUserRole201>({
|
||||
url: `/api/v2/user_roles`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: authtypesPostableUserRoleDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateUserRoleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createUserRole>>,
|
||||
TError,
|
||||
{ data?: BodyType<AuthtypesPostableUserRoleDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createUserRole>>,
|
||||
TError,
|
||||
{ data?: BodyType<AuthtypesPostableUserRoleDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createUserRole'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createUserRole>>,
|
||||
{ data?: BodyType<AuthtypesPostableUserRoleDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createUserRole(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateUserRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createUserRole>>
|
||||
>;
|
||||
export type CreateUserRoleMutationBody =
|
||||
| BodyType<AuthtypesPostableUserRoleDTO>
|
||||
| undefined;
|
||||
export type CreateUserRoleMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create user role
|
||||
*/
|
||||
export const useCreateUserRole = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createUserRole>>,
|
||||
TError,
|
||||
{ data?: BodyType<AuthtypesPostableUserRoleDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createUserRole>>,
|
||||
TError,
|
||||
{ data?: BodyType<AuthtypesPostableUserRoleDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateUserRoleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint revokes a role from a user
|
||||
* @summary Delete user role
|
||||
*/
|
||||
export const deleteUserRole = (
|
||||
{ id }: DeleteUserRolePathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/user_roles/${id}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteUserRoleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteUserRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteUserRolePathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteUserRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteUserRolePathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteUserRole'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof deleteUserRole>>,
|
||||
{ pathParams: DeleteUserRolePathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteUserRole(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteUserRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteUserRole>>
|
||||
>;
|
||||
|
||||
export type DeleteUserRoleMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Delete user role
|
||||
*/
|
||||
export const useDeleteUserRole = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteUserRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteUserRolePathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteUserRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteUserRolePathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getDeleteUserRoleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint gets an existing user role
|
||||
* @summary Get user role
|
||||
*/
|
||||
export const getUserRole = (
|
||||
{ id }: GetUserRolePathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetUserRole200>({
|
||||
url: `/api/v2/user_roles/${id}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetUserRoleQueryKey = ({ id }: GetUserRolePathParameters) => {
|
||||
return [`/api/v2/user_roles/${id}`] as const;
|
||||
};
|
||||
|
||||
export const getGetUserRoleQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getUserRole>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetUserRolePathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getUserRole>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetUserRoleQueryKey({ id });
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getUserRole>>> = ({
|
||||
signal,
|
||||
}) => getUserRole({ id }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getUserRole>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetUserRoleQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getUserRole>>
|
||||
>;
|
||||
export type GetUserRoleQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get user role
|
||||
*/
|
||||
|
||||
export function useGetUserRole<
|
||||
TData = Awaited<ReturnType<typeof getUserRole>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetUserRolePathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getUserRole>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetUserRoleQueryOptions({ id }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get user role
|
||||
*/
|
||||
export const invalidateGetUserRole = async (
|
||||
queryClient: QueryClient,
|
||||
{ id }: GetUserRolePathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetUserRoleQueryKey({ id }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint lists all users for the organization
|
||||
* @summary List users v2
|
||||
@@ -1865,6 +2131,7 @@ export const invalidateGetRolesByUserID = async (
|
||||
|
||||
/**
|
||||
* This endpoint assigns the role to the user roles by user id
|
||||
* @deprecated
|
||||
* @summary Set user roles
|
||||
*/
|
||||
export const setRoleByUserID = (
|
||||
@@ -1936,6 +2203,7 @@ export type SetRoleByUserIDMutationBody =
|
||||
export type SetRoleByUserIDMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Set user roles
|
||||
*/
|
||||
export const useSetRoleByUserID = <
|
||||
@@ -1964,6 +2232,7 @@ export const useSetRoleByUserID = <
|
||||
};
|
||||
/**
|
||||
* This endpoint removes a role from the user by user id and role id
|
||||
* @deprecated
|
||||
* @summary Remove a role from user
|
||||
*/
|
||||
export const removeUserRoleByUserIDAndRoleID = (
|
||||
@@ -2022,6 +2291,7 @@ export type RemoveUserRoleByUserIDAndRoleIDMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Remove a role from user
|
||||
*/
|
||||
export const useRemoveUserRoleByUserIDAndRoleID = <
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Button, Popover, Tooltip } from 'antd';
|
||||
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { useExportRawData } from 'hooks/useExportData/useServerExport';
|
||||
import { useExportRawData } from 'hooks/useDownloadOptionsMenu/useDownloadOptionsMenu';
|
||||
import { Download, LoaderCircle } from '@signozhq/icons';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
.export-menu-popover {
|
||||
.ant-popover-inner {
|
||||
border-radius: 4px;
|
||||
background-color: var(--l2-background);
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
padding: 0 8px 12px 8px;
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.export-options-container {
|
||||
width: 240px;
|
||||
border-radius: 4px;
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
letter-spacing: 0.88px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.export-format {
|
||||
padding: 12px 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
:global(.ant-radio-wrapper) {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.export-button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { Download, LoaderCircle } from '@signozhq/icons';
|
||||
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Button, Popover, Tooltip } from 'antd';
|
||||
import {
|
||||
ClientExportData,
|
||||
useClientExport,
|
||||
} from 'hooks/useExportData/useClientExport';
|
||||
import { ExportFormat } from 'lib/exportData/types';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import './ExportMenu.styles.scss';
|
||||
|
||||
interface ExportMenuProps {
|
||||
dataSource: DataSource;
|
||||
// The queryRange response object the view holds — the hook picks the
|
||||
// serializer (timeseries / table) from what it carries.
|
||||
data: ClientExportData;
|
||||
query?: Query;
|
||||
yAxisUnit?: string;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
// Download menu for in-memory query results (client-side serialization).
|
||||
// The raw/list backend export keeps its own menu in DownloadOptionsMenu.
|
||||
export default function ExportMenu({
|
||||
dataSource,
|
||||
data,
|
||||
query,
|
||||
yAxisUnit,
|
||||
fileName,
|
||||
}: ExportMenuProps): JSX.Element {
|
||||
const [exportFormat, setExportFormat] = useState<string>(ExportFormat.Csv);
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||
|
||||
const { isExporting, handleExport: handleClientExport } = useClientExport({
|
||||
data,
|
||||
query,
|
||||
yAxisUnit,
|
||||
fileName,
|
||||
});
|
||||
|
||||
const handleExport = useCallback((): void => {
|
||||
setIsPopoverOpen(false);
|
||||
handleClientExport({ format: exportFormat as ExportFormat });
|
||||
}, [exportFormat, handleClientExport]);
|
||||
|
||||
const popoverContent = useMemo(
|
||||
() => (
|
||||
<div
|
||||
className="export-options-container"
|
||||
role="dialog"
|
||||
aria-label="Export options"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="export-format">
|
||||
<Typography.Text className="title">FORMAT</Typography.Text>
|
||||
<RadioGroup value={exportFormat} onChange={setExportFormat}>
|
||||
<RadioGroupItem value={ExportFormat.Csv}>csv</RadioGroupItem>
|
||||
<RadioGroupItem value={ExportFormat.Jsonl}>jsonl</RadioGroupItem>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Download size={16} />}
|
||||
onClick={handleExport}
|
||||
className="export-button"
|
||||
disabled={isExporting}
|
||||
loading={isExporting}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
[exportFormat, isExporting, handleExport],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
content={popoverContent}
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
arrow={false}
|
||||
open={isPopoverOpen}
|
||||
onOpenChange={setIsPopoverOpen}
|
||||
rootClassName="export-menu-popover"
|
||||
>
|
||||
<Tooltip title="Download" placement="top">
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
icon={
|
||||
isExporting ? (
|
||||
<LoaderCircle size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Download size={14} />
|
||||
)
|
||||
}
|
||||
data-testid={`export-menu-${dataSource}`}
|
||||
disabled={isExporting}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import ExportMenu from '../ExportMenu';
|
||||
|
||||
const mockHandleExport = jest.fn();
|
||||
let mockIsExporting = false;
|
||||
|
||||
jest.mock('hooks/useExportData/useClientExport', () => ({
|
||||
useClientExport: (): unknown => ({
|
||||
isExporting: mockIsExporting,
|
||||
handleExport: mockHandleExport,
|
||||
}),
|
||||
}));
|
||||
|
||||
const data = {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: '',
|
||||
payload: { data: { result: [], resultType: 'time_series' } },
|
||||
} as unknown as MetricQueryRangeSuccessResponse;
|
||||
|
||||
const TEST_ID = `export-menu-${DataSource.LOGS}`;
|
||||
|
||||
function renderMenu(): void {
|
||||
render(
|
||||
<ExportMenu
|
||||
dataSource={DataSource.LOGS}
|
||||
data={data}
|
||||
fileName="logs-timeseries"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ExportMenu', () => {
|
||||
beforeEach(() => {
|
||||
mockHandleExport.mockReset();
|
||||
mockIsExporting = false;
|
||||
});
|
||||
|
||||
it('renders the download trigger button', () => {
|
||||
renderMenu();
|
||||
expect(screen.getByTestId(TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows only format options — no shape, row-count, or column controls', () => {
|
||||
renderMenu();
|
||||
fireEvent.click(screen.getByTestId(TEST_ID));
|
||||
|
||||
expect(screen.getByText('FORMAT')).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: 'csv' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: 'jsonl' })).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByText('Number of Rows')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Columns')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('radio', { name: 'long' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('radio', { name: 'wide' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exports as csv by default', () => {
|
||||
renderMenu();
|
||||
fireEvent.click(screen.getByTestId(TEST_ID));
|
||||
fireEvent.click(screen.getByText('Export'));
|
||||
|
||||
expect(mockHandleExport).toHaveBeenCalledTimes(1);
|
||||
expect(mockHandleExport).toHaveBeenCalledWith({ format: 'csv' });
|
||||
});
|
||||
|
||||
it('exports as jsonl when selected', () => {
|
||||
renderMenu();
|
||||
fireEvent.click(screen.getByTestId(TEST_ID));
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'jsonl' }));
|
||||
fireEvent.click(screen.getByText('Export'));
|
||||
|
||||
expect(mockHandleExport).toHaveBeenCalledWith({ format: 'jsonl' });
|
||||
});
|
||||
|
||||
it('disables the trigger while an export is in progress', () => {
|
||||
mockIsExporting = true;
|
||||
renderMenu();
|
||||
|
||||
expect(screen.getByTestId(TEST_ID)).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ComponentType } from 'react';
|
||||
import { TabsProps } from 'antd';
|
||||
import { History } from 'history';
|
||||
|
||||
export type TabRoutes = {
|
||||
name: React.ReactNode;
|
||||
route: string;
|
||||
Component: () => JSX.Element;
|
||||
Component: ComponentType;
|
||||
key: string;
|
||||
};
|
||||
|
||||
|
||||
34
frontend/src/components/TagBadge/TagBadge.tsx
Normal file
34
frontend/src/components/TagBadge/TagBadge.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { type MouseEvent, type ReactNode } from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
|
||||
interface TagBadgeProps {
|
||||
children: ReactNode;
|
||||
// Show a remove button (editable contexts: create modal, settings drawer).
|
||||
closable?: boolean;
|
||||
onClose?: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// The single sienna tag chip used everywhere dashboards render tags — list rows,
|
||||
// the details header, and the tag editors. Kept as one component so the tag
|
||||
// styling stays identical across all of them.
|
||||
function TagBadge({
|
||||
children,
|
||||
closable,
|
||||
onClose,
|
||||
className,
|
||||
}: TagBadgeProps): JSX.Element {
|
||||
return (
|
||||
<Badge
|
||||
color="sienna"
|
||||
variant="outline"
|
||||
className={className}
|
||||
closable={closable}
|
||||
onClose={onClose}
|
||||
>
|
||||
{children}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export default TagBadge;
|
||||
@@ -15,59 +15,28 @@
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
// Sienna chip — matches the dashboard list-row tag badge.
|
||||
// Sienna chip via the @signozhq Badge; this only constrains its width.
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 240px;
|
||||
height: 24px;
|
||||
padding: 2px 4px 2px 8px;
|
||||
border-radius: 50px;
|
||||
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent);
|
||||
color: var(--bg-sienna-400);
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 20px;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
// The tag label is a bare, chrome-less button inside the Badge (double-click to
|
||||
// edit); strip its Button styling and let it ellipsize.
|
||||
.tagLabel {
|
||||
--button-height: auto;
|
||||
--button-padding: 0;
|
||||
--button-gap: 0;
|
||||
--button-variant-ghost-background-color: transparent;
|
||||
--button-variant-ghost-hover-background-color: transparent;
|
||||
--button-variant-ghost-color: inherit;
|
||||
--button-variant-ghost-hover-color: inherit;
|
||||
--button-variant-ghost-color: currentColor;
|
||||
--button-variant-ghost-hover-color: currentColor;
|
||||
overflow: hidden;
|
||||
max-width: 200px;
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.remove {
|
||||
// Size overrides to fit the chip, plus a sienna-tinted hover — the Button's
|
||||
// default ghost hover is a grey that clashes with the chip. Resting color is
|
||||
// left at the Button default.
|
||||
--button-height: 16px;
|
||||
--button-padding: 0;
|
||||
--button-border-radius: 50%;
|
||||
--button-variant-ghost-hover-background-color: color-mix(
|
||||
in srgb,
|
||||
var(--bg-sienna-500) 22%,
|
||||
transparent
|
||||
);
|
||||
--button-variant-ghost-hover-color: var(--bg-sienna-400);
|
||||
width: 16px;
|
||||
min-width: 16px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
|
||||
@@ -2,9 +2,9 @@ import { type ChangeEvent, type KeyboardEvent, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { X } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import TagBadge from '../TagBadge/TagBadge';
|
||||
import { parseKeyValueTag } from './utils';
|
||||
|
||||
import styles from './TagKeyValueInput.module.scss';
|
||||
@@ -120,27 +120,23 @@ function TagKeyValueInput({
|
||||
onBlur={commitEdit}
|
||||
/>
|
||||
) : (
|
||||
<div key={tag} className={styles.tag} data-testid={`${testId}-chip`}>
|
||||
<TagBadge
|
||||
key={tag}
|
||||
className={styles.tag}
|
||||
closable
|
||||
onClose={(): void => removeTag(tag)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.tagLabel}
|
||||
title="Double-click to edit"
|
||||
testId={`${testId}-chip`}
|
||||
onDoubleClick={(): void => startEdit(index)}
|
||||
>
|
||||
{tag}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.remove}
|
||||
aria-label={`Remove ${tag}`}
|
||||
onClick={(): void => removeTag(tag)}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
</TagBadge>
|
||||
),
|
||||
)}
|
||||
<Input
|
||||
|
||||
@@ -89,8 +89,10 @@ const ROUTES = {
|
||||
AI_ASSISTANT_BASE: '/ai-assistant',
|
||||
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
|
||||
MCP_SERVER: '/settings/mcp-server',
|
||||
LLM_OBSERVABILITY_ATTRIBUTE_MAPPING: '/llm-observability/attribute-mapping',
|
||||
LLM_OBSERVABILITY_BASE: '/llm-observability',
|
||||
LLM_OBSERVABILITY_MODEL_PRICING: '/llm-observability/settings/model-pricing',
|
||||
LLM_OBSERVABILITY_OVERVIEW: '/llm-observability/overview',
|
||||
LLM_OBSERVABILITY_CONFIGURATION: '/llm-observability/configuration',
|
||||
} as const;
|
||||
|
||||
export default ROUTES;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react';
|
||||
import type { PrecisionOption } from 'components/Graph/types';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
|
||||
@@ -27,7 +28,7 @@ interface PieArcProps {
|
||||
fill: string;
|
||||
onEnter: (slice: PieSlice, centroidX: number, centroidY: number) => void;
|
||||
onLeave: () => void;
|
||||
onClick?: (slice: PieSlice) => void;
|
||||
onClick?: (slice: PieSlice, event: ReactMouseEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,7 +73,7 @@ export default function PieArc({
|
||||
<g
|
||||
onMouseEnter={(): void => onEnter(slice, centroidX, centroidY)}
|
||||
onMouseLeave={onLeave}
|
||||
onClick={(): void => onClick?.(slice)}
|
||||
onClick={(event): void => onClick?.(slice, event)}
|
||||
>
|
||||
<path d={arcPath} fill={fill} />
|
||||
{shouldShowLabel && (
|
||||
|
||||
@@ -80,6 +80,7 @@ describe('PieArc', () => {
|
||||
expect(onLeave).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(g);
|
||||
expect(onClick).toHaveBeenCalledWith(SLICE);
|
||||
// onClick now also receives the DOM event (for drill-down popover positioning).
|
||||
expect(onClick).toHaveBeenCalledWith(SLICE, expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import {
|
||||
@@ -79,6 +80,10 @@ export interface PieSlice {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
/** Source query of the slice's value column — the drill-down target (present for V2 panels). */
|
||||
queryName?: string;
|
||||
/** Group-by key→value of the slice's source row, used to build drill-down filters. */
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,7 +104,7 @@ export interface PieChartProps {
|
||||
* (shared GRAPH_VISIBILITY_STATES, keyed by label). Omit to disable persistence.
|
||||
*/
|
||||
id?: string;
|
||||
/** Fired when a slice (or its legend entry) is clicked. */
|
||||
onSliceClick?: (slice: PieSlice) => void;
|
||||
/** Fired when a slice's arc is clicked; carries the DOM event for popover positioning. */
|
||||
onSliceClick?: (slice: PieSlice, event: ReactMouseEvent) => void;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: auto;
|
||||
flex-direction: column;
|
||||
|
||||
// Stacked children (the FullView / standalone graph-manager) sit below the chart
|
||||
|
||||
@@ -66,20 +66,6 @@
|
||||
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header) {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-content-box) {
|
||||
padding: 0 !important;
|
||||
padding-block: 0 !important;
|
||||
|
||||
:global(.quick-filters .checkbox-filter) {
|
||||
padding-left: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.quick-filters) {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
@@ -111,22 +97,6 @@
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.quickFiltersCategoryLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.quickFiltersCategoryLabelIcon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.quickFiltersCategoryLabelContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.listContainer {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -160,3 +130,106 @@
|
||||
.listContainerFiltersVisible {
|
||||
max-width: calc(100% - 280px);
|
||||
}
|
||||
|
||||
.categorySelectorSection {
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
margin-bottom: var(--spacing-4);
|
||||
|
||||
&[data-type='filter'] {
|
||||
padding: 0 var(--spacing-4);
|
||||
}
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
font-size: var(--periscope-font-size-small);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--l3-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sectionLine {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.categoryCard {
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--spacing-3);
|
||||
padding: var(--spacing-2);
|
||||
}
|
||||
|
||||
.categoryList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.categoryItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-5);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
border: none;
|
||||
border-radius: var(--spacing-2);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
color 0.2s ease;
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
--typography-color: var(--l1-foreground);
|
||||
|
||||
&:hover:not(.categoryItemSelected) {
|
||||
background: var(--l2-background-hover);
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--l3-foreground);
|
||||
flex-shrink: 0;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
&.categoryItemSelected {
|
||||
background: var(--accent-primary);
|
||||
color: var(--l1-background);
|
||||
font-weight: 600;
|
||||
|
||||
&:hover {
|
||||
background: var(--accent-primary-hover, var(--accent-primary));
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quickFiltersSection {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Button, CollapseProps } from 'antd';
|
||||
import { Collapse, Tooltip } from 'antd';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
@@ -113,192 +112,74 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
});
|
||||
};
|
||||
|
||||
const renderCategoryLabel = (
|
||||
icon: JSX.Element,
|
||||
label: string,
|
||||
): JSX.Element => (
|
||||
<div className={styles.quickFiltersCategoryLabel}>
|
||||
<div className={styles.quickFiltersCategoryLabelContainer}>
|
||||
{icon}
|
||||
<Typography.Text>{label}</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
const categories = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: K8sCategories.PODS,
|
||||
label: 'Pods',
|
||||
icon: <Container size={14} />,
|
||||
config: GetPodsQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.NODES,
|
||||
label: 'Nodes',
|
||||
icon: <Workflow size={14} />,
|
||||
config: GetNodesQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.NAMESPACES,
|
||||
label: 'Namespaces',
|
||||
icon: <FilePenLine size={14} />,
|
||||
config: GetNamespaceQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.CLUSTERS,
|
||||
label: 'Clusters',
|
||||
icon: <Boxes size={14} />,
|
||||
config: GetClustersQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.DEPLOYMENTS,
|
||||
label: 'Deployments',
|
||||
icon: <Computer size={14} />,
|
||||
config: GetDeploymentsQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.JOBS,
|
||||
label: 'Jobs',
|
||||
icon: <Bolt size={14} />,
|
||||
config: GetJobsQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.DAEMONSETS,
|
||||
label: 'DaemonSets',
|
||||
icon: <Group size={14} />,
|
||||
config: GetDaemonsetsQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.STATEFULSETS,
|
||||
label: 'StatefulSets',
|
||||
icon: <ArrowUpDown size={14} />,
|
||||
config: GetStatefulsetsQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.VOLUMES,
|
||||
label: 'Volumes',
|
||||
icon: <HardDrive size={14} />,
|
||||
config: GetVolumesQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
],
|
||||
[dotMetricsEnabled],
|
||||
);
|
||||
|
||||
const items: CollapseProps['items'] = [
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Container size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Pods',
|
||||
),
|
||||
key: K8sCategories.PODS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetPodsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Workflow size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Nodes',
|
||||
),
|
||||
key: K8sCategories.NODES,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetNodesQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<FilePenLine size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Namespaces',
|
||||
),
|
||||
key: K8sCategories.NAMESPACES,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetNamespaceQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Boxes size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Clusters',
|
||||
),
|
||||
key: K8sCategories.CLUSTERS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetClustersQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Computer size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Deployments',
|
||||
),
|
||||
key: K8sCategories.DEPLOYMENTS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetDeploymentsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Bolt size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Jobs',
|
||||
),
|
||||
key: K8sCategories.JOBS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetJobsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Group size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'DaemonSets',
|
||||
),
|
||||
key: K8sCategories.DAEMONSETS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetDaemonsetsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<ArrowUpDown size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'StatefulSets',
|
||||
),
|
||||
key: K8sCategories.STATEFULSETS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetStatefulsetsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<HardDrive size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Volumes',
|
||||
),
|
||||
key: K8sCategories.VOLUMES,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetVolumesQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
// TODO: Enable once we have implemented containers.
|
||||
// {
|
||||
// label: (
|
||||
// <div className="k8s-quick-filters-category-label">
|
||||
// <div className="k8s-quick-filters-category-label-container">
|
||||
// <PackageOpen
|
||||
// size={14}
|
||||
// className="k8s-quick-filters-category-label-icon"
|
||||
// />
|
||||
// <Typography.Text>Containers</Typography.Text>
|
||||
// </div>
|
||||
// </div>
|
||||
// ),
|
||||
// key: K8sCategories.CONTAINERS,
|
||||
// showArrow: false,
|
||||
// children: (
|
||||
// <QuickFilters
|
||||
// source={QuickFiltersSource.INFRA_MONITORING}
|
||||
// config={ContainersQuickFiltersConfig}
|
||||
// handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
// onFilterChange={handleFilterChange}
|
||||
// />
|
||||
// ),
|
||||
// },
|
||||
];
|
||||
const selectedCategoryConfig = useMemo(
|
||||
() => categories.find((cat) => cat.key === selectedCategory)?.config,
|
||||
[categories, selectedCategory],
|
||||
);
|
||||
|
||||
const handleCategoryChange = (key: string | string[]): void => {
|
||||
if (Array.isArray(key) && key.length > 0) {
|
||||
setSelectedCategory(key[0] as string);
|
||||
const handleCategorySelect = (key: string): void => {
|
||||
if (key !== selectedCategory) {
|
||||
setSelectedCategory(key);
|
||||
// Reset filters
|
||||
setUrlFilters(null);
|
||||
setOrderBy(null);
|
||||
@@ -332,26 +213,58 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
<div className={styles.infraContentRow}>
|
||||
{showFilters && (
|
||||
<div className={styles.quickFiltersContainer}>
|
||||
<div className={styles.quickFiltersContainerHeader}>
|
||||
<Typography.Text>Filters</Typography.Text>
|
||||
|
||||
<Tooltip title="Collapse Filters">
|
||||
<ArrowUpToLine
|
||||
style={{ transform: 'rotate(270deg)' }}
|
||||
onClick={handleFilterVisibilityChange}
|
||||
size="md"
|
||||
/>
|
||||
</Tooltip>
|
||||
<div className={styles.categorySelectorSection}>
|
||||
<div className={styles.sectionHeader} data-type="resource">
|
||||
<Typography.Text className={styles.sectionLabel}>
|
||||
Viewing · Resource
|
||||
</Typography.Text>
|
||||
<div className={styles.sectionLine} />
|
||||
<Tooltip title="Collapse Filters">
|
||||
<ArrowUpToLine
|
||||
style={{ transform: 'rotate(270deg)' }}
|
||||
onClick={handleFilterVisibilityChange}
|
||||
size="md"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={styles.categoryCard}>
|
||||
<div className={styles.categoryList}>
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.key}
|
||||
type="button"
|
||||
className={`${styles.categoryItem} ${
|
||||
selectedCategory === category.key
|
||||
? styles.categoryItemSelected
|
||||
: ''
|
||||
}`}
|
||||
onClick={(): void => handleCategorySelect(category.key)}
|
||||
data-testid={`category-${category.key}`}
|
||||
>
|
||||
{category.icon}
|
||||
<Typography.Text>{category.label}</Typography.Text>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.quickFiltersSection}>
|
||||
<div className={styles.sectionHeader} data-type="filter">
|
||||
<Typography.Text className={styles.sectionLabel}>
|
||||
Filter by
|
||||
</Typography.Text>
|
||||
<div className={styles.sectionLine} />
|
||||
</div>
|
||||
{selectedCategoryConfig && (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={selectedCategoryConfig}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Collapse
|
||||
onChange={handleCategoryChange}
|
||||
items={items}
|
||||
defaultActiveKey={[selectedCategory]}
|
||||
activeKey={[selectedCategory]}
|
||||
accordion
|
||||
bordered={false}
|
||||
ghost
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
.pageError {
|
||||
padding: var(--padding-3) var(--padding-4);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--callout-error-background);
|
||||
color: var(--callout-error-title);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.pageFooter {
|
||||
margin-top: var(--spacing-8);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { DraftGroup } from '../types';
|
||||
import styles from './AttributeMappingsTab.module.scss';
|
||||
import MappingsTable from './components/MappingsTable';
|
||||
import { AttributeMappingStore } from './hooks/useAttributeMappingStore';
|
||||
|
||||
interface AttributeMappingsTabProps {
|
||||
store: AttributeMappingStore;
|
||||
onEditGroup: (group: DraftGroup) => void;
|
||||
onAddGroup: () => void;
|
||||
}
|
||||
|
||||
// "Attribute mappings" tab: the mapping-groups listing and its error state.
|
||||
// The store is owned by the container (the header's save/discard share it),
|
||||
// so it's passed in rather than created here.
|
||||
function AttributeMappingsTab({
|
||||
store,
|
||||
onEditGroup,
|
||||
onAddGroup,
|
||||
}: AttributeMappingsTabProps): JSX.Element {
|
||||
return (
|
||||
<div data-testid="attribute-mappings-tab">
|
||||
{store.isError ? (
|
||||
<div className={styles.pageError} role="alert">
|
||||
Failed to load mapping groups. Please try again.
|
||||
</div>
|
||||
) : (
|
||||
<MappingsTable
|
||||
store={store}
|
||||
onEditGroup={onEditGroup}
|
||||
onAddGroup={onAddGroup}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AttributeMappingsTab;
|
||||
@@ -0,0 +1,156 @@
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import {
|
||||
GROUPS_ENDPOINT,
|
||||
makeGroupsResponse,
|
||||
makeMapper,
|
||||
makeMappersResponse,
|
||||
mappersEndpoint,
|
||||
mockGroups,
|
||||
mockMappers,
|
||||
} from '../../__tests__/fixtures';
|
||||
import AttributeMappingsTab from '../AttributeMappingsTab';
|
||||
|
||||
import { useAttributeMappingStore } from '../hooks/useAttributeMappingStore';
|
||||
|
||||
function setupGroups(): void {
|
||||
server.use(
|
||||
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeGroupsResponse(mockGroups))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// The tab is a presentational view over a store owned by the container, so the
|
||||
// harness creates the store (via the hook, backed by the mocked API) and wires
|
||||
// the edit/add callbacks the tab needs. This suite only exercises the listing,
|
||||
// so the callbacks are no-ops.
|
||||
function AttributeMappingHarness(): JSX.Element {
|
||||
const store = useAttributeMappingStore();
|
||||
return (
|
||||
<AttributeMappingsTab
|
||||
store={store}
|
||||
onEditGroup={jest.fn()}
|
||||
onAddGroup={jest.fn()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// The real Save button lives in the page header; this harness exposes the same
|
||||
// store.save() path the header wires up, so the tab suite can exercise it.
|
||||
function SaveableHarness(): JSX.Element {
|
||||
const store = useAttributeMappingStore();
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="save-button"
|
||||
onClick={(): void => {
|
||||
void store.save();
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<AttributeMappingsTab
|
||||
store={store}
|
||||
onEditGroup={jest.fn()}
|
||||
onAddGroup={jest.fn()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe('AttributeMappingsTab (integration)', () => {
|
||||
beforeEach(() => {
|
||||
window.history.pushState(null, '', '/');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders no error banner on a successful load', async () => {
|
||||
setupGroups();
|
||||
render(<AttributeMappingHarness />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('group-name-group-1')).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an error banner when the groups request fails', async () => {
|
||||
server.use(
|
||||
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) => res(ctx.status(500))),
|
||||
);
|
||||
render(<AttributeMappingHarness />);
|
||||
|
||||
await expect(screen.findByRole('alert')).resolves.toHaveTextContent(
|
||||
'Failed to load mapping groups. Please try again.',
|
||||
);
|
||||
});
|
||||
|
||||
it("lazily fetches and renders a group's mappers on first expand", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
server.use(
|
||||
rest.get(mappersEndpoint('group-1'), (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeMappersResponse(mockMappers))),
|
||||
),
|
||||
);
|
||||
render(<AttributeMappingHarness />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('group-name-group-1')).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId('mapper-target-mapper-1'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('group-expand-group-1'));
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('mapper-target-mapper-1'),
|
||||
).resolves.toHaveTextContent('gen_ai.request.model');
|
||||
});
|
||||
|
||||
it("re-fetches an open group's mappers on save instead of showing stale rows", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
|
||||
// The mapper list changes server-side between the first open and the save
|
||||
// (e.g. another edit landed, or the save reconciled ids). The open table
|
||||
// must reflect the latest list — not the list cached on first expand.
|
||||
let mappers = mockMappers;
|
||||
server.use(
|
||||
rest.get(mappersEndpoint('group-1'), (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeMappersResponse(mappers))),
|
||||
),
|
||||
);
|
||||
render(<SaveableHarness />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('group-name-group-1')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('group-expand-group-1'));
|
||||
await expect(
|
||||
screen.findByTestId('mapper-target-mapper-1'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
// Server now returns a different mapper for the group.
|
||||
mappers = [makeMapper({ id: 'mapper-2', name: 'gen_ai.response.model' })];
|
||||
|
||||
await user.click(screen.getByTestId('save-button'));
|
||||
|
||||
// Fresh row appears; the stale one is gone. Without removeQueries the table
|
||||
// would keep showing mapper-1 (the hydrate once-guard blocks the update).
|
||||
await expect(
|
||||
screen.findByTestId('mapper-target-mapper-2'),
|
||||
).resolves.toHaveTextContent('gen_ai.response.model');
|
||||
expect(
|
||||
screen.queryByTestId('mapper-target-mapper-1'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useMemo } from 'react';
|
||||
import { EllipsisVertical, Pencil, Trash2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import type { DraftGroup } from 'container/LLMObservability/AttributeMapping/types';
|
||||
|
||||
interface GroupActionsMenuProps {
|
||||
group: DraftGroup;
|
||||
onEdit: (group: DraftGroup) => void;
|
||||
onRemove: (localId: string) => void;
|
||||
}
|
||||
|
||||
// Per-group overflow menu: Edit opens the group drawer, Delete stages the
|
||||
// removal in the working copy (persisted on the page-level save).
|
||||
function GroupActionsMenu({
|
||||
group,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: GroupActionsMenuProps): JSX.Element {
|
||||
const menuItems = useMemo<MenuItem[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'edit',
|
||||
label: 'Edit',
|
||||
icon: <Pencil size={14} />,
|
||||
onClick: (): void => onEdit(group),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
danger: true,
|
||||
icon: <Trash2 size={14} />,
|
||||
onClick: (): void => onRemove(group.localId),
|
||||
},
|
||||
],
|
||||
[onEdit, onRemove, group],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple menu={{ items: menuItems }} align="end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Group actions"
|
||||
testId={`group-actions-${group.localId}`}
|
||||
>
|
||||
<EllipsisVertical size={16} />
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default GroupActionsMenu;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './GroupActionsMenu';
|
||||
@@ -0,0 +1,20 @@
|
||||
.groupHeaderLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.groupName {
|
||||
color: var(--l1-foreground);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.groupCount {
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { DraftGroup } from 'container/LLMObservability/AttributeMapping/types';
|
||||
import styles from './GroupHeader.module.scss';
|
||||
|
||||
interface GroupHeaderProps {
|
||||
group: DraftGroup;
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
// Label content of a group's Collapse header: name plus condition/mapping
|
||||
// counts. The antd header owns the toggle interaction (click + keyboard +
|
||||
// aria-expanded); the testid on this wrapper is the stable click target for
|
||||
// tests, whose clicks bubble up to that header.
|
||||
function GroupHeader({ group, expanded }: GroupHeaderProps): JSX.Element {
|
||||
const mapperCount = group.mappers.length;
|
||||
// Condition keys (attribute + resource) ship with the group up front, so this
|
||||
// count is always trustworthy — shown regardless of expand state.
|
||||
const conditionCount = group.attributes.length + group.resource.length;
|
||||
// Mappers load lazily on first expand, so the count is only trustworthy once
|
||||
// the group has been opened (or already carries staged/loaded mappers). Hiding
|
||||
// it for never-opened groups avoids a misleading "0 mappings".
|
||||
const showCount = expanded || mapperCount > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.groupHeaderLabel}
|
||||
data-testid={`group-expand-${group.localId}`}
|
||||
>
|
||||
<span
|
||||
className={styles.groupName}
|
||||
data-testid={`group-name-${group.localId}`}
|
||||
>
|
||||
{group.name}
|
||||
</span>
|
||||
<span
|
||||
className={styles.groupCount}
|
||||
data-testid={`group-condition-count-${group.localId}`}
|
||||
>
|
||||
· {conditionCount} {conditionCount === 1 ? 'condition' : 'conditions'}
|
||||
</span>
|
||||
{showCount && (
|
||||
<span className={styles.groupCount}>
|
||||
· {mapperCount} {mapperCount === 1 ? 'mapping' : 'mappings'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GroupHeader;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './GroupHeader';
|
||||
@@ -1,7 +1,6 @@
|
||||
.traces-table-view-header {
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
flex-shrink: 0;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
|
||||
import { DraftGroup } from 'container/LLMObservability/AttributeMapping/types';
|
||||
import GroupActionsMenu from '../GroupActionsMenu';
|
||||
import styles from './GroupHeaderActions.module.scss';
|
||||
|
||||
interface GroupHeaderActionsProps {
|
||||
group: DraftGroup;
|
||||
onToggle: (localId: string, enabled: boolean) => void;
|
||||
onEdit: (group: DraftGroup) => void;
|
||||
onRemove: (localId: string) => void;
|
||||
}
|
||||
|
||||
function GroupHeaderActions({
|
||||
group,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: GroupHeaderActionsProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={styles.actions}
|
||||
role="presentation"
|
||||
onClick={(event): void => event.stopPropagation()}
|
||||
onKeyDown={(event): void => event.stopPropagation()}
|
||||
>
|
||||
<Switch
|
||||
value={group.enabled}
|
||||
onChange={(checked): void => onToggle(group.localId, checked)}
|
||||
testId={`group-enabled-${group.localId}`}
|
||||
/>
|
||||
<GroupActionsMenu group={group} onEdit={onEdit} onRemove={onRemove} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GroupHeaderActions;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './GroupHeaderActions';
|
||||
@@ -0,0 +1,23 @@
|
||||
// Must mirror the header table's layout in MappingsTable.module.scss so a
|
||||
// panel's rows align to the shared colgroup (see MappingsColgroup).
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
// ---- per-group state rows (loading / empty / error / add) ----
|
||||
|
||||
.mapperStateRow .stateCell {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.addMapperRow .stateCell {
|
||||
padding-top: var(--spacing-2);
|
||||
padding-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
.stateCell {
|
||||
padding: var(--spacing-4) var(--spacing-6) var(--spacing-4) var(--spacing-12);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { useListSpanMappers } from 'api/generated/services/spanmapper';
|
||||
import { motion, useReducedMotion } from 'motion/react';
|
||||
|
||||
import {
|
||||
DraftGroup,
|
||||
DraftMapper,
|
||||
Mapper,
|
||||
} from 'container/LLMObservability/AttributeMapping/types';
|
||||
import { AttributeMappingStore } from 'container/LLMObservability/AttributeMapping/AttributeMappingsTab/hooks/useAttributeMappingStore';
|
||||
import { COLUMN_COUNT } from '../constants';
|
||||
import MapperRow, { MapperRowSkeleton } from '../MapperRow';
|
||||
import MappingsColgroup from '../MappingsColgroup';
|
||||
import styles from './GroupMappers.module.scss';
|
||||
|
||||
const MAPPER_SKELETON_ROWS = 1;
|
||||
|
||||
// Fade shared by the non-row states (skeleton / error / empty / add-mapping) so
|
||||
// they reveal in step with the mapper rows while the antd Collapse runs its
|
||||
// height animation on expand.
|
||||
const STATE_ROW_MOTION = {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1 },
|
||||
transition: { duration: 0.18, ease: 'easeOut' },
|
||||
} as const;
|
||||
|
||||
interface GroupMappersProps {
|
||||
group: DraftGroup;
|
||||
store: AttributeMappingStore;
|
||||
onAddMapper: (groupLocalId: string) => void;
|
||||
onEditMapper: (groupLocalId: string, mapper: DraftMapper) => void;
|
||||
}
|
||||
|
||||
// A group's Collapse panel body: its mapper rows rendered in a table that
|
||||
// shares the listing's colgroup, so they align to the columns of the header
|
||||
// table above the Collapse. The panel only mounts while its group is expanded
|
||||
// (destroyInactivePanel), so the fetch is lazy by construction — page load is
|
||||
// a single groups request rather than an N+1 fan-out. New (unsaved) groups
|
||||
// have no serverId, so they skip the fetch entirely.
|
||||
function GroupMappers({
|
||||
group,
|
||||
store,
|
||||
onAddMapper,
|
||||
onEditMapper,
|
||||
}: GroupMappersProps): JSX.Element {
|
||||
const { hydrateGroupMappers, removeMapper, toggleMapper } = store;
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const stateRowMotion = prefersReducedMotion
|
||||
? { initial: false as const }
|
||||
: STATE_ROW_MOTION;
|
||||
|
||||
const hasServerId = group.serverId !== null;
|
||||
const { data, isLoading, isError } = useListSpanMappers(
|
||||
{ groupId: group.serverId ?? '' },
|
||||
{ query: { enabled: hasServerId } },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const items = data?.data?.items;
|
||||
if (group.serverId && items) {
|
||||
// The generated schema mis-types this list response with the groups DTO;
|
||||
// the runtime payload is mappers.
|
||||
hydrateGroupMappers(group.serverId, items as unknown as Mapper[]);
|
||||
}
|
||||
}, [group.serverId, data, hydrateGroupMappers]);
|
||||
|
||||
const isLoadingMappers = hasServerId && isLoading;
|
||||
const isErrorMappers = hasServerId && isError;
|
||||
const mapperCount = group.mappers.length;
|
||||
|
||||
const skeletonRows = Array.from({ length: MAPPER_SKELETON_ROWS }).map(
|
||||
(_, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<MapperRowSkeleton key={`mapper-skeleton-${index}`} />
|
||||
),
|
||||
);
|
||||
|
||||
const errorRow = (
|
||||
<motion.tr key="error" className={styles.mapperStateRow} {...stateRowMotion}>
|
||||
<td
|
||||
colSpan={COLUMN_COUNT}
|
||||
className={styles.stateCell}
|
||||
data-testid={`mappers-error-${group.localId}`}
|
||||
>
|
||||
Failed to load mappings. Please try again.
|
||||
</td>
|
||||
</motion.tr>
|
||||
);
|
||||
|
||||
const emptyRow = (
|
||||
<motion.tr key="empty" className={styles.mapperStateRow} {...stateRowMotion}>
|
||||
<td
|
||||
colSpan={COLUMN_COUNT}
|
||||
className={styles.stateCell}
|
||||
data-testid={`mappers-empty-${group.localId}`}
|
||||
>
|
||||
No mappings in this group yet.
|
||||
</td>
|
||||
</motion.tr>
|
||||
);
|
||||
|
||||
const addMapperRow = (
|
||||
<motion.tr
|
||||
key="add-mapper"
|
||||
className={styles.addMapperRow}
|
||||
{...stateRowMotion}
|
||||
>
|
||||
<td colSpan={COLUMN_COUNT} className={styles.stateCell}>
|
||||
<Button
|
||||
variant="link"
|
||||
color="primary"
|
||||
size="sm"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={(): void => onAddMapper(group.localId)}
|
||||
testId={`add-mapper-${group.localId}`}
|
||||
>
|
||||
Add mapping
|
||||
</Button>
|
||||
</td>
|
||||
</motion.tr>
|
||||
);
|
||||
|
||||
const mapperRows = group.mappers.map((mapper, index) => (
|
||||
<MapperRow
|
||||
key={mapper.localId}
|
||||
mapper={mapper}
|
||||
index={index}
|
||||
onEdit={(next): void => onEditMapper(group.localId, next)}
|
||||
onRemove={(localId): void => removeMapper(group.localId, localId)}
|
||||
onToggle={(localId, enabled): void =>
|
||||
toggleMapper(group.localId, localId, enabled)
|
||||
}
|
||||
/>
|
||||
));
|
||||
|
||||
// The add-mapping row trails every non-error state (including loading/empty).
|
||||
let rows: JSX.Element[];
|
||||
if (isErrorMappers) {
|
||||
rows = [errorRow];
|
||||
} else if (isLoadingMappers && mapperCount === 0) {
|
||||
rows = [...skeletonRows, addMapperRow];
|
||||
} else if (mapperCount === 0) {
|
||||
rows = [emptyRow, addMapperRow];
|
||||
} else {
|
||||
rows = [...mapperRows, addMapperRow];
|
||||
}
|
||||
|
||||
return (
|
||||
<table className={styles.table}>
|
||||
<MappingsColgroup />
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
export default GroupMappers;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './GroupMappers';
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useMemo } from 'react';
|
||||
import { EllipsisVertical, Pencil, Trash2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import type { DraftMapper } from 'container/LLMObservability/AttributeMapping/types';
|
||||
|
||||
interface MapperActionsMenuProps {
|
||||
mapper: DraftMapper;
|
||||
onEdit: (mapper: DraftMapper) => void;
|
||||
onRemove: (localId: string) => void;
|
||||
}
|
||||
|
||||
// Per-row overflow menu for a mapping. The enable/disable toggle stays inline in
|
||||
// the row (it's the primary, high-frequency action); the lower-frequency Edit
|
||||
// and Delete actions live behind the kebab to keep the row compact.
|
||||
function MapperActionsMenu({
|
||||
mapper,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: MapperActionsMenuProps): JSX.Element {
|
||||
const menuItems = useMemo<MenuItem[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'edit',
|
||||
label: 'Edit',
|
||||
icon: <Pencil size={14} />,
|
||||
onClick: (): void => onEdit(mapper),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
danger: true,
|
||||
icon: <Trash2 size={14} />,
|
||||
onClick: (): void => onRemove(mapper.localId),
|
||||
},
|
||||
],
|
||||
[onEdit, onRemove, mapper],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple menu={{ items: menuItems }} align="end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Mapping actions"
|
||||
testId={`mapper-actions-${mapper.localId}`}
|
||||
>
|
||||
<EllipsisVertical size={16} />
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapperActionsMenu;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './MapperActionsMenu';
|
||||
@@ -0,0 +1,70 @@
|
||||
.mapperRow {
|
||||
&:hover {
|
||||
background: var(--l2-background-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.cell {
|
||||
padding: var(--spacing-4) var(--spacing-6);
|
||||
vertical-align: middle;
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
// Indent the first cell so mapper rows read as nested under their group.
|
||||
.targetCell {
|
||||
padding-left: var(--spacing-12);
|
||||
}
|
||||
|
||||
// Shorter vertical padding so the loading state reads as a compact placeholder.
|
||||
.skeletonCell {
|
||||
composes: cell;
|
||||
:global(.ant-skeleton-input) {
|
||||
min-height: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
:global(.ant-skeleton-button) {
|
||||
min-height: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.actionsCell {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.sources {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.sourceChip {
|
||||
// Badge's outline/vanilla variant supplies border/background/radius/padding.
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.sourceChipText {
|
||||
display: block;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sourceMore {
|
||||
font-size: var(--font-size-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { SpantypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import { motion, useReducedMotion } from 'motion/react';
|
||||
|
||||
import { DraftMapper } from 'container/LLMObservability/AttributeMapping/types';
|
||||
import MapperActionsMenu from '../MapperActionsMenu';
|
||||
import styles from './MapperRow.module.scss';
|
||||
|
||||
const MAX_VISIBLE_SOURCES = 3;
|
||||
|
||||
// Rows mount when their group's Collapse panel opens, so this entrance runs
|
||||
// alongside antd's height animation as the expand reveal. A small per-row
|
||||
// stagger, capped so a long group doesn't cascade for too long. Collapse
|
||||
// (unmount + antd's height animation) needs no per-row exit.
|
||||
const ROW_TRANSITION = { duration: 0.18, ease: 'easeOut' } as const;
|
||||
const MAX_STAGGERED_ROWS = 6;
|
||||
const STAGGER_STEP = 0.03;
|
||||
|
||||
interface MapperRowProps {
|
||||
mapper: DraftMapper;
|
||||
// Position within the group, used to stagger the expand reveal.
|
||||
index: number;
|
||||
onEdit: (mapper: DraftMapper) => void;
|
||||
onRemove: (localId: string) => void;
|
||||
onToggle: (localId: string, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
// A single mapper row, aligned to the table's shared columns (Target / Sources /
|
||||
// Writes to / Actions). Priority order is positional — top wins — so there's no
|
||||
// sortable affordance here.
|
||||
function MapperRow({
|
||||
mapper,
|
||||
index,
|
||||
onEdit,
|
||||
onRemove,
|
||||
onToggle,
|
||||
}: MapperRowProps): JSX.Element {
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const sources = mapper.sources ?? [];
|
||||
const visibleSources = sources.slice(0, MAX_VISIBLE_SOURCES);
|
||||
const remainingSources = sources.length - visibleSources.length;
|
||||
|
||||
return (
|
||||
<motion.tr
|
||||
className={styles.mapperRow}
|
||||
data-testid={`mapper-row-${mapper.localId}`}
|
||||
initial={prefersReducedMotion ? false : { opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
...ROW_TRANSITION,
|
||||
delay: Math.min(index, MAX_STAGGERED_ROWS) * STAGGER_STEP,
|
||||
}}
|
||||
>
|
||||
<td className={cx(styles.cell, styles.targetCell)}>
|
||||
<Typography.Text
|
||||
weight="semibold"
|
||||
truncate={1}
|
||||
title={mapper.name}
|
||||
data-testid={`mapper-target-${mapper.localId}`}
|
||||
>
|
||||
{mapper.name}
|
||||
</Typography.Text>
|
||||
</td>
|
||||
<td className={styles.cell}>
|
||||
{sources.length === 0 ? (
|
||||
<span
|
||||
className={styles.muted}
|
||||
data-testid={`mapper-sources-${mapper.localId}`}
|
||||
>
|
||||
—
|
||||
</span>
|
||||
) : (
|
||||
<div
|
||||
className={styles.sources}
|
||||
data-testid={`mapper-sources-${mapper.localId}`}
|
||||
>
|
||||
{visibleSources.map((source) => (
|
||||
<Badge
|
||||
variant="outline"
|
||||
color="vanilla"
|
||||
className={styles.sourceChip}
|
||||
key={`${source.context}:${source.key}`}
|
||||
>
|
||||
<span className={styles.sourceChipText} title={source.key}>
|
||||
{source.key}
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
{remainingSources > 0 && (
|
||||
<span className={cx(styles.sourceMore, styles.muted)}>
|
||||
+{remainingSources} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className={styles.cell}>
|
||||
<Badge
|
||||
color={
|
||||
mapper.fieldContext === SpantypesFieldContextDTO.resource
|
||||
? 'amber'
|
||||
: 'robin'
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
{mapper.fieldContext}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className={cx(styles.cell, styles.actionsCell)}>
|
||||
<div className={styles.rowActions}>
|
||||
<Switch
|
||||
value={mapper.enabled}
|
||||
onChange={(checked): void => onToggle(mapper.localId, checked)}
|
||||
testId={`mapper-enabled-${mapper.localId}`}
|
||||
/>
|
||||
<MapperActionsMenu mapper={mapper} onEdit={onEdit} onRemove={onRemove} />
|
||||
</div>
|
||||
</td>
|
||||
</motion.tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapperRow;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Skeleton } from 'antd';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './MapperRow.module.scss';
|
||||
|
||||
function MapperRowSkeleton(): JSX.Element {
|
||||
return (
|
||||
<tr className={styles.mapperRow}>
|
||||
<td className={cx(styles.skeletonCell, styles.targetCell)}>
|
||||
<Skeleton.Input active size="small" style={{ width: '55%' }} />
|
||||
</td>
|
||||
<td className={styles.skeletonCell}>
|
||||
<div className={styles.sources}>
|
||||
<Skeleton.Button active size="small" style={{ width: 88 }} />
|
||||
<Skeleton.Button active size="small" style={{ width: 56 }} />
|
||||
</div>
|
||||
</td>
|
||||
<td className={styles.skeletonCell}>
|
||||
<Skeleton.Button active size="small" style={{ width: 72 }} />
|
||||
</td>
|
||||
<td className={cx(styles.skeletonCell, styles.actionsCell)}>
|
||||
<div className={styles.rowActions}>
|
||||
<Skeleton.Button active size="small" shape="round" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapperRowSkeleton;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './MapperRow';
|
||||
export { default as MapperRowSkeleton } from './MapperRowSkeleton';
|
||||
@@ -0,0 +1,11 @@
|
||||
.colTarget {
|
||||
width: 32%;
|
||||
}
|
||||
|
||||
.colWritesTo {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.colActions {
|
||||
width: 176px;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import styles from './MappingsColgroup.module.scss';
|
||||
|
||||
// Shared <colgroup> for every table in the listing: the standalone column
|
||||
// header and each group panel's mappers table. All of them are width-100% +
|
||||
// table-layout: fixed, so identical col widths keep their columns aligned
|
||||
// even though antd Collapse splits them into separate <table> elements.
|
||||
function MappingsColgroup(): JSX.Element {
|
||||
return (
|
||||
<colgroup>
|
||||
<col className={styles.colTarget} />
|
||||
<col />
|
||||
<col className={styles.colWritesTo} />
|
||||
<col className={styles.colActions} />
|
||||
</colgroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default MappingsColgroup;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './MappingsColgroup';
|
||||
@@ -0,0 +1,145 @@
|
||||
.tableWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.headerRow {
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.headerCell {
|
||||
padding: var(--spacing-4) var(--spacing-6);
|
||||
text-align: left;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--l2-foreground);
|
||||
|
||||
&:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- group accordion (antd Collapse) ----
|
||||
|
||||
// Restyle antd Collapse to the listing's banner-row look. antd v5's component
|
||||
// styles use selectors like `.ant-collapse > .ant-collapse-item >
|
||||
// .ant-collapse-header` (specificity 0-3-0), so these overrides repeat the
|
||||
// same direct-child chain — plus the module class — to out-rank them without
|
||||
// !important. In particular, antd top-aligns header content (flex-start, for
|
||||
// multiline labels); this header is single-line, so icon/label/extra center.
|
||||
.groupsCollapse:global(.ant-collapse) {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
|
||||
> :global(.ant-collapse-item) {
|
||||
border-bottom: none;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
border-radius: 0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
> :global(.ant-collapse-header) {
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
background: var(--l2-background);
|
||||
border-radius: 0;
|
||||
padding: var(--spacing-3) var(--spacing-6);
|
||||
color: var(--l3-foreground);
|
||||
|
||||
:global(.ant-collapse-expand-icon) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: auto;
|
||||
padding-inline-end: 0;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header-text) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-extra) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(.ant-collapse-content) {
|
||||
background: transparent;
|
||||
border-top: none;
|
||||
color: inherit;
|
||||
|
||||
> :global(.ant-collapse-content-box) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tableEmpty {
|
||||
padding: var(--spacing-12) var(--spacing-6);
|
||||
text-align: center;
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.skeletonList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
// Mirrors the Collapse header banner while groups load.
|
||||
.skeletonBanner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-3) var(--spacing-6);
|
||||
background: var(--l2-background);
|
||||
border-top: 1px solid var(--l2-border);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
}
|
||||
:global(.ant-skeleton-input) {
|
||||
min-height: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
:global(.ant-skeleton-button) {
|
||||
min-height: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.skeletonGroupLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.skeletonGroupRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { ChevronDown, ChevronRight, Plus } from '@signozhq/icons';
|
||||
import { Collapse, type CollapseProps, Skeleton } from 'antd';
|
||||
|
||||
import MapperFormDrawer from 'container/LLMObservability/AttributeMapping/components/MapperFormDrawer';
|
||||
import { useMapperFormDrawer } from 'container/LLMObservability/AttributeMapping/useMapperFormDrawer';
|
||||
import {
|
||||
DraftGroup,
|
||||
DraftMapper,
|
||||
} from 'container/LLMObservability/AttributeMapping/types';
|
||||
import { AttributeMappingStore } from 'container/LLMObservability/AttributeMapping/AttributeMappingsTab/hooks/useAttributeMappingStore';
|
||||
import GroupHeader from './GroupHeader';
|
||||
import GroupHeaderActions from './GroupHeaderActions';
|
||||
import GroupMappers from './GroupMappers';
|
||||
import MappingsColgroup from './MappingsColgroup';
|
||||
import styles from './MappingsTable.module.scss';
|
||||
|
||||
const SKELETON_ROW_COUNT = 3;
|
||||
|
||||
interface MappingsTableProps {
|
||||
store: AttributeMappingStore;
|
||||
onEditGroup: (group: DraftGroup) => void;
|
||||
onAddGroup: () => void;
|
||||
}
|
||||
|
||||
// Single, GitHub-style listing: one shared column header, groups as collapsible
|
||||
// full-width banner rows, and each group's mappers rendered as rows aligned to
|
||||
// the shared columns. The accordion itself is antd Collapse (controlled
|
||||
// activeKey, one item per group); the column alignment across its panels comes
|
||||
// from every panel body being a fixed-layout table with the same colgroup as
|
||||
// the header table (see MappingsColgroup). destroyInactivePanel keeps collapse
|
||||
// semantics identical to an unmount: a closed group's rows leave the DOM and
|
||||
// its mappers query disables. The mapper drawer lives here once — a target
|
||||
// group is tracked so a single instance serves add/edit for whichever group
|
||||
// triggered it.
|
||||
function MappingsTable({
|
||||
store,
|
||||
onEditGroup,
|
||||
onAddGroup,
|
||||
}: MappingsTableProps): JSX.Element {
|
||||
const [expandedGroups, setExpandedGroups] = useState<string[]>([]);
|
||||
const [targetGroupId, setTargetGroupId] = useState<string | null>(null);
|
||||
const drawer = useMapperFormDrawer();
|
||||
|
||||
const { upsertMapper, removeMapper } = store;
|
||||
|
||||
const handleAddMapper = useCallback(
|
||||
(groupLocalId: string): void => {
|
||||
setTargetGroupId(groupLocalId);
|
||||
// Keep the group open so the staged row is visible after save.
|
||||
setExpandedGroups((prev) =>
|
||||
prev.includes(groupLocalId) ? prev : [...prev, groupLocalId],
|
||||
);
|
||||
drawer.openForAdd();
|
||||
},
|
||||
[drawer],
|
||||
);
|
||||
|
||||
const handleEditMapper = useCallback(
|
||||
(groupLocalId: string, mapper: DraftMapper): void => {
|
||||
setTargetGroupId(groupLocalId);
|
||||
drawer.openForEdit(mapper);
|
||||
},
|
||||
[drawer],
|
||||
);
|
||||
|
||||
const handleSaveMapper = useCallback((): void => {
|
||||
if (targetGroupId) {
|
||||
upsertMapper(targetGroupId, drawer.draft);
|
||||
}
|
||||
drawer.close();
|
||||
}, [targetGroupId, upsertMapper, drawer]);
|
||||
|
||||
const handleDeleteMapper = useCallback((): void => {
|
||||
if (targetGroupId && drawer.draft.id) {
|
||||
removeMapper(targetGroupId, drawer.draft.id);
|
||||
}
|
||||
drawer.close();
|
||||
}, [targetGroupId, removeMapper, drawer]);
|
||||
|
||||
const isEmpty = !store.isLoading && store.groups.length === 0;
|
||||
|
||||
const items: CollapseProps['items'] = store.groups.map((group) => ({
|
||||
key: group.localId,
|
||||
label: (
|
||||
<GroupHeader
|
||||
group={group}
|
||||
expanded={expandedGroups.includes(group.localId)}
|
||||
/>
|
||||
),
|
||||
extra: (
|
||||
<GroupHeaderActions
|
||||
group={group}
|
||||
onToggle={store.toggleGroup}
|
||||
onEdit={onEditGroup}
|
||||
onRemove={store.removeGroup}
|
||||
/>
|
||||
),
|
||||
children: (
|
||||
<GroupMappers
|
||||
group={group}
|
||||
store={store}
|
||||
onAddMapper={handleAddMapper}
|
||||
onEditMapper={handleEditMapper}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
const skeletonBanners = (
|
||||
<div className={styles.skeletonList}>
|
||||
{Array.from({ length: SKELETON_ROW_COUNT }).map((_, index) => (
|
||||
<div
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={`group-skeleton-${index}`}
|
||||
className={styles.skeletonBanner}
|
||||
>
|
||||
<div className={styles.skeletonGroupLeft}>
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
style={{ width: index % 2 === 0 ? 200 : 140 }}
|
||||
/>
|
||||
<Skeleton.Input active size="small" style={{ width: 64 }} />
|
||||
</div>
|
||||
<div className={styles.skeletonGroupRight}>
|
||||
<Skeleton.Button active size="small" shape="round" />
|
||||
<Skeleton.Avatar active size={16} shape="square" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.tableWrapper}>
|
||||
<div className={styles.toolbar}>
|
||||
<Button
|
||||
variant="link"
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={onAddGroup}
|
||||
testId="add-group-row"
|
||||
disabled={store.isLoading}
|
||||
>
|
||||
Add a new group
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isEmpty ? (
|
||||
<div className={styles.tableEmpty} data-testid="mapper-groups-empty">
|
||||
No mapping groups yet.
|
||||
</div>
|
||||
) : (
|
||||
<div data-testid="mappings-table">
|
||||
<table className={styles.table}>
|
||||
<MappingsColgroup />
|
||||
<thead>
|
||||
<tr className={styles.headerRow}>
|
||||
<th className={styles.headerCell}>Target</th>
|
||||
<th className={styles.headerCell}>Sources</th>
|
||||
<th className={styles.headerCell}>Writes to</th>
|
||||
<th className={styles.headerCell}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
{store.isLoading ? (
|
||||
skeletonBanners
|
||||
) : (
|
||||
<Collapse
|
||||
className={styles.groupsCollapse}
|
||||
activeKey={expandedGroups}
|
||||
onChange={(keys): void =>
|
||||
setExpandedGroups(Array.isArray(keys) ? keys : [keys])
|
||||
}
|
||||
bordered={false}
|
||||
destroyInactivePanel
|
||||
expandIcon={({ isActive }): JSX.Element =>
|
||||
isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />
|
||||
}
|
||||
items={items}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{drawer.isOpen && (
|
||||
<MapperFormDrawer
|
||||
isOpen={drawer.isOpen}
|
||||
mode={drawer.mode}
|
||||
draft={drawer.draft}
|
||||
setDraft={drawer.setDraft}
|
||||
onClose={drawer.close}
|
||||
onSave={handleSaveMapper}
|
||||
onDelete={handleDeleteMapper}
|
||||
isSaving={false}
|
||||
isDeleting={false}
|
||||
saveError={null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MappingsTable;
|
||||
@@ -0,0 +1,4 @@
|
||||
// Shared column count for the single mappings table. Group header rows and
|
||||
// per-group state rows (loading / empty / add) span all columns; keeping the
|
||||
// value in one place stops the header and its full-width rows drifting apart.
|
||||
export const COLUMN_COUNT = 4;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './MappingsTable';
|
||||
@@ -0,0 +1,149 @@
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
|
||||
import {
|
||||
GROUPS_ENDPOINT,
|
||||
makeGroupsResponse,
|
||||
mockGroups,
|
||||
} from '../../../__tests__/fixtures';
|
||||
import { useAttributeMappingStore } from '../useAttributeMappingStore';
|
||||
|
||||
function createWrapper(): ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => React.ReactElement {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return function Wrapper({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function renderStore(): ReturnType<
|
||||
typeof renderHook<ReturnType<typeof useAttributeMappingStore>, unknown>
|
||||
> {
|
||||
return renderHook(() => useAttributeMappingStore(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
}
|
||||
|
||||
describe('useAttributeMappingStore', () => {
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('starts loading with no groups', () => {
|
||||
server.use(
|
||||
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeGroupsResponse(mockGroups))),
|
||||
),
|
||||
);
|
||||
const { result } = renderStore();
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
expect(result.current.groups).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('builds a draft tree from the server groups once loaded', async () => {
|
||||
server.use(
|
||||
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeGroupsResponse(mockGroups))),
|
||||
),
|
||||
);
|
||||
const { result } = renderStore();
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
expect(result.current.groups).toHaveLength(2);
|
||||
expect(result.current.groups[0]).toStrictEqual({
|
||||
localId: 'group-1',
|
||||
serverId: 'group-1',
|
||||
name: 'demo',
|
||||
attributes: ['ai.embeddings'],
|
||||
resource: ['cloud.account.id'],
|
||||
enabled: true,
|
||||
mappers: [],
|
||||
});
|
||||
expect(result.current.isError).toBe(false);
|
||||
});
|
||||
|
||||
it('surfaces isError when the groups request fails', async () => {
|
||||
server.use(
|
||||
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) => res(ctx.status(500))),
|
||||
);
|
||||
const { result } = renderStore();
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
|
||||
expect(result.current.groups).toStrictEqual([]);
|
||||
});
|
||||
|
||||
// Regression guard for the "empty mappers after save" bug: on save the groups
|
||||
// list must be *invalidated* in place (it stays mounted, so no flash), while
|
||||
// each per-group mapper list must be *removed* from the cache — invalidate
|
||||
// alone leaves an expanded group's table stale/empty because react-query keeps
|
||||
// `data` referentially stable when the list is unchanged, so the hydrate effect
|
||||
// never re-fires. This asserts the exact query-client calls and their key
|
||||
// targeting so a revert to invalidate-everything fails here.
|
||||
it('invalidates the groups list and removes cached mapper lists on save', async () => {
|
||||
server.use(
|
||||
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeGroupsResponse(mockGroups))),
|
||||
),
|
||||
);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries');
|
||||
const removeSpy = jest.spyOn(queryClient, 'removeQueries');
|
||||
|
||||
const { result } = renderHook(() => useAttributeMappingStore(), {
|
||||
wrapper: function Wrapper({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Wait until the draft is initialised, otherwise save() no-ops early.
|
||||
await waitFor(() => expect(result.current.groups.length).toBeGreaterThan(0));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
|
||||
const groupsKey = ['/api/v1/span_mapper_groups'];
|
||||
const mappersKey = ['/api/v1/span_mapper_groups/group-1/span_mappers'];
|
||||
|
||||
// Groups list: invalidated, and the predicate matches *only* the list key.
|
||||
expect(invalidateSpy).toHaveBeenCalledTimes(1);
|
||||
const invalidatePredicate = invalidateSpy.mock.calls[0][0] as unknown as {
|
||||
predicate: (query: { queryKey: unknown[] }) => boolean;
|
||||
};
|
||||
expect(invalidatePredicate.predicate({ queryKey: groupsKey })).toBe(true);
|
||||
expect(invalidatePredicate.predicate({ queryKey: mappersKey })).toBe(false);
|
||||
|
||||
// Per-group mapper lists: removed, and the predicate matches *only* the
|
||||
// child mapper keys (never the groups list).
|
||||
expect(removeSpy).toHaveBeenCalledTimes(1);
|
||||
const removeFilters = removeSpy.mock.calls[0][0] as unknown as {
|
||||
predicate: (query: { queryKey: unknown[] }) => boolean;
|
||||
};
|
||||
expect(removeFilters.predicate({ queryKey: mappersKey })).toBe(true);
|
||||
expect(removeFilters.predicate({ queryKey: groupsKey })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,331 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { cloneDeep, isEqual } from 'lodash-es';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import {
|
||||
useCreateSpanMapper,
|
||||
useCreateSpanMapperGroup,
|
||||
useDeleteSpanMapper,
|
||||
useDeleteSpanMapperGroup,
|
||||
useListSpanMapperGroups,
|
||||
useUpdateSpanMapper,
|
||||
useUpdateSpanMapperGroup,
|
||||
} from 'api/generated/services/spanmapper';
|
||||
|
||||
import { persistDraft, SaveMutations } from '../../saveDraft';
|
||||
import {
|
||||
DraftGroup,
|
||||
GroupDraft,
|
||||
Mapper,
|
||||
MapperDraft,
|
||||
MapperGroup,
|
||||
} from '../../types';
|
||||
import {
|
||||
buildDraftGroup,
|
||||
buildDraftMapper,
|
||||
nodeFromGroupDraft,
|
||||
nodeFromMapperDraft,
|
||||
} from '../../utils';
|
||||
|
||||
const GROUPS_KEY_PREFIX = '/api/v1/span_mapper_groups';
|
||||
|
||||
function clone(groups: DraftGroup[]): DraftGroup[] {
|
||||
return cloneDeep(groups);
|
||||
}
|
||||
|
||||
export interface AttributeMappingStore {
|
||||
groups: DraftGroup[];
|
||||
// The last-saved server baseline the working `groups` are diffed against.
|
||||
// Exposed so the Test tab can send only the groups whose mappers changed.
|
||||
snapshot: DraftGroup[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
saveError: string | null;
|
||||
upsertGroup: (draft: GroupDraft) => void;
|
||||
removeGroup: (localId: string) => void;
|
||||
toggleGroup: (localId: string, enabled: boolean) => void;
|
||||
hydrateGroupMappers: (groupServerId: string, mappers: Mapper[]) => void;
|
||||
upsertMapper: (groupLocalId: string, draft: MapperDraft) => void;
|
||||
removeMapper: (groupLocalId: string, mapperLocalId: string) => void;
|
||||
toggleMapper: (
|
||||
groupLocalId: string,
|
||||
mapperLocalId: string,
|
||||
enabled: boolean,
|
||||
) => void;
|
||||
save: () => Promise<void>;
|
||||
discard: () => void;
|
||||
}
|
||||
|
||||
export function useAttributeMappingStore(): AttributeMappingStore {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const groupsQuery = useListSpanMapperGroups();
|
||||
const serverGroups: MapperGroup[] = useMemo(
|
||||
() => groupsQuery.data?.data?.items ?? [],
|
||||
[groupsQuery.data],
|
||||
);
|
||||
|
||||
const ready = !groupsQuery.isLoading;
|
||||
|
||||
// A group's mappers are fetched lazily when its row is first expanded (see
|
||||
// MappersTable -> hydrateGroupMappers) and cached here, keyed by group server
|
||||
// id. Page load stays a single groups request — never an N+1 fan-out across
|
||||
// every group.
|
||||
const [loadedMappers, setLoadedMappers] = useState<Record<string, Mapper[]>>(
|
||||
{},
|
||||
);
|
||||
const loadedRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const snapshot = useMemo<DraftGroup[]>(() => {
|
||||
if (!ready) {
|
||||
return [];
|
||||
}
|
||||
return serverGroups.map((group) =>
|
||||
buildDraftGroup(group, loadedMappers[group.id] ?? []),
|
||||
);
|
||||
}, [ready, serverGroups, loadedMappers]);
|
||||
|
||||
const [draft, setDraft] = useState<DraftGroup[] | null>(null);
|
||||
|
||||
// Initialise the working copy once data is ready (and re-init after a save
|
||||
// clears it). Never clobbers in-flight edits — only runs when draft is null.
|
||||
useEffect(() => {
|
||||
if (ready && draft === null) {
|
||||
setDraft(clone(snapshot));
|
||||
}
|
||||
}, [ready, draft, snapshot]);
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const { mutateAsync: createGroup } = useCreateSpanMapperGroup();
|
||||
const { mutateAsync: updateGroup } = useUpdateSpanMapperGroup();
|
||||
const { mutateAsync: deleteGroup } = useDeleteSpanMapperGroup();
|
||||
const { mutateAsync: createMapper } = useCreateSpanMapper();
|
||||
const { mutateAsync: updateMapper } = useUpdateSpanMapper();
|
||||
const { mutateAsync: deleteMapper } = useDeleteSpanMapper();
|
||||
|
||||
const mutations: SaveMutations = useMemo(
|
||||
() => ({
|
||||
createGroup: async (data): Promise<string> => {
|
||||
const result = await createGroup({ data });
|
||||
return result.data.id;
|
||||
},
|
||||
updateGroup: async (groupId, data): Promise<void> => {
|
||||
await updateGroup({ pathParams: { groupId }, data });
|
||||
},
|
||||
deleteGroup: async (groupId): Promise<void> => {
|
||||
await deleteGroup({ pathParams: { groupId } });
|
||||
},
|
||||
createMapper: async (groupId, data): Promise<void> => {
|
||||
await createMapper({ pathParams: { groupId }, data });
|
||||
},
|
||||
updateMapper: async (groupId, mapperId, data): Promise<void> => {
|
||||
await updateMapper({ pathParams: { groupId, mapperId }, data });
|
||||
},
|
||||
deleteMapper: async (groupId, mapperId): Promise<void> => {
|
||||
await deleteMapper({ pathParams: { groupId, mapperId } });
|
||||
},
|
||||
}),
|
||||
[
|
||||
createGroup,
|
||||
updateGroup,
|
||||
deleteGroup,
|
||||
createMapper,
|
||||
updateMapper,
|
||||
deleteMapper,
|
||||
],
|
||||
);
|
||||
|
||||
const upsertGroup = useCallback((groupDraft: GroupDraft): void => {
|
||||
setDraft((prev) => {
|
||||
const groups = prev ?? [];
|
||||
if (groupDraft.id) {
|
||||
return groups.map((group) =>
|
||||
group.localId === groupDraft.id
|
||||
? nodeFromGroupDraft(groupDraft, group)
|
||||
: group,
|
||||
);
|
||||
}
|
||||
return [...groups, nodeFromGroupDraft(groupDraft)];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeGroup = useCallback((localId: string): void => {
|
||||
setDraft((prev) => (prev ?? []).filter((group) => group.localId !== localId));
|
||||
}, []);
|
||||
|
||||
const toggleGroup = useCallback((localId: string, enabled: boolean): void => {
|
||||
setDraft((prev) =>
|
||||
(prev ?? []).map((group) =>
|
||||
group.localId === localId ? { ...group, enabled } : group,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Fold a group's freshly-fetched server mappers into both the snapshot
|
||||
// baseline and the working draft, exactly once per group (the guard skips
|
||||
// re-expands). The "Add mapping" affordance renders while the fetch is still
|
||||
// in flight, so a user can stage a new mapper (serverId === null) before the
|
||||
// server list arrives — those unsaved rows are preserved, with the server
|
||||
// mappers folded in ahead of them, so a racing add isn't clobbered.
|
||||
const hydrateGroupMappers = useCallback(
|
||||
(groupServerId: string, mappers: Mapper[]): void => {
|
||||
if (loadedRef.current.has(groupServerId)) {
|
||||
return;
|
||||
}
|
||||
loadedRef.current.add(groupServerId);
|
||||
setLoadedMappers((prev) => ({ ...prev, [groupServerId]: mappers }));
|
||||
setDraft((prev) =>
|
||||
prev === null
|
||||
? prev
|
||||
: prev.map((group) =>
|
||||
group.serverId === groupServerId
|
||||
? {
|
||||
...group,
|
||||
mappers: [
|
||||
...mappers.map(buildDraftMapper),
|
||||
...group.mappers.filter((mapper) => mapper.serverId === null),
|
||||
],
|
||||
}
|
||||
: group,
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const upsertMapper = useCallback(
|
||||
(groupLocalId: string, mapperDraft: MapperDraft): void => {
|
||||
setDraft((prev) =>
|
||||
(prev ?? []).map((group) => {
|
||||
if (group.localId !== groupLocalId) {
|
||||
return group;
|
||||
}
|
||||
if (mapperDraft.id) {
|
||||
return {
|
||||
...group,
|
||||
mappers: group.mappers.map((mapper) =>
|
||||
mapper.localId === mapperDraft.id
|
||||
? nodeFromMapperDraft(mapperDraft, mapper)
|
||||
: mapper,
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...group,
|
||||
mappers: [...group.mappers, nodeFromMapperDraft(mapperDraft)],
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const removeMapper = useCallback(
|
||||
(groupLocalId: string, mapperLocalId: string): void => {
|
||||
setDraft((prev) =>
|
||||
(prev ?? []).map((group) =>
|
||||
group.localId === groupLocalId
|
||||
? {
|
||||
...group,
|
||||
mappers: group.mappers.filter(
|
||||
(mapper) => mapper.localId !== mapperLocalId,
|
||||
),
|
||||
}
|
||||
: group,
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const toggleMapper = useCallback(
|
||||
(groupLocalId: string, mapperLocalId: string, enabled: boolean): void => {
|
||||
setDraft((prev) =>
|
||||
(prev ?? []).map((group) =>
|
||||
group.localId === groupLocalId
|
||||
? {
|
||||
...group,
|
||||
mappers: group.mappers.map((mapper) =>
|
||||
mapper.localId === mapperLocalId ? { ...mapper, enabled } : mapper,
|
||||
),
|
||||
}
|
||||
: group,
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const discard = useCallback((): void => {
|
||||
setSaveError(null);
|
||||
setDraft(clone(snapshot));
|
||||
}, [snapshot]);
|
||||
|
||||
const save = useCallback(async (): Promise<void> => {
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
await persistDraft(snapshot, draft, mutations);
|
||||
// Refetch the groups list in place — it stays mounted, so this just
|
||||
// swaps in fresh data without a loading flash. Exact-match the key so
|
||||
// this doesn't also touch the per-group mapper lists (handled below).
|
||||
await queryClient.invalidateQueries({
|
||||
predicate: (query) => query.queryKey?.[0] === GROUPS_KEY_PREFIX,
|
||||
});
|
||||
// Reset the working copy and the lazily-loaded mapper mirror *before*
|
||||
// the mapper queries re-emit, so the re-hydrate isn't clobbered.
|
||||
loadedRef.current = new Set();
|
||||
setLoadedMappers({});
|
||||
setDraft(null);
|
||||
// removeQueries (not invalidate) for the per-group mapper lists: an
|
||||
// expanded group's table only re-hydrates when its query `data` changes
|
||||
// reference, but react-query's structural sharing keeps `data` stable
|
||||
// when the list is unchanged — so invalidate alone leaves the table
|
||||
// empty. Removing the cache forces undefined -> refetch -> fresh, which
|
||||
// always fires the hydrate effect. Also fixes stale mappers on re-expand.
|
||||
queryClient.removeQueries({
|
||||
predicate: (query) =>
|
||||
typeof query.queryKey?.[0] === 'string' &&
|
||||
(query.queryKey[0] as string).startsWith(`${GROUPS_KEY_PREFIX}/`),
|
||||
});
|
||||
toast.success('Attribute mapping changes saved');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Save failed';
|
||||
setSaveError(message);
|
||||
toast.error(`Failed to save changes: ${message}`);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [draft, snapshot, mutations, queryClient]);
|
||||
|
||||
const isDirty = useMemo(
|
||||
() => draft !== null && !isEqual(draft, snapshot),
|
||||
[draft, snapshot],
|
||||
);
|
||||
|
||||
return {
|
||||
groups: draft ?? [],
|
||||
snapshot,
|
||||
isLoading: !ready || draft === null,
|
||||
isError: groupsQuery.isError,
|
||||
isDirty,
|
||||
isSaving,
|
||||
saveError,
|
||||
upsertGroup,
|
||||
removeGroup,
|
||||
toggleGroup,
|
||||
hydrateGroupMappers,
|
||||
upsertMapper,
|
||||
removeMapper,
|
||||
toggleMapper,
|
||||
save,
|
||||
discard,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './AttributeMappingsTab';
|
||||
@@ -0,0 +1,15 @@
|
||||
.llmObservabilityAttributeMapping {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pageError {
|
||||
padding: var(--padding-3) var(--padding-4);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--callout-error-background);
|
||||
color: var(--callout-error-title);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
|
||||
import AttributeMappingHeader from './components/AttributeMappingHeader';
|
||||
import AttributeMappingsTab from './AttributeMappingsTab';
|
||||
import GroupFormDrawer from './components/GroupFormDrawer';
|
||||
import styles from './LLMObservabilityAttributeMapping.module.scss';
|
||||
import TestTab from './TestTab';
|
||||
import { useAttributeMappingStore } from './AttributeMappingsTab/hooks/useAttributeMappingStore';
|
||||
import { useGroupFormDrawer } from './useGroupFormDrawer';
|
||||
|
||||
function LLMObservabilityAttributeMapping(): JSX.Element {
|
||||
const store = useAttributeMappingStore();
|
||||
const groupDrawer = useGroupFormDrawer();
|
||||
|
||||
const handleGroupSave = useCallback((): void => {
|
||||
store.upsertGroup(groupDrawer.draft);
|
||||
groupDrawer.close();
|
||||
}, [store, groupDrawer]);
|
||||
|
||||
const handleGroupDelete = useCallback((): void => {
|
||||
if (groupDrawer.draft.id) {
|
||||
store.removeGroup(groupDrawer.draft.id);
|
||||
}
|
||||
groupDrawer.close();
|
||||
}, [store, groupDrawer]);
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'attribute-mappings',
|
||||
label: 'Attribute mappings',
|
||||
children: (
|
||||
<AttributeMappingsTab
|
||||
store={store}
|
||||
onEditGroup={groupDrawer.openForEdit}
|
||||
onAddGroup={groupDrawer.openForAdd}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'test',
|
||||
label: 'Test',
|
||||
children: <TestTab store={store} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.llmObservabilityAttributeMapping}
|
||||
data-testid="llm-observability-attribute-mapping-page"
|
||||
>
|
||||
<AttributeMappingHeader
|
||||
isDirty={store.isDirty}
|
||||
isSaving={store.isSaving}
|
||||
onDiscard={store.discard}
|
||||
onSave={store.save}
|
||||
/>
|
||||
|
||||
{store.saveError && (
|
||||
<div className={styles.pageError} role="alert">
|
||||
{store.saveError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
testId="attribute-mapping-tabs"
|
||||
defaultValue="attribute-mappings"
|
||||
items={tabItems}
|
||||
/>
|
||||
{groupDrawer.isOpen && (
|
||||
<GroupFormDrawer
|
||||
isOpen={groupDrawer.isOpen}
|
||||
mode={groupDrawer.mode}
|
||||
draft={groupDrawer.draft}
|
||||
setDraft={groupDrawer.setDraft}
|
||||
onClose={groupDrawer.close}
|
||||
onSave={handleGroupSave}
|
||||
onDelete={handleGroupDelete}
|
||||
isSaving={false}
|
||||
isDeleting={false}
|
||||
saveError={null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LLMObservabilityAttributeMapping;
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { SpantypesSpanMapperTestSpanDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { AttrChangeStatus, diffSpanAttributes } from './testPayload';
|
||||
import styles from './TestTab.module.scss';
|
||||
|
||||
interface TestResultProps {
|
||||
index: number;
|
||||
span: SpantypesSpanMapperTestSpanDTO;
|
||||
inputAttributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Partial<
|
||||
Record<
|
||||
AttrChangeStatus,
|
||||
{ color: 'success' | 'robin' | 'sienna'; label: string }
|
||||
>
|
||||
> = {
|
||||
added: { color: 'success', label: 'populated' },
|
||||
changed: { color: 'robin', label: 'remapped' },
|
||||
removed: { color: 'sienna', label: 'moved out' },
|
||||
};
|
||||
|
||||
// Only added/removed rows carry a background treatment; the rest render plain.
|
||||
// Kept as an explicit map so we never do dynamic `styles[...]` access.
|
||||
const ROW_CLASS: Partial<Record<AttrChangeStatus, string>> = {
|
||||
added: styles.added,
|
||||
removed: styles.removed,
|
||||
};
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
// Renders one transformed span as a key/value list, highlighting which target
|
||||
// attributes the mappers populated and which source keys a move consumed.
|
||||
function TestResult({
|
||||
index,
|
||||
span,
|
||||
inputAttributes,
|
||||
}: TestResultProps): JSX.Element {
|
||||
const entries = useMemo(
|
||||
() => diffSpanAttributes(inputAttributes, span.attributes ?? {}),
|
||||
[inputAttributes, span.attributes],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.resultCard} data-testid={`test-result-${index}`}>
|
||||
<div className={styles.resultTitle}>Resulting attributes</div>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<div className={styles.resultEmpty}>This span has no attributes.</div>
|
||||
) : (
|
||||
<div className={styles.attrRows}>
|
||||
{entries.map((entry) => {
|
||||
const badge = STATUS_BADGE[entry.status];
|
||||
return (
|
||||
<div
|
||||
key={entry.key}
|
||||
className={`${styles.attrRow} ${ROW_CLASS[entry.status] ?? ''}`}
|
||||
>
|
||||
<span className={styles.attrKey} title={entry.key}>
|
||||
{entry.key}
|
||||
</span>
|
||||
<span className={styles.attrValue} title={formatValue(entry.value)}>
|
||||
{formatValue(entry.value)}
|
||||
</span>
|
||||
{badge ? (
|
||||
<Badge color={badge.color} variant="outline">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TestResult;
|
||||
@@ -0,0 +1,161 @@
|
||||
.testTab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
margin-top: var(--spacing-6);
|
||||
}
|
||||
|
||||
// Heading + description on the left, Run button pinned to the top-right.
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.headerText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
margin: 0;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-6);
|
||||
height: calc(100vh - 320px);
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.editor {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.resultsPanel {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: var(--padding-4);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
// Pre-run empty state that fills the blank right half.
|
||||
.placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-2);
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.placeholderTitle {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: var(--padding-3) var(--padding-4);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--callout-error-background);
|
||||
color: var(--callout-error-title);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.resultCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--padding-4);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.resultTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.resultEmpty {
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.attrRows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.attrRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-2) var(--padding-3);
|
||||
border-radius: var(--radius-2);
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: var(--font-size-xs);
|
||||
|
||||
&.added {
|
||||
background: var(--callout-success-background);
|
||||
}
|
||||
|
||||
&.removed {
|
||||
opacity: 0.65;
|
||||
|
||||
.attrKey,
|
||||
.attrValue {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attrKey,
|
||||
.attrValue {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.attrKey {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.attrValue {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Play } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import Editor from 'components/Editor';
|
||||
|
||||
import styles from './TestTab.module.scss';
|
||||
import TestResult from './TestResult';
|
||||
import { AttributeMappingStore } from './AttributeMappingsTab/hooks/useAttributeMappingStore';
|
||||
import { useTestSpanMapper } from './useTestSpanMapper';
|
||||
|
||||
interface TestTabProps {
|
||||
store: AttributeMappingStore;
|
||||
}
|
||||
|
||||
// "Test" tab: paste a sample span, run it through the working draft's mappers,
|
||||
// and see which target attributes get populated. Only groups whose mappers
|
||||
// changed are sent in full — unchanged groups are backfilled server-side.
|
||||
function TestTab({ store }: TestTabProps): JSX.Element {
|
||||
const { input, setInput, run, isRunning, result, testedAttributes, error } =
|
||||
useTestSpanMapper(store.snapshot, store.groups);
|
||||
|
||||
function renderResults(): JSX.Element {
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.error} role="alert" data-testid="test-error">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (result) {
|
||||
if (result.length === 0) {
|
||||
return (
|
||||
<div className={styles.resultEmpty} data-testid="test-results-empty">
|
||||
No spans returned. The mappers produced no output for this input.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={styles.results} data-testid="test-results">
|
||||
{result.map((span, index) => (
|
||||
<TestResult
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
index={index}
|
||||
span={span}
|
||||
inputAttributes={testedAttributes ?? {}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={styles.placeholder} data-testid="test-results-placeholder">
|
||||
<span className={styles.placeholderTitle}>No results yet</span>
|
||||
<span>
|
||||
Run the test to see which target attributes your mappers populate.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.testTab} data-testid="test-tab">
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerText}>
|
||||
<h3 className={styles.heading}>Test with sample span</h3>
|
||||
<p className={styles.description}>
|
||||
Paste a JSON span object to see which target attributes get populated and
|
||||
which source key matched.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
testId="run-test-button"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Play size={14} />}
|
||||
onClick={run}
|
||||
loading={isRunning}
|
||||
disabled={isRunning}
|
||||
>
|
||||
Run Test
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
<div
|
||||
className={styles.editor}
|
||||
data-testid="test-span-input"
|
||||
aria-label="Sample span JSON"
|
||||
>
|
||||
<Editor
|
||||
language="json"
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
height="100%"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 12,
|
||||
lineNumbers: 'on',
|
||||
wordWrap: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
formatOnPaste: true,
|
||||
tabSize: 2,
|
||||
automaticLayout: true,
|
||||
scrollbar: { alwaysConsumeMouseWheel: false },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.resultsPanel} data-testid="test-results-panel">
|
||||
{renderResults()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TestTab;
|
||||
@@ -0,0 +1,72 @@
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import LLMObservabilityAttributeMapping from '../LLMObservabilityAttributeMapping';
|
||||
import { GROUPS_ENDPOINT, makeGroupsResponse, mockGroups } from './fixtures';
|
||||
|
||||
function setupGroups(): void {
|
||||
server.use(
|
||||
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeGroupsResponse(mockGroups))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe('LLMObservabilityAttributeMapping', () => {
|
||||
beforeEach(() => {
|
||||
window.history.pushState(null, '', '/');
|
||||
setupGroups();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders the page shell', () => {
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('llm-observability-attribute-mapping-page'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the attribute-mappings and test sub-tab labels', () => {
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'Attribute mappings' }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Test' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('activates the attribute-mappings tab by default and renders its content', async () => {
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
const attributeMappingsTab = screen.getByRole('tab', {
|
||||
name: 'Attribute mappings',
|
||||
});
|
||||
expect(attributeMappingsTab).toHaveAttribute('data-state', 'active');
|
||||
await expect(
|
||||
screen.findByTestId('attribute-mappings-tab'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('enables the test tab and renders its content when activated', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
const testTab = screen.getByRole('tab', { name: 'Test' });
|
||||
expect(testTab).toBeEnabled();
|
||||
|
||||
await user.click(testTab);
|
||||
await expect(screen.findByTestId('test-tab')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the header with Save/Discard disabled by default', () => {
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
expect(screen.getByTestId('save-changes-btn')).toBeDisabled();
|
||||
expect(screen.getByTestId('discard-changes-btn')).toBeDisabled();
|
||||
expect(screen.queryByTestId('unsaved-changes')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
SpantypesFieldContextDTO as FieldContext,
|
||||
SpantypesSpanMapperDTO as Mapper,
|
||||
SpantypesSpanMapperGroupDTO as MapperGroup,
|
||||
SpantypesSpanMapperOperationDTO as MapperOperation,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
// Endpoint globs used by MSW handlers. The generated client hits relative
|
||||
// `/api/v1/span_mapper_groups[...]`, so the `*` prefix matches regardless of
|
||||
// base URL.
|
||||
export const GROUPS_ENDPOINT = '*/api/v1/span_mapper_groups';
|
||||
export function mappersEndpoint(groupId: string): string {
|
||||
return `*/api/v1/span_mapper_groups/${groupId}/span_mappers`;
|
||||
}
|
||||
|
||||
export function makeGroup(overrides: Partial<MapperGroup> = {}): MapperGroup {
|
||||
return {
|
||||
id: 'group-1',
|
||||
orgId: 'org-1',
|
||||
name: 'demo',
|
||||
enabled: true,
|
||||
condition: {
|
||||
attributes: ['ai.embeddings'],
|
||||
resource: ['cloud.account.id'],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeMapper(overrides: Partial<Mapper> = {}): Mapper {
|
||||
return {
|
||||
id: 'mapper-1',
|
||||
group_id: 'group-1',
|
||||
name: 'gen_ai.request.model',
|
||||
enabled: true,
|
||||
fieldContext: FieldContext.attribute,
|
||||
config: {
|
||||
sources: [
|
||||
{
|
||||
key: 'genai.model',
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.copy,
|
||||
priority: 2,
|
||||
},
|
||||
{
|
||||
key: 'llm.model',
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.move,
|
||||
priority: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Both list endpoints share the same `{ status, data: { items } }` envelope —
|
||||
// the generated schema mis-types the mappers response with the groups DTO
|
||||
// (see MappersTable), but the runtime envelope shape is identical.
|
||||
export function makeGroupsResponse(groups: MapperGroup[]): {
|
||||
status: string;
|
||||
data: { items: MapperGroup[] };
|
||||
} {
|
||||
return { status: 'ok', data: { items: groups } };
|
||||
}
|
||||
|
||||
export function makeMappersResponse(mappers: Mapper[]): {
|
||||
status: string;
|
||||
data: { items: Mapper[] };
|
||||
} {
|
||||
return { status: 'ok', data: { items: mappers } };
|
||||
}
|
||||
|
||||
export const mockGroups: MapperGroup[] = [
|
||||
makeGroup({
|
||||
id: 'group-1',
|
||||
name: 'demo',
|
||||
condition: {
|
||||
attributes: ['ai.embeddings'],
|
||||
resource: ['cloud.account.id'],
|
||||
},
|
||||
}),
|
||||
makeGroup({
|
||||
id: 'group-2',
|
||||
name: 'Tool',
|
||||
enabled: false,
|
||||
condition: { attributes: null, resource: null },
|
||||
}),
|
||||
];
|
||||
|
||||
export const mockMappers: Mapper[] = [
|
||||
makeMapper({ id: 'mapper-1', group_id: 'group-1' }),
|
||||
];
|
||||
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
SpantypesFieldContextDTO as FieldContext,
|
||||
SpantypesSpanMapperOperationDTO as MapperOperation,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
buildDraftGroup,
|
||||
buildDraftMapper,
|
||||
conditionFiltersFromGroup,
|
||||
getMapperSources,
|
||||
} from '../utils';
|
||||
import { makeGroup, makeMapper } from './fixtures';
|
||||
|
||||
describe('conditionFiltersFromGroup', () => {
|
||||
it('lists attribute keys before resource keys', () => {
|
||||
const filters = conditionFiltersFromGroup({
|
||||
attributes: ['ai.embeddings'],
|
||||
resource: ['cloud.account.id'],
|
||||
});
|
||||
|
||||
expect(filters).toStrictEqual([
|
||||
{ context: 'attribute', key: 'ai.embeddings' },
|
||||
{ context: 'resource', key: 'cloud.account.id' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('defaults missing attributes/resource to no filters', () => {
|
||||
expect(conditionFiltersFromGroup({})).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMapperSources', () => {
|
||||
it('orders sources by priority, highest first', () => {
|
||||
const mapper = makeMapper({
|
||||
config: {
|
||||
sources: [
|
||||
{
|
||||
key: 'llm.model',
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.move,
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
key: 'genai.model',
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.copy,
|
||||
priority: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(getMapperSources(mapper)).toStrictEqual([
|
||||
{
|
||||
key: 'genai.model',
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.copy,
|
||||
},
|
||||
{
|
||||
key: 'llm.model',
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.move,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('defaults a null sources config to an empty list', () => {
|
||||
const mapper = makeMapper({ config: { sources: null } });
|
||||
|
||||
expect(getMapperSources(mapper)).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDraftMapper', () => {
|
||||
it('maps the server mapper into a draft node keyed by the server id', () => {
|
||||
const mapper = makeMapper({ id: 'mapper-9', enabled: false });
|
||||
|
||||
const draft = buildDraftMapper(mapper);
|
||||
|
||||
expect(draft.localId).toBe('mapper-9');
|
||||
expect(draft.serverId).toBe('mapper-9');
|
||||
expect(draft.name).toBe(mapper.name);
|
||||
expect(draft.enabled).toBe(false);
|
||||
expect(draft.sources).toStrictEqual(getMapperSources(mapper));
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDraftGroup', () => {
|
||||
it('maps the server group and its mappers into a draft tree', () => {
|
||||
const group = makeGroup({
|
||||
id: 'group-9',
|
||||
condition: { attributes: ['a'], resource: ['b'] },
|
||||
});
|
||||
const mapper = makeMapper({ id: 'mapper-1' });
|
||||
|
||||
const draft = buildDraftGroup(group, [mapper]);
|
||||
|
||||
expect(draft.localId).toBe('group-9');
|
||||
expect(draft.serverId).toBe('group-9');
|
||||
expect(draft.attributes).toStrictEqual(['a']);
|
||||
expect(draft.resource).toStrictEqual(['b']);
|
||||
expect(draft.mappers).toHaveLength(1);
|
||||
expect(draft.mappers[0].localId).toBe('mapper-1');
|
||||
});
|
||||
|
||||
it('defaults a null condition to empty attributes/resource', () => {
|
||||
const group = makeGroup({ condition: null });
|
||||
|
||||
const draft = buildDraftGroup(group, []);
|
||||
|
||||
expect(draft.attributes).toStrictEqual([]);
|
||||
expect(draft.resource).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.pageHeaderActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.unsavedChanges {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './AttributeMappingHeader.module.scss';
|
||||
|
||||
interface AttributeMappingHeaderProps {
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
onDiscard: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
function AttributeMappingHeader({
|
||||
isDirty,
|
||||
isSaving,
|
||||
onDiscard,
|
||||
onSave,
|
||||
}: AttributeMappingHeaderProps): JSX.Element {
|
||||
return (
|
||||
<header className={styles.pageHeader}>
|
||||
<Typography.Text as="p" size="base" color="muted">
|
||||
Configure source-to-target attribute remapping for LLM traces
|
||||
</Typography.Text>
|
||||
<div className={styles.pageHeaderActions}>
|
||||
{isDirty && (
|
||||
<span className={styles.unsavedChanges} data-testid="unsaved-changes">
|
||||
Unsaved changes
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={onDiscard}
|
||||
disabled={!isDirty || isSaving}
|
||||
testId="discard-changes-btn"
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onSave}
|
||||
loading={isSaving}
|
||||
disabled={!isDirty || isSaving}
|
||||
testId="save-changes-btn"
|
||||
>
|
||||
{isSaving ? 'Saving…' : 'Save changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default AttributeMappingHeader;
|
||||
@@ -0,0 +1,89 @@
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import AttributeMappingHeader from '../AttributeMappingHeader';
|
||||
|
||||
describe('AttributeMappingHeader', () => {
|
||||
it('renders the description copy', () => {
|
||||
render(
|
||||
<AttributeMappingHeader
|
||||
isDirty={false}
|
||||
isSaving={false}
|
||||
onDiscard={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Configure source-to-target attribute remapping for LLM traces',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the unsaved-changes indicator and disables Save/Discard when not dirty', () => {
|
||||
render(
|
||||
<AttributeMappingHeader
|
||||
isDirty={false}
|
||||
isSaving={false}
|
||||
onDiscard={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('unsaved-changes')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('discard-changes-btn')).toBeDisabled();
|
||||
expect(screen.getByTestId('save-changes-btn')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows the unsaved-changes indicator and enables Save/Discard when dirty', () => {
|
||||
render(
|
||||
<AttributeMappingHeader
|
||||
isDirty
|
||||
isSaving={false}
|
||||
onDiscard={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('unsaved-changes')).toHaveTextContent(
|
||||
'Unsaved changes',
|
||||
);
|
||||
expect(screen.getByTestId('discard-changes-btn')).toBeEnabled();
|
||||
expect(screen.getByTestId('save-changes-btn')).toBeEnabled();
|
||||
});
|
||||
|
||||
it('disables Save/Discard and shows the saving label while saving', () => {
|
||||
render(
|
||||
<AttributeMappingHeader
|
||||
isDirty
|
||||
isSaving
|
||||
onDiscard={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('discard-changes-btn')).toBeDisabled();
|
||||
expect(screen.getByTestId('save-changes-btn')).toBeDisabled();
|
||||
expect(screen.getByTestId('save-changes-btn')).toHaveTextContent('Saving…');
|
||||
});
|
||||
|
||||
it('calls onSave and onDiscard when their buttons are clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onSave = jest.fn();
|
||||
const onDiscard = jest.fn();
|
||||
render(
|
||||
<AttributeMappingHeader
|
||||
isDirty
|
||||
isSaving={false}
|
||||
onDiscard={onDiscard}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('save-changes-btn'));
|
||||
await user.click(screen.getByTestId('discard-changes-btn'));
|
||||
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
expect(onDiscard).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './AttributeMappingHeader';
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Plus, X } from '@signozhq/icons';
|
||||
|
||||
import styles from './GroupFormDrawer.module.scss';
|
||||
import KeySearchInput from '../KeySearchInput';
|
||||
import { FieldContextValue } from '../../types';
|
||||
|
||||
interface ConditionKeyListProps {
|
||||
label: string;
|
||||
labelHint?: string;
|
||||
keys: string[];
|
||||
placeholder: string;
|
||||
addLabel: string;
|
||||
testIdPrefix: string;
|
||||
fieldContext: FieldContextValue;
|
||||
onChange: (keys: string[]) => void;
|
||||
}
|
||||
|
||||
// Editor for one list of condition keys (the group's span-attribute or
|
||||
// resource gating keys). Substring "contains" match, order irrelevant.
|
||||
function ConditionKeyList({
|
||||
label,
|
||||
labelHint,
|
||||
keys,
|
||||
placeholder,
|
||||
addLabel,
|
||||
testIdPrefix,
|
||||
fieldContext,
|
||||
onChange,
|
||||
}: ConditionKeyListProps): JSX.Element {
|
||||
const updateKey = (index: number, value: string): void => {
|
||||
onChange(keys.map((key, i) => (i === index ? value : key)));
|
||||
};
|
||||
|
||||
const addKey = (): void => {
|
||||
onChange([...keys, '']);
|
||||
};
|
||||
|
||||
const removeKey = (index: number): void => {
|
||||
onChange(keys.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.groupFormField}>
|
||||
<span className={styles.groupFormLabel}>
|
||||
{label}
|
||||
{labelHint && (
|
||||
<span className={styles.groupFormLabelHint}> {labelHint}</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{keys.length > 0 && (
|
||||
<div className={styles.groupFormKeys}>
|
||||
{keys.map((key, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<div className={styles.groupFormKey} key={index}>
|
||||
<KeySearchInput
|
||||
className={styles.groupFormKeyInput}
|
||||
placeholder={placeholder}
|
||||
value={key}
|
||||
fieldContext={fieldContext}
|
||||
onChange={(next): void => updateKey(index, next)}
|
||||
testId={`${testIdPrefix}-${index}`}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Remove key"
|
||||
onClick={(): void => removeKey(index)}
|
||||
testId={`${testIdPrefix}-remove-${index}`}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={addKey}
|
||||
testId={`${testIdPrefix}-add`}
|
||||
>
|
||||
{addLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConditionKeyList;
|
||||
@@ -0,0 +1,76 @@
|
||||
.groupForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-10);
|
||||
padding: var(--spacing-2) 0;
|
||||
}
|
||||
|
||||
.groupFormField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.groupFormFieldRow {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.groupFormLabel {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.groupFormLabelHint {
|
||||
font-weight: var(--font-weight-normal);
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
.groupFormHint {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.groupFormKeys {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.groupFormKey {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.groupFormKeyInput {
|
||||
flex: 1;
|
||||
font-family: 'Geist Mono', monospace;
|
||||
}
|
||||
|
||||
.groupFormError {
|
||||
padding: var(--spacing-5) var(--spacing-6);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--callout-error-background);
|
||||
color: var(--callout-error-title);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.groupFormFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.groupFormFooterActions {
|
||||
display: flex;
|
||||
gap: var(--spacing-4);
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Trash2 } from '@signozhq/icons';
|
||||
|
||||
import ConditionKeyList from './ConditionKeyList';
|
||||
import styles from './GroupFormDrawer.module.scss';
|
||||
import { FieldContext, GroupDraft, MapperDraftMode } from '../../types';
|
||||
import { isGroupDraftValid } from '../../utils';
|
||||
|
||||
interface GroupFormDrawerProps {
|
||||
isOpen: boolean;
|
||||
mode: MapperDraftMode;
|
||||
draft: GroupDraft;
|
||||
setDraft: (next: GroupDraft) => void;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onDelete: () => void;
|
||||
isSaving: boolean;
|
||||
isDeleting: boolean;
|
||||
saveError: string | null;
|
||||
}
|
||||
|
||||
function GroupFormDrawer({
|
||||
isOpen,
|
||||
mode,
|
||||
draft,
|
||||
setDraft,
|
||||
onClose,
|
||||
onSave,
|
||||
onDelete,
|
||||
isSaving,
|
||||
isDeleting,
|
||||
saveError,
|
||||
}: GroupFormDrawerProps): JSX.Element {
|
||||
const isEdit = mode === 'edit';
|
||||
const isValid = isGroupDraftValid(draft);
|
||||
|
||||
return (
|
||||
<DrawerWrapper
|
||||
open={isOpen}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title={isEdit ? 'Edit group' : 'New group'}
|
||||
subTitle="A group gates which spans its mappings run on"
|
||||
width="wide"
|
||||
testId="group-form-drawer"
|
||||
footer={
|
||||
<div className={styles.groupFormFooter}>
|
||||
{isEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
prefix={<Trash2 size={14} />}
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
testId="group-form-delete"
|
||||
>
|
||||
{isDeleting ? 'Deleting…' : 'Delete'}
|
||||
</Button>
|
||||
)}
|
||||
<div className={styles.groupFormFooterActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onClick={onClose}
|
||||
testId="group-form-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onSave}
|
||||
disabled={!isValid || isSaving}
|
||||
testId="group-form-save"
|
||||
>
|
||||
{/* eslint-disable-next-line no-nested-ternary */}
|
||||
{isSaving ? 'Saving…' : isEdit ? 'Save group' : 'Create group'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={styles.groupForm}>
|
||||
<div className={styles.groupFormField}>
|
||||
<span className={styles.groupFormLabel}>Group name</span>
|
||||
<Input
|
||||
placeholder="e.g. OpenAI gateway"
|
||||
value={draft.name}
|
||||
onChange={(event): void =>
|
||||
setDraft({ ...draft, name: event.target.value })
|
||||
}
|
||||
testId="group-form-name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.groupFormField} ${styles.groupFormFieldRow}`}>
|
||||
<span className={styles.groupFormLabel}>Enabled</span>
|
||||
<Switch
|
||||
value={draft.enabled}
|
||||
onChange={(checked): void => setDraft({ ...draft, enabled: checked })}
|
||||
testId="group-form-enabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ConditionKeyList
|
||||
label="Condition · span attribute keys"
|
||||
labelHint="· runs when a span attribute key contains any of these"
|
||||
keys={draft.attributes}
|
||||
placeholder="e.g. gen_ai."
|
||||
addLabel="Add attribute key"
|
||||
testIdPrefix="group-form-attribute"
|
||||
fieldContext={FieldContext.attribute}
|
||||
onChange={(attributes): void => setDraft({ ...draft, attributes })}
|
||||
/>
|
||||
|
||||
<ConditionKeyList
|
||||
label="Condition · resource keys"
|
||||
labelHint="· or when a resource key contains any of these"
|
||||
keys={draft.resource}
|
||||
placeholder="e.g. service.name"
|
||||
addLabel="Add resource key"
|
||||
testIdPrefix="group-form-resource"
|
||||
fieldContext={FieldContext.resource}
|
||||
onChange={(resource): void => setDraft({ ...draft, resource })}
|
||||
/>
|
||||
|
||||
<span className={styles.groupFormHint}>
|
||||
Leave both empty to run this group on every span.
|
||||
</span>
|
||||
|
||||
{saveError && (
|
||||
<div className={styles.groupFormError} role="alert">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DrawerWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default GroupFormDrawer;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './GroupFormDrawer';
|
||||
@@ -0,0 +1,51 @@
|
||||
.keySearch {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.keySearchDropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--spacing-2));
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
max-width: 420px;
|
||||
max-height: 240px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-2);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 6px;
|
||||
background: var(--l2-background);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.keySearchDropdownEmpty {
|
||||
padding: var(--spacing-4) var(--spacing-5);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.keySearchOption {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: var(--spacing-3) var(--spacing-5);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--l1-foreground);
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: var(--font-size-xs);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-background-hover);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { useGetFieldsKeys } from 'api/generated/services/fields';
|
||||
import {
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import Spinner from 'components/Spinner';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
|
||||
import styles from './KeySearchInput.module.scss';
|
||||
import { FieldContext, FieldContextValue } from '../../types';
|
||||
|
||||
const SUGGESTION_LIMIT = 50;
|
||||
const DEBOUNCE_MS = 300;
|
||||
|
||||
interface KeySearchInputProps {
|
||||
value: string;
|
||||
fieldContext: FieldContextValue;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
testId?: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
// Maps the mapper's attribute/resource context to the fields-endpoint context.
|
||||
function toFieldsContext(
|
||||
context: FieldContextValue,
|
||||
): TelemetrytypesFieldContextDTO {
|
||||
return context === FieldContext.resource
|
||||
? TelemetrytypesFieldContextDTO.resource
|
||||
: TelemetrytypesFieldContextDTO.attribute;
|
||||
}
|
||||
|
||||
// Free-text input with span/resource key suggestions from /api/v1/fields/keys
|
||||
// (signal=traces). Typing keeps the custom value; suggestions are assistive.
|
||||
function KeySearchInput({
|
||||
value,
|
||||
fieldContext,
|
||||
placeholder,
|
||||
className,
|
||||
disabled,
|
||||
testId,
|
||||
onChange,
|
||||
}: KeySearchInputProps): JSX.Element {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const debouncedSearch = useDebounce(value, DEBOUNCE_MS);
|
||||
|
||||
const { data, isFetching } = useGetFieldsKeys(
|
||||
{
|
||||
signal: TelemetrytypesSignalDTO.traces,
|
||||
fieldContext: toFieldsContext(fieldContext),
|
||||
searchText: debouncedSearch,
|
||||
limit: SUGGESTION_LIMIT,
|
||||
},
|
||||
{ query: { enabled: isOpen && !disabled, keepPreviousData: true } },
|
||||
);
|
||||
|
||||
const suggestions = useMemo(() => {
|
||||
const keys = data?.data?.keys ?? {};
|
||||
return Object.keys(keys)
|
||||
.filter((key) => key !== value)
|
||||
.slice(0, SUGGESTION_LIMIT);
|
||||
}, [data, value]);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.keySearch, className)}>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
onChange={(event): void => {
|
||||
onChange(event.target.value);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
onFocus={(): void => setIsOpen(true)}
|
||||
onBlur={(): void => setIsOpen(false)}
|
||||
testId={testId}
|
||||
/>
|
||||
{isOpen && suggestions.length > 0 && (
|
||||
<div
|
||||
className={styles.keySearchDropdown}
|
||||
data-testid={`${testId}-dropdown`}
|
||||
>
|
||||
{suggestions.map((name) => (
|
||||
<button
|
||||
type="button"
|
||||
key={name}
|
||||
className={styles.keySearchOption}
|
||||
// onMouseDown (not onClick) so selection runs before the input blur.
|
||||
onMouseDown={(event): void => {
|
||||
event.preventDefault();
|
||||
onChange(name);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
data-testid={`${testId}-option-${name}`}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isOpen && isFetching && suggestions.length === 0 && (
|
||||
<div
|
||||
className={cx(styles.keySearchDropdown, styles.keySearchDropdownEmpty)}
|
||||
>
|
||||
<Spinner size="small" height="auto" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default KeySearchInput;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './KeySearchInput';
|
||||
@@ -0,0 +1,63 @@
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-10);
|
||||
padding: var(--spacing-2) 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.labelHint {
|
||||
font-weight: var(--font-weight-normal);
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.fieldContext {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.sources {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: var(--spacing-5) var(--spacing-6);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--callout-error-background);
|
||||
color: var(--callout-error-title);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
display: flex;
|
||||
gap: var(--spacing-4);
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { Plus, Trash2 } from '@signozhq/icons';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import KeySearchInput from '../KeySearchInput';
|
||||
import styles from './MapperFormDrawer.module.scss';
|
||||
import SourceAttributeRow from './SourceAttributeRow';
|
||||
import {
|
||||
FieldContext,
|
||||
FieldContextValue,
|
||||
MapperDraft,
|
||||
MapperDraftMode,
|
||||
SourceConfig,
|
||||
} from '../../types';
|
||||
import { createEmptySource, isMapperDraftValid } from '../../utils';
|
||||
|
||||
const FIELD_CONTEXT_OPTIONS = [
|
||||
{ value: FieldContext.attribute, label: 'Span attribute' },
|
||||
{ value: FieldContext.resource, label: 'Resource' },
|
||||
];
|
||||
|
||||
interface MapperFormDrawerProps {
|
||||
isOpen: boolean;
|
||||
mode: MapperDraftMode;
|
||||
draft: MapperDraft;
|
||||
setDraft: (next: MapperDraft) => void;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onDelete: () => void;
|
||||
isSaving: boolean;
|
||||
isDeleting: boolean;
|
||||
saveError: string | null;
|
||||
}
|
||||
|
||||
function MapperFormDrawer({
|
||||
isOpen,
|
||||
mode,
|
||||
draft,
|
||||
setDraft,
|
||||
onClose,
|
||||
onSave,
|
||||
onDelete,
|
||||
isSaving,
|
||||
isDeleting,
|
||||
saveError,
|
||||
}: MapperFormDrawerProps): JSX.Element {
|
||||
const isEdit = mode === 'edit';
|
||||
const isValid = isMapperDraftValid(draft);
|
||||
|
||||
// Stable per-row ids for the sortable list. These are UI-only (never sent to
|
||||
// the API and excluded from the draft), so dnd-kit can track rows reliably
|
||||
// even though sources are stored as a plain array. Re-seeded each time the
|
||||
// drawer opens; kept in lockstep with the sources array on add/remove/drag.
|
||||
const [rowIds, setRowIds] = useState<string[]>(() =>
|
||||
draft.sources.map(() => uuid()),
|
||||
);
|
||||
const sourceIds = draft.sources.map(
|
||||
(_, index) => rowIds[index] ?? `pending-${index}`,
|
||||
);
|
||||
|
||||
// 5px activation distance so clicking into the input never starts a drag.
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
);
|
||||
|
||||
const updateSource = (index: number, patch: Partial<SourceConfig>): void => {
|
||||
const sources = draft.sources.map((source, i) =>
|
||||
i === index ? { ...source, ...patch } : source,
|
||||
);
|
||||
setDraft({ ...draft, sources });
|
||||
};
|
||||
|
||||
const addSource = (): void => {
|
||||
setDraft({ ...draft, sources: [...draft.sources, createEmptySource()] });
|
||||
setRowIds((prev) => [...prev, uuid()]);
|
||||
};
|
||||
|
||||
const removeSource = (index: number): void => {
|
||||
const sources = draft.sources.filter((_, i) => i !== index);
|
||||
if (sources.length === 0) {
|
||||
setDraft({ ...draft, sources: [createEmptySource()] });
|
||||
setRowIds([uuid()]);
|
||||
return;
|
||||
}
|
||||
setDraft({ ...draft, sources });
|
||||
setRowIds((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent): void => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) {
|
||||
return;
|
||||
}
|
||||
const from = sourceIds.indexOf(String(active.id));
|
||||
const to = sourceIds.indexOf(String(over.id));
|
||||
if (from === -1 || to === -1) {
|
||||
return;
|
||||
}
|
||||
setDraft({ ...draft, sources: arrayMove(draft.sources, from, to) });
|
||||
setRowIds((prev) => arrayMove(prev, from, to));
|
||||
};
|
||||
|
||||
return (
|
||||
<DrawerWrapper
|
||||
open={isOpen}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title={isEdit ? 'Edit mapping' : 'New custom mapping'}
|
||||
subTitle="Map source attributes onto a canonical target attribute"
|
||||
width="wide"
|
||||
testId="mapper-form-drawer"
|
||||
footer={
|
||||
<div className={styles.footer}>
|
||||
{isEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
prefix={<Trash2 size={14} />}
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
testId="mapper-form-delete"
|
||||
>
|
||||
{isDeleting ? 'Deleting…' : 'Delete'}
|
||||
</Button>
|
||||
)}
|
||||
<div className={styles.footerActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onClick={onClose}
|
||||
testId="mapper-form-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onSave}
|
||||
disabled={!isValid || isSaving}
|
||||
testId="mapper-form-save"
|
||||
>
|
||||
{/* eslint-disable-next-line no-nested-ternary */}
|
||||
{isSaving ? 'Saving…' : isEdit ? 'Save mapping' : 'Create mapping'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={styles.form}>
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>Target attribute</span>
|
||||
<KeySearchInput
|
||||
placeholder="e.g. gen_ai.content.prompt"
|
||||
value={draft.name}
|
||||
fieldContext={draft.fieldContext}
|
||||
disabled={isEdit}
|
||||
onChange={(name): void => setDraft({ ...draft, name })}
|
||||
testId="mapper-form-target"
|
||||
/>
|
||||
{isEdit && (
|
||||
<span className={styles.hint}>
|
||||
The target attribute can't be changed after creation.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>Write target to</span>
|
||||
<SelectSimple
|
||||
className={styles.fieldContext}
|
||||
items={FIELD_CONTEXT_OPTIONS}
|
||||
value={draft.fieldContext}
|
||||
withPortal={false}
|
||||
onChange={(next): void =>
|
||||
setDraft({ ...draft, fieldContext: next as FieldContextValue })
|
||||
}
|
||||
testId="mapper-form-field-context"
|
||||
/>
|
||||
<span className={styles.hint}>
|
||||
Where the standardized attribute is written.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>
|
||||
Source attributes
|
||||
<span className={styles.labelHint}>
|
||||
{' '}
|
||||
· priority: top → bottom · drag to reorder
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={sourceIds} strategy={verticalListSortingStrategy}>
|
||||
<div className={styles.sources}>
|
||||
{draft.sources.map((source, index) => (
|
||||
<SourceAttributeRow
|
||||
key={sourceIds[index]}
|
||||
id={sourceIds[index]}
|
||||
index={index}
|
||||
value={source}
|
||||
canRemove={draft.sources.length > 1}
|
||||
onChange={updateSource}
|
||||
onRemove={removeSource}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
<Button
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={addSource}
|
||||
testId="mapper-form-add-source"
|
||||
>
|
||||
Add another source
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{saveError && (
|
||||
<div className={styles.error} role="alert">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DrawerWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapperFormDrawer;
|
||||
@@ -0,0 +1,40 @@
|
||||
.source {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.sourceHandle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-2);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--l3-foreground);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.sourceIndex {
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.sourceInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: 'Geist Mono', monospace;
|
||||
}
|
||||
|
||||
.sourceSelect {
|
||||
flex: 0 0 auto;
|
||||
width: 120px;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { GripVertical, X } from '@signozhq/icons';
|
||||
|
||||
import KeySearchInput from '../KeySearchInput';
|
||||
import styles from './SourceAttributeRow.module.scss';
|
||||
import {
|
||||
FieldContext,
|
||||
FieldContextValue,
|
||||
MapperOperation,
|
||||
MapperOperationValue,
|
||||
SourceConfig,
|
||||
} from '../../types';
|
||||
|
||||
const CONTEXT_OPTIONS = [
|
||||
{ value: FieldContext.attribute, label: 'Attribute' },
|
||||
{ value: FieldContext.resource, label: 'Resource' },
|
||||
];
|
||||
|
||||
const OPERATION_OPTIONS = [
|
||||
{ value: MapperOperation.move, label: 'Move' },
|
||||
{ value: MapperOperation.copy, label: 'Copy' },
|
||||
];
|
||||
|
||||
interface SourceAttributeRowProps {
|
||||
id: string;
|
||||
index: number;
|
||||
value: SourceConfig;
|
||||
canRemove: boolean;
|
||||
onChange: (index: number, patch: Partial<SourceConfig>) => void;
|
||||
onRemove: (index: number) => void;
|
||||
}
|
||||
|
||||
// A single draggable source row. Order = priority (top wins). Each source can
|
||||
// be read from a span attribute or the resource, and moved (delete source) or
|
||||
// copied (keep source). Only the grip is a drag handle.
|
||||
function SourceAttributeRow({
|
||||
id,
|
||||
index,
|
||||
value,
|
||||
canRemove,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: SourceAttributeRowProps): JSX.Element {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.source} ref={setNodeRef} style={style}>
|
||||
<div
|
||||
className={styles.sourceHandle}
|
||||
data-testid={`mapper-form-source-handle-${index}`}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</div>
|
||||
<span className={styles.sourceIndex}>{index + 1}</span>
|
||||
<KeySearchInput
|
||||
className={styles.sourceInput}
|
||||
placeholder="Source attribute key"
|
||||
value={value.key}
|
||||
fieldContext={value.context}
|
||||
onChange={(key): void => onChange(index, { key })}
|
||||
testId={`mapper-form-source-${index}`}
|
||||
/>
|
||||
<SelectSimple
|
||||
className={styles.sourceSelect}
|
||||
items={CONTEXT_OPTIONS}
|
||||
value={value.context}
|
||||
withPortal={false}
|
||||
onChange={(next): void =>
|
||||
onChange(index, { context: next as FieldContextValue })
|
||||
}
|
||||
testId={`mapper-form-source-context-${index}`}
|
||||
/>
|
||||
<SelectSimple
|
||||
className={styles.sourceSelect}
|
||||
items={OPERATION_OPTIONS}
|
||||
value={value.operation}
|
||||
withPortal={false}
|
||||
onChange={(next): void =>
|
||||
onChange(index, { operation: next as MapperOperationValue })
|
||||
}
|
||||
testId={`mapper-form-source-operation-${index}`}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Remove source"
|
||||
disabled={!canRemove}
|
||||
onClick={(): void => onRemove(index)}
|
||||
testId={`mapper-form-source-remove-${index}`}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SourceAttributeRow;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './MapperFormDrawer';
|
||||
@@ -0,0 +1,187 @@
|
||||
import { isEqual } from 'lodash-es';
|
||||
import {
|
||||
SpantypesPostableSpanMapperDTO,
|
||||
SpantypesPostableSpanMapperGroupDTO,
|
||||
SpantypesUpdatableSpanMapperDTO,
|
||||
SpantypesUpdatableSpanMapperGroupDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { DraftGroup, DraftMapper, SourceConfig } from './types';
|
||||
import {
|
||||
buildPostableGroup,
|
||||
buildPostableMapper,
|
||||
buildUpdatableGroup,
|
||||
buildUpdatableMapper,
|
||||
} from './utils';
|
||||
|
||||
// Thin persistence surface the store wires to the generated mutations.
|
||||
// createGroup returns the new server id so its mappers can be created under it.
|
||||
export interface SaveMutations {
|
||||
createGroup: (data: SpantypesPostableSpanMapperGroupDTO) => Promise<string>;
|
||||
updateGroup: (
|
||||
groupId: string,
|
||||
data: SpantypesUpdatableSpanMapperGroupDTO,
|
||||
) => Promise<void>;
|
||||
deleteGroup: (groupId: string) => Promise<void>;
|
||||
createMapper: (
|
||||
groupId: string,
|
||||
data: SpantypesPostableSpanMapperDTO,
|
||||
) => Promise<void>;
|
||||
updateMapper: (
|
||||
groupId: string,
|
||||
mapperId: string,
|
||||
data: SpantypesUpdatableSpanMapperDTO,
|
||||
) => Promise<void>;
|
||||
deleteMapper: (groupId: string, mapperId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function sourcesEqual(a: SourceConfig[], b: SourceConfig[]): boolean {
|
||||
return (
|
||||
a.length === b.length &&
|
||||
a.every(
|
||||
(source, index) =>
|
||||
source.key === b[index].key &&
|
||||
source.context === b[index].context &&
|
||||
source.operation === b[index].operation,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function groupChanged(snapshot: DraftGroup, draft: DraftGroup): boolean {
|
||||
return (
|
||||
snapshot.name !== draft.name ||
|
||||
snapshot.enabled !== draft.enabled ||
|
||||
!isEqual(snapshot.attributes, draft.attributes) ||
|
||||
!isEqual(snapshot.resource, draft.resource)
|
||||
);
|
||||
}
|
||||
|
||||
function mapperChanged(snapshot: DraftMapper, draft: DraftMapper): boolean {
|
||||
return (
|
||||
snapshot.enabled !== draft.enabled ||
|
||||
snapshot.fieldContext !== draft.fieldContext ||
|
||||
!sourcesEqual(snapshot.sources, draft.sources)
|
||||
);
|
||||
}
|
||||
|
||||
function groupDraftOf(
|
||||
node: DraftGroup,
|
||||
): Parameters<typeof buildPostableGroup>[0] {
|
||||
return {
|
||||
id: node.serverId,
|
||||
name: node.name,
|
||||
attributes: node.attributes,
|
||||
resource: node.resource,
|
||||
enabled: node.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
function mapperDraftOf(
|
||||
node: DraftMapper,
|
||||
): Parameters<typeof buildPostableMapper>[0] {
|
||||
return {
|
||||
id: node.serverId,
|
||||
name: node.name,
|
||||
fieldContext: node.fieldContext,
|
||||
sources: node.sources,
|
||||
enabled: node.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
async function persistMappers(
|
||||
groupServerId: string,
|
||||
snapshotMappers: DraftMapper[],
|
||||
draftMappers: DraftMapper[],
|
||||
m: SaveMutations,
|
||||
): Promise<void> {
|
||||
const snapById = new Map(
|
||||
snapshotMappers
|
||||
.filter((mapper) => mapper.serverId)
|
||||
.map((mapper) => [mapper.serverId as string, mapper]),
|
||||
);
|
||||
const draftServerIds = new Set(
|
||||
draftMappers
|
||||
.filter((mapper) => mapper.serverId)
|
||||
.map((mapper) => mapper.serverId as string),
|
||||
);
|
||||
|
||||
// Deleted mappers.
|
||||
await Promise.all(
|
||||
snapshotMappers
|
||||
.filter((mapper) => mapper.serverId && !draftServerIds.has(mapper.serverId))
|
||||
.map((mapper) => m.deleteMapper(groupServerId, mapper.serverId as string)),
|
||||
);
|
||||
|
||||
// Created + updated mappers (sequential to keep ordering deterministic).
|
||||
for (const mapper of draftMappers) {
|
||||
if (!mapper.serverId) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await m.createMapper(
|
||||
groupServerId,
|
||||
buildPostableMapper(mapperDraftOf(mapper)),
|
||||
);
|
||||
} else {
|
||||
const snap = snapById.get(mapper.serverId);
|
||||
if (!snap || mapperChanged(snap, mapper)) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await m.updateMapper(
|
||||
groupServerId,
|
||||
mapper.serverId,
|
||||
buildUpdatableMapper(mapperDraftOf(mapper)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Diffs the staged tree against the server snapshot and issues the minimal set
|
||||
// of create/update/delete calls to reconcile them.
|
||||
export async function persistDraft(
|
||||
snapshot: DraftGroup[],
|
||||
draft: DraftGroup[],
|
||||
m: SaveMutations,
|
||||
): Promise<void> {
|
||||
const snapById = new Map(
|
||||
snapshot
|
||||
.filter((group) => group.serverId)
|
||||
.map((group) => [group.serverId as string, group]),
|
||||
);
|
||||
const draftServerIds = new Set(
|
||||
draft
|
||||
.filter((group) => group.serverId)
|
||||
.map((group) => group.serverId as string),
|
||||
);
|
||||
|
||||
// Apply additive work (creates/updates) before deletes, so a failure here
|
||||
// leaves at worst an incomplete set of additions rather than groups that
|
||||
// were deleted with no replacement persisted. Deletes are irreversible
|
||||
// (they cascade mappers server-side), so we do them last, once everything
|
||||
// else has succeeded.
|
||||
for (const group of draft) {
|
||||
if (!group.serverId) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const newId = await m.createGroup(buildPostableGroup(groupDraftOf(group)));
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await persistMappers(newId, [], group.mappers, m);
|
||||
continue;
|
||||
}
|
||||
|
||||
const snap = snapById.get(group.serverId);
|
||||
if (!snap || groupChanged(snap, group)) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await m.updateGroup(
|
||||
group.serverId,
|
||||
buildUpdatableGroup(groupDraftOf(group)),
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await persistMappers(group.serverId, snap?.mappers ?? [], group.mappers, m);
|
||||
}
|
||||
|
||||
// Deleted groups (cascades mappers server-side).
|
||||
await Promise.all(
|
||||
snapshot
|
||||
.filter((group) => group.serverId && !draftServerIds.has(group.serverId))
|
||||
.map((group) => m.deleteGroup(group.serverId as string)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
SpantypesPostableSpanMapperTestDTO,
|
||||
SpantypesPostableSpanMapperTestGroupDTO,
|
||||
SpantypesSpanMapperTestSpanDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { DraftGroup } from './types';
|
||||
import {
|
||||
buildPostableGroup,
|
||||
buildPostableMapper,
|
||||
groupDraftFromNode,
|
||||
mapperDraftFromNode,
|
||||
} from './utils';
|
||||
|
||||
// A group's mappers must be sent in full when the group is new (the backend has
|
||||
// no saved group of that name to backfill from) or when its mapper set has
|
||||
// diverged from the saved baseline. Otherwise we omit them and let the backend
|
||||
// load the saved mappers by group name — which also covers groups whose rows
|
||||
// were never expanded (their draft carries no mappers yet).
|
||||
function shouldSendMappers(
|
||||
snap: DraftGroup | undefined,
|
||||
group: DraftGroup,
|
||||
): boolean {
|
||||
if (!snap) {
|
||||
return true;
|
||||
}
|
||||
return JSON.stringify(snap.mappers) !== JSON.stringify(group.mappers);
|
||||
}
|
||||
|
||||
// Builds the `groups` portion of the test request from the working draft,
|
||||
// sending each group's name/enabled/condition from the current draft and its
|
||||
// `mappers` only when they changed (null otherwise → backend backfills).
|
||||
export function buildTestGroups(
|
||||
snapshot: DraftGroup[],
|
||||
draft: DraftGroup[],
|
||||
): SpantypesPostableSpanMapperTestGroupDTO[] {
|
||||
const snapById = new Map(
|
||||
snapshot
|
||||
.filter((group) => group.serverId)
|
||||
.map((group) => [group.serverId as string, group]),
|
||||
);
|
||||
|
||||
return draft.map((group) => {
|
||||
const base = buildPostableGroup(groupDraftFromNode(group));
|
||||
const snap = group.serverId ? snapById.get(group.serverId) : undefined;
|
||||
return {
|
||||
...base,
|
||||
mappers: shouldSendMappers(snap, group)
|
||||
? group.mappers.map((mapper) =>
|
||||
buildPostableMapper(mapperDraftFromNode(mapper)),
|
||||
)
|
||||
: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Parses the pasted JSON into a single test span. The pasted object is treated
|
||||
// as the span's attribute map (matching the sample shown to the user); resource
|
||||
// is left empty. Throws a friendly error on anything that isn't a JSON object.
|
||||
export function parseSpanInput(input: string): SpantypesSpanMapperTestSpanDTO {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('Paste a JSON span object to run the test.');
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Invalid JSON — check for trailing commas or missing quotes.',
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('Span must be a JSON object of attribute key-value pairs.');
|
||||
}
|
||||
|
||||
return { attributes: parsed as Record<string, unknown>, resource: {} };
|
||||
}
|
||||
|
||||
export function buildTestRequest(
|
||||
snapshot: DraftGroup[],
|
||||
draft: DraftGroup[],
|
||||
input: string,
|
||||
): SpantypesPostableSpanMapperTestDTO {
|
||||
return {
|
||||
groups: buildTestGroups(snapshot, draft),
|
||||
spans: [parseSpanInput(input)],
|
||||
};
|
||||
}
|
||||
|
||||
export type AttrChangeStatus = 'added' | 'changed' | 'unchanged' | 'removed';
|
||||
|
||||
export interface AttrDiffEntry {
|
||||
key: string;
|
||||
value: unknown;
|
||||
status: AttrChangeStatus;
|
||||
}
|
||||
|
||||
function valuesEqual(a: unknown, b: unknown): boolean {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
// Diffs the span attributes a user pasted against the attributes the mappers
|
||||
// produced, so the UI can highlight which target keys got populated ('added'),
|
||||
// which source keys were consumed by a move ('removed'), and what stayed.
|
||||
// Added keys (the populated targets) sort first as the primary signal.
|
||||
export function diffSpanAttributes(
|
||||
inputAttributes: Record<string, unknown>,
|
||||
resultAttributes: Record<string, unknown>,
|
||||
): AttrDiffEntry[] {
|
||||
const added: AttrDiffEntry[] = [];
|
||||
const changed: AttrDiffEntry[] = [];
|
||||
const unchanged: AttrDiffEntry[] = [];
|
||||
const removed: AttrDiffEntry[] = [];
|
||||
|
||||
Object.entries(resultAttributes).forEach(([key, value]) => {
|
||||
if (!(key in inputAttributes)) {
|
||||
added.push({ key, value, status: 'added' });
|
||||
} else if (!valuesEqual(inputAttributes[key], value)) {
|
||||
changed.push({ key, value, status: 'changed' });
|
||||
} else {
|
||||
unchanged.push({ key, value, status: 'unchanged' });
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(inputAttributes).forEach(([key, value]) => {
|
||||
if (!(key in resultAttributes)) {
|
||||
removed.push({ key, value, status: 'removed' });
|
||||
}
|
||||
});
|
||||
|
||||
return [...added, ...changed, ...unchanged, ...removed];
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
SpantypesFieldContextDTO,
|
||||
SpantypesSpanMapperDTO,
|
||||
SpantypesSpanMapperGroupDTO,
|
||||
SpantypesSpanMapperOperationDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
// Convenience aliases over the generated DTOs. The read-only listing consumes
|
||||
// the generated types directly, but the group-drawer form code reads better
|
||||
// against these domain names (and FieldContext is used as a value/enum).
|
||||
export type MapperGroup = SpantypesSpanMapperGroupDTO;
|
||||
export type Mapper = SpantypesSpanMapperDTO;
|
||||
export const FieldContext = SpantypesFieldContextDTO;
|
||||
export type FieldContextValue = SpantypesFieldContextDTO;
|
||||
export const MapperOperation = SpantypesSpanMapperOperationDTO;
|
||||
export type MapperOperationValue = SpantypesSpanMapperOperationDTO;
|
||||
|
||||
// A single human-readable condition clause shown in the group's Filters column.
|
||||
export interface ConditionFilter {
|
||||
context: 'attribute' | 'resource';
|
||||
key: string;
|
||||
}
|
||||
|
||||
export type MapperDraftMode = 'add' | 'edit';
|
||||
|
||||
// One source candidate. `context` is where the key is read from (span
|
||||
// attribute or resource); `operation` is move (delete source) or copy (keep).
|
||||
// Priority is implicit in list order (top wins), derived on save.
|
||||
export interface SourceConfig {
|
||||
key: string;
|
||||
context: SpantypesFieldContextDTO;
|
||||
operation: SpantypesSpanMapperOperationDTO;
|
||||
}
|
||||
|
||||
// Editable form state for a mapper. `sources` is ordered highest priority
|
||||
// first; `fieldContext` is where the standardized target is written.
|
||||
export interface MapperDraft {
|
||||
id: string | null;
|
||||
name: string;
|
||||
fieldContext: SpantypesFieldContextDTO;
|
||||
sources: SourceConfig[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// Editable form state for a group. The group runs when a span carries a
|
||||
// span-attribute key matching `attributes` OR a resource key matching
|
||||
// `resource` (plain substring match).
|
||||
export interface GroupDraft {
|
||||
id: string | null;
|
||||
name: string;
|
||||
attributes: string[];
|
||||
resource: string[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// Working-copy node for a mapper. `localId` is a stable client key (the server
|
||||
// id once persisted, or a temporary id for not-yet-saved rows). `serverId` is
|
||||
// null until the row has been persisted.
|
||||
export interface DraftMapper {
|
||||
localId: string;
|
||||
serverId: string | null;
|
||||
name: string;
|
||||
fieldContext: SpantypesFieldContextDTO;
|
||||
sources: SourceConfig[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// Working-copy node for a group, holding its mappers inline so the whole tree
|
||||
// can be staged locally and diffed against the server snapshot on save.
|
||||
export interface DraftGroup {
|
||||
localId: string;
|
||||
serverId: string | null;
|
||||
name: string;
|
||||
attributes: string[];
|
||||
resource: string[];
|
||||
enabled: boolean;
|
||||
mappers: DraftMapper[];
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { DraftGroup, GroupDraft, MapperDraftMode } from './types';
|
||||
import { EMPTY_GROUP_DRAFT, groupDraftFromNode } from './utils';
|
||||
|
||||
interface UseGroupFormDrawer {
|
||||
isOpen: boolean;
|
||||
mode: MapperDraftMode;
|
||||
draft: GroupDraft;
|
||||
setDraft: (next: GroupDraft) => void;
|
||||
openForAdd: () => void;
|
||||
openForEdit: (group: DraftGroup) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
// Form state for the group drawer. Persistence is staged through the store,
|
||||
// so this hook only owns open/draft/mode.
|
||||
export function useGroupFormDrawer(): UseGroupFormDrawer {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [mode, setMode] = useState<MapperDraftMode>('add');
|
||||
const [draft, setDraft] = useState<GroupDraft>(EMPTY_GROUP_DRAFT);
|
||||
|
||||
const openForAdd = useCallback((): void => {
|
||||
setMode('add');
|
||||
setDraft(EMPTY_GROUP_DRAFT);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const openForEdit = useCallback((group: DraftGroup): void => {
|
||||
setMode('edit');
|
||||
setDraft(groupDraftFromNode(group));
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const close = useCallback((): void => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
return { isOpen, mode, draft, setDraft, openForAdd, openForEdit, close };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { DraftMapper, MapperDraft, MapperDraftMode } from './types';
|
||||
import { EMPTY_MAPPER_DRAFT, mapperDraftFromNode } from './utils';
|
||||
|
||||
interface UseMapperFormDrawerResult {
|
||||
isOpen: boolean;
|
||||
mode: MapperDraftMode;
|
||||
draft: MapperDraft;
|
||||
setDraft: (next: MapperDraft) => void;
|
||||
openForAdd: () => void;
|
||||
openForEdit: (mapper: DraftMapper) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
// Form state for the mapper drawer. Persistence is staged through the store,
|
||||
// so this hook only owns open/draft/mode.
|
||||
export function useMapperFormDrawer(): UseMapperFormDrawerResult {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [mode, setMode] = useState<MapperDraftMode>('add');
|
||||
const [draft, setDraft] = useState<MapperDraft>(EMPTY_MAPPER_DRAFT);
|
||||
|
||||
const openForAdd = useCallback((): void => {
|
||||
setMode('add');
|
||||
setDraft(EMPTY_MAPPER_DRAFT);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const openForEdit = useCallback((mapper: DraftMapper): void => {
|
||||
setMode('edit');
|
||||
setDraft(mapperDraftFromNode(mapper));
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const close = useCallback((): void => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
return { isOpen, mode, draft, setDraft, openForAdd, openForEdit, close };
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
RenderErrorResponseDTO,
|
||||
SpantypesSpanMapperTestSpanDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useTestSpanMappers } from 'api/generated/services/spanmapper';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
import { buildTestRequest } from './testPayload';
|
||||
import { DraftGroup } from './types';
|
||||
|
||||
// Pre-filled sample so the tab is runnable on first open (mirrors the design).
|
||||
export const SAMPLE_SPAN_JSON = `{
|
||||
"my_company.llm.input": "What is quantum computing?",
|
||||
"llm.input_messages": "What is quantum computing?",
|
||||
"gen_ai.request.model": "gpt-4",
|
||||
"gen_ai.usage.total_tokens": 1250,
|
||||
"gen_ai.content.completion": "Quantum computing leverages..."
|
||||
}`;
|
||||
|
||||
function apiErrorMessage(error: unknown): string {
|
||||
const axiosError = error as AxiosError<RenderErrorResponseDTO>;
|
||||
return (
|
||||
axiosError?.response?.data?.error?.message ??
|
||||
(error instanceof Error ? error.message : 'Test failed. Please try again.')
|
||||
);
|
||||
}
|
||||
|
||||
export interface UseTestSpanMapper {
|
||||
input: string;
|
||||
setInput: (value: string) => void;
|
||||
run: () => void;
|
||||
reset: () => void;
|
||||
isRunning: boolean;
|
||||
result: SpantypesSpanMapperTestSpanDTO[] | null;
|
||||
// The attributes that were actually submitted with the last successful run,
|
||||
// so the result diff stays stable even if the textarea is edited afterwards.
|
||||
testedAttributes: Record<string, unknown> | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// Owns the Test tab's local state: the pasted span JSON, the parsed/built
|
||||
// request, and the result/error of the test mutation. Builds the request from
|
||||
// the working draft (sending only changed groups' mappers — see testPayload).
|
||||
export function useTestSpanMapper(
|
||||
snapshot: DraftGroup[],
|
||||
draft: DraftGroup[],
|
||||
): UseTestSpanMapper {
|
||||
const [input, setInput] = useState<string>(SAMPLE_SPAN_JSON);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<SpantypesSpanMapperTestSpanDTO[] | null>(
|
||||
null,
|
||||
);
|
||||
const [testedAttributes, setTestedAttributes] = useState<Record<
|
||||
string,
|
||||
unknown
|
||||
> | null>(null);
|
||||
|
||||
const { mutate, isLoading } = useTestSpanMappers();
|
||||
|
||||
const run = useCallback((): void => {
|
||||
setError(null);
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = buildTestRequest(snapshot, draft, input);
|
||||
} catch (parseError) {
|
||||
setResult(null);
|
||||
setError(apiErrorMessage(parseError));
|
||||
return;
|
||||
}
|
||||
|
||||
const submittedAttributes = (body.spans?.[0]?.attributes ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
mutate(
|
||||
{ data: body },
|
||||
{
|
||||
onSuccess: (response) => {
|
||||
setTestedAttributes(submittedAttributes);
|
||||
setResult(response.data?.spans ?? []);
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
setResult(null);
|
||||
setError(apiErrorMessage(mutationError));
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [snapshot, draft, input, mutate]);
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setTestedAttributes(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
input,
|
||||
setInput,
|
||||
run,
|
||||
reset,
|
||||
isRunning: isLoading,
|
||||
result,
|
||||
testedAttributes,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import {
|
||||
SpantypesPostableSpanMapperDTO,
|
||||
SpantypesPostableSpanMapperGroupDTO,
|
||||
SpantypesUpdatableSpanMapperDTO,
|
||||
SpantypesUpdatableSpanMapperGroupDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import {
|
||||
ConditionFilter,
|
||||
DraftGroup,
|
||||
DraftMapper,
|
||||
FieldContext,
|
||||
GroupDraft,
|
||||
Mapper,
|
||||
MapperDraft,
|
||||
MapperGroup,
|
||||
MapperOperation,
|
||||
SourceConfig,
|
||||
} from './types';
|
||||
|
||||
// Client-side id for not-yet-persisted rows. Prefixed so it never collides
|
||||
// with a server UUID and is easy to spot in logs.
|
||||
export function genLocalId(prefix: 'group' | 'mapper'): string {
|
||||
return `local-${prefix}-${uuid()}`;
|
||||
}
|
||||
|
||||
// Trimmed, de-duplicated, non-empty keys preserving input order.
|
||||
export function cleanKeys(keys: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
keys.forEach((raw) => {
|
||||
const key = raw.trim();
|
||||
if (key && !seen.has(key)) {
|
||||
seen.add(key);
|
||||
result.push(key);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// Display clauses for a group's condition keys (span attribute keys first,
|
||||
// then resource keys).
|
||||
export function conditionFiltersFromGroup(group: {
|
||||
attributes?: string[];
|
||||
resource?: string[];
|
||||
}): ConditionFilter[] {
|
||||
// TanStackTable renders skeleton placeholder rows through the cells on first
|
||||
// render, so these arrays can be undefined before real data lands — default
|
||||
// to empty rather than crashing the cell.
|
||||
return [
|
||||
...(group.attributes ?? []).map((key) => ({
|
||||
context: 'attribute' as const,
|
||||
key,
|
||||
})),
|
||||
...(group.resource ?? []).map((key) => ({
|
||||
context: 'resource' as const,
|
||||
key,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
// Source configs for a mapper, highest priority first (first match wins at
|
||||
// evaluation time).
|
||||
export function getMapperSources(mapper: Mapper): SourceConfig[] {
|
||||
const sources = mapper.config?.sources ?? [];
|
||||
return [...sources]
|
||||
.sort((a, b) => b.priority - a.priority)
|
||||
.map((source) => ({
|
||||
key: source.key,
|
||||
context: source.context,
|
||||
operation: source.operation,
|
||||
}));
|
||||
}
|
||||
|
||||
export function createEmptySource(): SourceConfig {
|
||||
return {
|
||||
key: '',
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.copy,
|
||||
};
|
||||
}
|
||||
|
||||
export const EMPTY_MAPPER_DRAFT: MapperDraft = {
|
||||
id: null,
|
||||
name: '',
|
||||
fieldContext: FieldContext.attribute,
|
||||
sources: [createEmptySource()],
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
// Trimmed, de-duplicated (by context+key), non-empty sources in priority order,
|
||||
// preserving each source's context and operation. A key identifies a different
|
||||
// source per context (span attribute vs resource), so both can coexist; only an
|
||||
// exact (context, key) repeat is collapsed, keeping the higher-priority row.
|
||||
export function getCleanSources(draft: MapperDraft): SourceConfig[] {
|
||||
const seen = new Set<string>();
|
||||
const result: SourceConfig[] = [];
|
||||
draft.sources.forEach((source) => {
|
||||
const key = source.key.trim();
|
||||
const dedupeKey = `${source.context}:${key}`;
|
||||
if (key && !seen.has(dedupeKey)) {
|
||||
seen.add(dedupeKey);
|
||||
result.push({ ...source, key });
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function isMapperDraftValid(draft: MapperDraft): boolean {
|
||||
return draft.name.trim().length > 0 && getCleanSources(draft).length > 0;
|
||||
}
|
||||
|
||||
// Priority is derived from list order so the first row wins.
|
||||
function buildSources(
|
||||
draft: MapperDraft,
|
||||
): SpantypesPostableSpanMapperDTO['config']['sources'] {
|
||||
const sources = getCleanSources(draft);
|
||||
return sources.map((source, index) => ({
|
||||
key: source.key,
|
||||
context: source.context,
|
||||
operation: source.operation,
|
||||
priority: sources.length - index,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildPostableMapper(
|
||||
draft: MapperDraft,
|
||||
): SpantypesPostableSpanMapperDTO {
|
||||
return {
|
||||
name: draft.name.trim(),
|
||||
fieldContext: draft.fieldContext,
|
||||
enabled: draft.enabled,
|
||||
config: { sources: buildSources(draft) },
|
||||
};
|
||||
}
|
||||
|
||||
// The target name is immutable on update (UpdatableSpanMapper has no name).
|
||||
export function buildUpdatableMapper(
|
||||
draft: MapperDraft,
|
||||
): SpantypesUpdatableSpanMapperDTO {
|
||||
return {
|
||||
fieldContext: draft.fieldContext,
|
||||
enabled: draft.enabled,
|
||||
config: { sources: buildSources(draft) },
|
||||
};
|
||||
}
|
||||
|
||||
export const EMPTY_GROUP_DRAFT: GroupDraft = {
|
||||
id: null,
|
||||
name: '',
|
||||
attributes: [''],
|
||||
resource: [],
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
export function isGroupDraftValid(draft: GroupDraft): boolean {
|
||||
return draft.name.trim().length > 0;
|
||||
}
|
||||
|
||||
export function buildPostableGroup(
|
||||
draft: GroupDraft,
|
||||
): SpantypesPostableSpanMapperGroupDTO {
|
||||
return {
|
||||
name: draft.name.trim(),
|
||||
enabled: draft.enabled,
|
||||
condition: {
|
||||
attributes: cleanKeys(draft.attributes),
|
||||
resource: cleanKeys(draft.resource),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// A full group payload is also a valid partial-update payload (all updatable
|
||||
// fields are present), so we reuse the postable builder.
|
||||
export function buildUpdatableGroup(
|
||||
draft: GroupDraft,
|
||||
): SpantypesUpdatableSpanMapperGroupDTO {
|
||||
return buildPostableGroup(draft);
|
||||
}
|
||||
|
||||
// ---- working-copy (draft tree) helpers ----
|
||||
|
||||
export function buildDraftMapper(mapper: Mapper): DraftMapper {
|
||||
return {
|
||||
localId: mapper.id,
|
||||
serverId: mapper.id,
|
||||
name: mapper.name,
|
||||
fieldContext: mapper.fieldContext,
|
||||
sources: getMapperSources(mapper),
|
||||
enabled: mapper.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDraftGroup(
|
||||
group: MapperGroup,
|
||||
mappers: Mapper[],
|
||||
): DraftGroup {
|
||||
return {
|
||||
localId: group.id,
|
||||
serverId: group.id,
|
||||
name: group.name,
|
||||
attributes: group.condition?.attributes ?? [],
|
||||
resource: group.condition?.resource ?? [],
|
||||
enabled: group.enabled,
|
||||
mappers: mappers.map(buildDraftMapper),
|
||||
};
|
||||
}
|
||||
|
||||
// DraftGroup -> editable form state (id carries the localId).
|
||||
export function groupDraftFromNode(group: DraftGroup): GroupDraft {
|
||||
return {
|
||||
id: group.localId,
|
||||
name: group.name,
|
||||
attributes: group.attributes.length > 0 ? group.attributes : [''],
|
||||
resource: group.resource,
|
||||
enabled: group.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
// DraftMapper -> editable form state (id carries the localId).
|
||||
export function mapperDraftFromNode(mapper: DraftMapper): MapperDraft {
|
||||
return {
|
||||
id: mapper.localId,
|
||||
name: mapper.name,
|
||||
fieldContext: mapper.fieldContext,
|
||||
sources:
|
||||
mapper.sources.length > 0
|
||||
? mapper.sources.map((source) => ({ ...source }))
|
||||
: [createEmptySource()],
|
||||
enabled: mapper.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
// Form state -> working-copy node. Reuses cleanKeys/getCleanSourceKeys so the
|
||||
// staged tree already holds normalized values.
|
||||
export function nodeFromGroupDraft(
|
||||
draft: GroupDraft,
|
||||
existing?: DraftGroup,
|
||||
): DraftGroup {
|
||||
return {
|
||||
localId: existing?.localId ?? genLocalId('group'),
|
||||
serverId: existing?.serverId ?? null,
|
||||
name: draft.name.trim(),
|
||||
attributes: cleanKeys(draft.attributes),
|
||||
resource: cleanKeys(draft.resource),
|
||||
enabled: draft.enabled,
|
||||
mappers: existing?.mappers ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function nodeFromMapperDraft(
|
||||
draft: MapperDraft,
|
||||
existing?: DraftMapper,
|
||||
): DraftMapper {
|
||||
return {
|
||||
localId: existing?.localId ?? genLocalId('mapper'),
|
||||
serverId: existing?.serverId ?? null,
|
||||
name: draft.name.trim(),
|
||||
fieldContext: draft.fieldContext,
|
||||
sources: getCleanSources(draft),
|
||||
enabled: draft.enabled,
|
||||
};
|
||||
}
|
||||
@@ -1,27 +1,7 @@
|
||||
.llmObservability {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.pageHeaderTitle {
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: var(--spacing-2) 0 0;
|
||||
color: var(--text-vanilla-400);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
height: 100%;
|
||||
margin-top: var(--spacing-2);
|
||||
margin-left: var(--spacing-2);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
|
||||
import { useLLMObservabilityTabs } from './hooks/useLLMObservabilityTabs';
|
||||
import styles from './LLMObservability.module.scss';
|
||||
|
||||
// Shell for the LLM Observability page: renders the top-level tab bar
|
||||
// (Overview / Configuration) using the SigNoz design-system Tabs, with
|
||||
// route-driven active state from useLLMObservabilityTabs.
|
||||
function LLMObservability(): JSX.Element {
|
||||
const { items, activeTab, onTabChange } = useLLMObservabilityTabs();
|
||||
|
||||
return (
|
||||
<div className={styles.llmObservability} data-testid="llm-observability-page">
|
||||
<header className={styles.pageHeader}>
|
||||
<div className={styles.pageHeaderTitle}>
|
||||
<h1 className={styles.title}>LLM Observability</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Monitor and analyze your LLM usage, costs, and performance
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<Tabs
|
||||
items={items}
|
||||
value={activeTab}
|
||||
onChange={onTabChange}
|
||||
testId="llm-observability-tabs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
.overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.pageHeaderTitle {
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: var(--spacing-2) 0 0;
|
||||
color: var(--text-vanilla-400);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import styles from './Overview.module.scss';
|
||||
|
||||
// Overview tab content for LLM Observability. Currently the feature's landing
|
||||
// surface; usage/cost/performance widgets land in later PRs.
|
||||
function Overview(): JSX.Element {
|
||||
return (
|
||||
<div className={styles.overview} data-testid="llm-observability-overview">
|
||||
<header className={styles.pageHeader}>
|
||||
<div className={styles.pageHeaderTitle}>
|
||||
<h1 className={styles.title}>LLM Observability</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Monitor and analyze your LLM usage, costs, and performance
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Overview;
|
||||
@@ -2,29 +2,4 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.pageHeaderTitle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: var(--spacing-2) 0 0;
|
||||
color: var(--text-vanilla-400);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import ModelCostTabPanel from './ModelCostTabPanel';
|
||||
import styles from './LLMObservabilityModelPricing.module.scss';
|
||||
@@ -10,20 +9,9 @@ function LLMObservabilityModelPricing(): JSX.Element {
|
||||
className={styles.llmObservabilityModelPricing}
|
||||
data-testid="llm-observability-model-pricing-page"
|
||||
>
|
||||
<header className={styles.pageHeader}>
|
||||
<div className={styles.pageHeaderTitle}>
|
||||
<Typography.Text as="h1" size="large" weight="semibold">
|
||||
Configuration
|
||||
</Typography.Text>
|
||||
<Typography.Text color="muted">
|
||||
Model pricing and cost estimation settings
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Tabs
|
||||
// Model costs is the only enabled tab for now, so default to it. When
|
||||
// the unpriced-models tab lands, this can become a URL-backed param.
|
||||
// the unpriced-models tab lands in a later PR.
|
||||
defaultValue="model-costs"
|
||||
items={[
|
||||
{
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useMemo } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Plus, Search, X } from '@signozhq/icons';
|
||||
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
|
||||
import { type ListLLMPricingRulesParams } from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -161,12 +160,6 @@ function ModelCostTabPanel(): JSX.Element {
|
||||
onDelete={deletion.requestDelete}
|
||||
/>
|
||||
|
||||
<footer>
|
||||
<Typography.Text color="muted" size="small">
|
||||
All prices per 1M tokens (USD)
|
||||
</Typography.Text>
|
||||
</footer>
|
||||
|
||||
{drawer.isOpen && (
|
||||
<ModelCostDrawer
|
||||
isOpen={drawer.isOpen}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
|
||||
import {
|
||||
LLM_PRICING_ENDPOINT,
|
||||
LLM_PRICING_RULE_ENDPOINT,
|
||||
makeListResponse,
|
||||
mockRules,
|
||||
} from '../../__tests__/fixtures';
|
||||
import ModelCostTabPanel from '../ModelCostTabPanel';
|
||||
|
||||
const toastSuccess = jest.fn();
|
||||
const toastError = jest.fn();
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: {
|
||||
success: (...args: unknown[]): void => toastSuccess(...args),
|
||||
error: (...args: unknown[]): void => toastError(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
function setupList(items = mockRules, total = items.length): void {
|
||||
server.use(
|
||||
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeListResponse(items, total))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// The list panel keeps page/search/source in the URL via nuqs, which reads
|
||||
// window.location. jsdom shares that across tests in a file, so reset it.
|
||||
function resetUrl(): void {
|
||||
window.history.pushState(null, '', '/');
|
||||
}
|
||||
|
||||
// The row kebab is a DropdownMenuSimple trigger; its testId isn't forwarded, so
|
||||
// select it as the row's only button and open the Edit/Delete menu.
|
||||
async function openRowMenu(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
ruleId: string,
|
||||
): Promise<void> {
|
||||
const row = screen.getByTestId(`model-cell-name-${ruleId}`).closest('tr');
|
||||
await user.click(within(row as HTMLElement).getByRole('button'));
|
||||
}
|
||||
|
||||
describe('ModelCostTabPanel (integration)', () => {
|
||||
beforeEach(() => {
|
||||
resetUrl();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders pricing rules returned by the list API', async () => {
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
const openaiCell = await screen.findByTestId('model-cell-name-rule-openai');
|
||||
expect(openaiCell).toHaveTextContent('gpt-4o');
|
||||
expect(
|
||||
screen.getByTestId('model-cell-name-rule-anthropic'),
|
||||
).toHaveTextContent('claude-3-5-sonnet');
|
||||
// Canonical id under the model name + provider column.
|
||||
expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('OpenAI').length).toBeGreaterThan(0);
|
||||
// Source badges reflect the override flag.
|
||||
expect(screen.getByTestId('source-badge-rule-openai')).toHaveTextContent(
|
||||
'User override',
|
||||
);
|
||||
expect(screen.getByTestId('source-badge-rule-anthropic')).toHaveTextContent(
|
||||
'Auto',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the empty state when there are no rules', async () => {
|
||||
setupList([], 0);
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
const empty = await screen.findByTestId('model-costs-empty');
|
||||
expect(empty).toHaveTextContent('No model costs yet.');
|
||||
});
|
||||
|
||||
it('shows an error message when the list request fails', async () => {
|
||||
server.use(
|
||||
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) => res(ctx.status(500))),
|
||||
);
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(alert).toHaveTextContent(
|
||||
'Failed to load pricing rules. Please try again.',
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the debounced search term as the q param', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let lastParams: URLSearchParams | null = null;
|
||||
server.use(
|
||||
rest.get(LLM_PRICING_ENDPOINT, (req, res, ctx) => {
|
||||
lastParams = req.url.searchParams;
|
||||
return res(ctx.status(200), ctx.json(makeListResponse(mockRules)));
|
||||
}),
|
||||
);
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
await screen.findByTestId('model-cell-name-rule-openai');
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('Search by model or provider'),
|
||||
'claude',
|
||||
);
|
||||
|
||||
await waitFor(() => expect(lastParams?.get('q')).toBe('claude'));
|
||||
});
|
||||
|
||||
it('clears the search via the clear button', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
const input = screen.getByPlaceholderText(
|
||||
'Search by model or provider',
|
||||
) as HTMLInputElement;
|
||||
await user.type(input, 'gpt');
|
||||
expect(input.value).toBe('gpt');
|
||||
|
||||
await user.click(screen.getByTestId('model-cost-search-clear'));
|
||||
await waitFor(() => expect(input.value).toBe(''));
|
||||
});
|
||||
|
||||
it('sends isOverride=true when the source filter is set to User override', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let lastParams: URLSearchParams | null = null;
|
||||
server.use(
|
||||
rest.get(LLM_PRICING_ENDPOINT, (req, res, ctx) => {
|
||||
lastParams = req.url.searchParams;
|
||||
return res(ctx.status(200), ctx.json(makeListResponse(mockRules)));
|
||||
}),
|
||||
);
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
await screen.findByTestId('model-cell-name-rule-openai');
|
||||
await user.click(screen.getByTestId('source-filter'));
|
||||
// Scope to the listbox option — "User override" also appears as a row badge.
|
||||
await user.click(
|
||||
await screen.findByRole('option', { name: 'User override' }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(lastParams?.get('isOverride')).toBe('true'));
|
||||
});
|
||||
|
||||
it('opens the add drawer for a manager (ADMIN)', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
await screen.findByTestId('model-cell-name-rule-openai');
|
||||
await user.click(screen.getByTestId('add-model-cost-btn'));
|
||||
|
||||
const modelInput = await screen.findByTestId('drawer-model-id-input');
|
||||
expect(modelInput).toBeInTheDocument();
|
||||
expect(screen.getByTestId('drawer-save-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the add button and row actions for a viewer', async () => {
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />, undefined, { role: 'VIEWER' });
|
||||
|
||||
const row = (
|
||||
await screen.findByTestId('model-cell-name-rule-openai')
|
||||
).closest('tr') as HTMLElement;
|
||||
expect(screen.queryByTestId('add-model-cost-btn')).not.toBeInTheDocument();
|
||||
// View-only rows render no action menu (no buttons in the row).
|
||||
expect(within(row).queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the edit drawer prefilled from the row action menu', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
await screen.findByTestId('model-cell-name-rule-openai');
|
||||
await openRowMenu(user, 'rule-openai');
|
||||
await user.click(await screen.findByText('Edit'));
|
||||
|
||||
const drawerTitle = await screen.findByText('Edit model cost');
|
||||
expect(drawerTitle).toBeInTheDocument();
|
||||
const modelInput = screen.getByTestId(
|
||||
'drawer-model-id-input',
|
||||
) as HTMLInputElement;
|
||||
expect(modelInput.value).toBe('gpt-4o');
|
||||
expect(modelInput).toBeDisabled();
|
||||
});
|
||||
|
||||
it('deletes a rule through the confirm dialog', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let deletedId: string | null = null;
|
||||
setupList();
|
||||
server.use(
|
||||
rest.delete(LLM_PRICING_RULE_ENDPOINT, (req, res, ctx) => {
|
||||
deletedId = req.params.id as string;
|
||||
return res(ctx.status(200), ctx.json({ status: 'success' }));
|
||||
}),
|
||||
);
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
await screen.findByTestId('model-cell-name-rule-openai');
|
||||
await openRowMenu(user, 'rule-openai');
|
||||
await user.click(await screen.findByText('Delete'));
|
||||
|
||||
await user.click(await screen.findByTestId('drawer-delete-confirm-btn'));
|
||||
|
||||
await waitFor(() => expect(deletedId).toBe('rule-openai'));
|
||||
await waitFor(() =>
|
||||
expect(toastSuccess).toHaveBeenCalledWith('Model cost deleted'),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders cache buckets for rules that have cache pricing', async () => {
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
const anthropicRow = (
|
||||
await screen.findByTestId('model-cell-name-rule-anthropic')
|
||||
).closest('tr') as HTMLElement;
|
||||
expect(within(anthropicRow).getByText(/Cache Read/i)).toBeInTheDocument();
|
||||
expect(within(anthropicRow).getByText(/Cache Write/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import DeleteConfirmDialog from '../DeleteConfirmDialog';
|
||||
|
||||
describe('DeleteConfirmDialog', () => {
|
||||
it('renders the model name in the confirmation copy', () => {
|
||||
render(
|
||||
<DeleteConfirmDialog
|
||||
open
|
||||
modelName="gpt-4o"
|
||||
isDeleting={false}
|
||||
onConfirm={jest.fn()}
|
||||
onCancel={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('gpt-4o')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onConfirm when the confirm button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onConfirm = jest.fn();
|
||||
render(
|
||||
<DeleteConfirmDialog
|
||||
open
|
||||
modelName="gpt-4o"
|
||||
isDeleting={false}
|
||||
onConfirm={onConfirm}
|
||||
onCancel={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('drawer-delete-confirm-btn'));
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onCancel when the cancel button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onCancel = jest.fn();
|
||||
render(
|
||||
<DeleteConfirmDialog
|
||||
open
|
||||
modelName="gpt-4o"
|
||||
isDeleting={false}
|
||||
onConfirm={jest.fn()}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('drawer-delete-cancel-btn'));
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('disables the confirm button while deleting', () => {
|
||||
render(
|
||||
<DeleteConfirmDialog
|
||||
open
|
||||
modelName="gpt-4o"
|
||||
isDeleting
|
||||
onConfirm={jest.fn()}
|
||||
onCancel={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('drawer-delete-confirm-btn')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('calls onCancel when the dialog is dismissed via Escape', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onCancel = jest.fn();
|
||||
render(
|
||||
<DeleteConfirmDialog
|
||||
open
|
||||
modelName="gpt-4o"
|
||||
isDeleting={false}
|
||||
onConfirm={jest.fn()}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { makePricingRule } from '../../../../__tests__/fixtures';
|
||||
import { EMPTY_DRAFT } from '../../../../constants';
|
||||
import { draftFromRule } from '../../../../utils';
|
||||
import ModelCostDrawer from '../ModelCostDrawer';
|
||||
|
||||
const editDraft = draftFromRule(
|
||||
makePricingRule({
|
||||
id: 'rule-openai',
|
||||
modelName: 'gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
}),
|
||||
);
|
||||
|
||||
describe('ModelCostDrawer (integration)', () => {
|
||||
it('renders the add title and a save button for a manager', () => {
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Add model cost')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('drawer-save-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables save until the form is dirty', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('drawer-save-btn')).toBeDisabled();
|
||||
|
||||
await user.type(screen.getByTestId('drawer-model-id-input'), 'openai:gpt-4o');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('drawer-save-btn')).toBeEnabled(),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the model id required error and does not call onSave when the name is empty', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onSave = jest.fn();
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={onSave}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
// Make the form dirty without touching the model id: add a pattern, which
|
||||
// mutates the `patterns` form field while leaving the name empty.
|
||||
await user.type(screen.getByTestId('drawer-pattern-input'), 'gpt');
|
||||
await user.click(screen.getByTestId('drawer-pattern-add-btn'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('drawer-save-btn')).toBeEnabled(),
|
||||
);
|
||||
await user.click(screen.getByTestId('drawer-save-btn'));
|
||||
|
||||
const error = await screen.findByText('Billing model ID is required.');
|
||||
expect(error).toBeInTheDocument();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onSave once on the happy path with valid model id and pricing', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onSave = jest.fn();
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={onSave}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByTestId('drawer-model-id-input'), 'openai:gpt-4o');
|
||||
await user.type(screen.getByTestId('drawer-input-cost'), '3');
|
||||
await user.type(screen.getByTestId('drawer-output-cost'), '9');
|
||||
|
||||
await user.click(screen.getByTestId('drawer-save-btn'));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('renders the edit title with disabled, prefilled model id and disabled provider', () => {
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="edit"
|
||||
initialDraft={editDraft}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Edit model cost')).toBeInTheDocument();
|
||||
const modelInput = screen.getByTestId(
|
||||
'drawer-model-id-input',
|
||||
) as HTMLInputElement;
|
||||
expect(modelInput.value).toBe('gpt-4o');
|
||||
expect(modelInput).toBeDisabled();
|
||||
expect(screen.getByTestId('drawer-provider-select')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('renders a read-only view with a Close button and no save for a viewer', () => {
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="edit"
|
||||
initialDraft={editDraft}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('View model cost')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('drawer-save-btn')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('drawer-cancel-btn')).toHaveTextContent('Close');
|
||||
});
|
||||
|
||||
it('renders the save error text', () => {
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError="boom"
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('boom')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { fireEvent, render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import type { DrawerDraft } from '../../../../../../types';
|
||||
import ExtraPricingBuckets from '../ExtraPricingBuckets';
|
||||
|
||||
type Pricing = DrawerDraft['pricing'];
|
||||
|
||||
function makePricing(overrides: Partial<Pricing> = {}): Pricing {
|
||||
return {
|
||||
input: 3,
|
||||
output: 9,
|
||||
cacheMode: CacheModeDTO.unknown,
|
||||
cacheRead: null,
|
||||
cacheWrite: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ExtraPricingBuckets', () => {
|
||||
it('shows only the add button when no bucket has a value', () => {
|
||||
render(
|
||||
<ExtraPricingBuckets
|
||||
pricing={makePricing()}
|
||||
isReadOnly={false}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('drawer-add-bucket-btn')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('drawer-cache-read-cost'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('drawer-cache-mode')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the picker and adds a cache_read row with the cache mode select', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<ExtraPricingBuckets
|
||||
pricing={makePricing()}
|
||||
isReadOnly={false}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('drawer-add-bucket-btn'));
|
||||
expect(screen.getByTestId('drawer-bucket-picker')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('drawer-add-bucket-cache-read'));
|
||||
|
||||
expect(screen.getByTestId('drawer-cache-read-cost')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('drawer-cache-mode')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onChange with the cache_read value typed into the row', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<ExtraPricingBuckets
|
||||
pricing={makePricing()}
|
||||
isReadOnly={false}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('drawer-add-bucket-btn'));
|
||||
await user.click(screen.getByTestId('drawer-add-bucket-cache-read'));
|
||||
|
||||
fireEvent.change(screen.getByTestId('drawer-cache-read-cost'), {
|
||||
target: { value: '2' },
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ cacheRead: 2 });
|
||||
});
|
||||
|
||||
it('calls onChange with cacheRead null when the row is removed', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<ExtraPricingBuckets
|
||||
pricing={makePricing({ cacheRead: 2 })}
|
||||
isReadOnly={false}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('drawer-remove-cache-read'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ cacheRead: null });
|
||||
});
|
||||
|
||||
it('renders the cache_read row on mount when pricing already has a value', () => {
|
||||
render(
|
||||
<ExtraPricingBuckets
|
||||
pricing={makePricing({ cacheRead: 2 })}
|
||||
isReadOnly={false}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('drawer-cache-read-cost')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('drawer-cache-mode')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the add and remove buttons when read-only', () => {
|
||||
render(
|
||||
<ExtraPricingBuckets
|
||||
pricing={makePricing({ cacheRead: 2 })}
|
||||
isReadOnly
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('drawer-add-bucket-btn')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('drawer-remove-cache-read'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { fireEvent, render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import PatternEditor from '../PatternEditor';
|
||||
|
||||
describe('PatternEditor', () => {
|
||||
it('adds a typed pattern via the Add button', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<PatternEditor
|
||||
patterns={['gpt-4o']}
|
||||
isReadOnly={false}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByTestId('drawer-pattern-input'), 'gpt-5');
|
||||
await user.click(screen.getByTestId('drawer-pattern-add-btn'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(['gpt-4o', 'gpt-5']);
|
||||
});
|
||||
|
||||
it('adds a pattern when Enter is pressed', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<PatternEditor patterns={[]} isReadOnly={false} onChange={onChange} />,
|
||||
);
|
||||
|
||||
const input = screen.getByTestId('drawer-pattern-input');
|
||||
fireEvent.change(input, { target: { value: 'claude' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(['claude']);
|
||||
});
|
||||
|
||||
it('does not call onChange for a duplicate and clears the input', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<PatternEditor
|
||||
patterns={['gpt-4o']}
|
||||
isReadOnly={false}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByTestId('drawer-pattern-input') as HTMLInputElement;
|
||||
await user.type(input, 'gpt-4o');
|
||||
await user.click(screen.getByTestId('drawer-pattern-add-btn'));
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace before adding', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<PatternEditor patterns={[]} isReadOnly={false} onChange={onChange} />,
|
||||
);
|
||||
|
||||
const input = screen.getByTestId('drawer-pattern-input');
|
||||
fireEvent.change(input, { target: { value: ' gemini ' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(['gemini']);
|
||||
});
|
||||
|
||||
it('removes a pattern when its chip remove button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<PatternEditor
|
||||
patterns={['gpt-4o', 'gpt-5']}
|
||||
isReadOnly={false}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Remove pattern gpt-4o' }),
|
||||
);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(['gpt-5']);
|
||||
});
|
||||
|
||||
it('renders chips without remove buttons and no input when read-only', () => {
|
||||
render(
|
||||
<PatternEditor patterns={['gpt-4o']} isReadOnly onChange={jest.fn()} />,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('drawer-pattern-input')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('drawer-pattern-add-btn'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Remove pattern gpt-4o' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user