mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-02 10:20:24 +01:00
Compare commits
16 Commits
SIG_3791
...
user-roles
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b80af505a3 | ||
|
|
5e122de0ba | ||
|
|
4c292ccfe2 | ||
|
|
faa964fb1d | ||
|
|
73ecbb3951 | ||
|
|
244216e49f | ||
|
|
be47fd5316 | ||
|
|
6f513b9497 | ||
|
|
1f9bfb5d97 | ||
|
|
e66dec439c | ||
|
|
ffac1a8768 | ||
|
|
b0859f3594 | ||
|
|
6cd896b49b | ||
|
|
84adb743f2 | ||
|
|
3f6e20037a | ||
|
|
4a6241acc7 |
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -51,6 +51,7 @@ jobs:
|
||||
- alerts
|
||||
- ingestionkeys
|
||||
- rootuser
|
||||
- serviceaccount
|
||||
sqlstore-provider:
|
||||
- postgres
|
||||
- sqlite
|
||||
|
||||
@@ -354,3 +354,13 @@ identn:
|
||||
impersonation:
|
||||
# toggle impersonation identN, when enabled, all requests will impersonate the root user
|
||||
enabled: false
|
||||
|
||||
##################### Service Account #####################
|
||||
serviceaccount:
|
||||
email:
|
||||
# email domain for the service account principal
|
||||
domain: signozserviceaccount.com
|
||||
|
||||
analytics:
|
||||
# toggle service account analytics
|
||||
enabled: true
|
||||
|
||||
@@ -2447,7 +2447,7 @@ components:
|
||||
- start
|
||||
- end
|
||||
type: object
|
||||
ServiceaccounttypesFactorAPIKey:
|
||||
ServiceaccounttypesGettableFactorAPIKey:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
@@ -2457,8 +2457,6 @@ components:
|
||||
type: integer
|
||||
id:
|
||||
type: string
|
||||
key:
|
||||
type: string
|
||||
lastObservedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
@@ -2471,7 +2469,6 @@ components:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- key
|
||||
- expiresAt
|
||||
- lastObservedAt
|
||||
- serviceAccountId
|
||||
@@ -2499,27 +2496,23 @@ components:
|
||||
type: object
|
||||
ServiceaccounttypesPostableServiceAccount:
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
roles:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- name
|
||||
- email
|
||||
- roles
|
||||
type: object
|
||||
ServiceaccounttypesPostableServiceAccountRole:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
ServiceaccounttypesServiceAccount:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
deletedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
id:
|
||||
@@ -2528,9 +2521,57 @@ components:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
roles:
|
||||
status:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- email
|
||||
- status
|
||||
- orgId
|
||||
type: object
|
||||
ServiceaccounttypesServiceAccountRole:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
role:
|
||||
$ref: '#/components/schemas/AuthtypesRole'
|
||||
roleId:
|
||||
type: string
|
||||
serviceAccountId:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- serviceAccountId
|
||||
- roleId
|
||||
- role
|
||||
type: object
|
||||
ServiceaccounttypesServiceAccountWithRoles:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
serviceAccountRoles:
|
||||
items:
|
||||
type: string
|
||||
$ref: '#/components/schemas/ServiceaccounttypesServiceAccountRole'
|
||||
nullable: true
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
@@ -2541,10 +2582,9 @@ components:
|
||||
- id
|
||||
- name
|
||||
- email
|
||||
- roles
|
||||
- status
|
||||
- orgId
|
||||
- deletedAt
|
||||
- serviceAccountRoles
|
||||
type: object
|
||||
ServiceaccounttypesUpdatableFactorAPIKey:
|
||||
properties:
|
||||
@@ -2557,28 +2597,6 @@ components:
|
||||
- name
|
||||
- expiresAt
|
||||
type: object
|
||||
ServiceaccounttypesUpdatableServiceAccount:
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
roles:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- name
|
||||
- email
|
||||
- roles
|
||||
type: object
|
||||
ServiceaccounttypesUpdatableServiceAccountStatus:
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
type: object
|
||||
TelemetrytypesFieldContext:
|
||||
enum:
|
||||
- metric
|
||||
@@ -2702,43 +2720,6 @@ components:
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
TypesGettableAPIKey:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
createdBy:
|
||||
type: string
|
||||
createdByUser:
|
||||
$ref: '#/components/schemas/TypesUser'
|
||||
expiresAt:
|
||||
format: int64
|
||||
type: integer
|
||||
id:
|
||||
type: string
|
||||
lastUsed:
|
||||
format: int64
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
revoked:
|
||||
type: boolean
|
||||
role:
|
||||
type: string
|
||||
token:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
updatedBy:
|
||||
type: string
|
||||
updatedByUser:
|
||||
$ref: '#/components/schemas/TypesUser'
|
||||
userId:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
TypesIdentifiable:
|
||||
properties:
|
||||
id:
|
||||
@@ -2793,16 +2774,6 @@ components:
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
TypesPostableAPIKey:
|
||||
properties:
|
||||
expiresInDays:
|
||||
format: int64
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
role:
|
||||
type: string
|
||||
type: object
|
||||
TypesPostableBulkInviteRequest:
|
||||
properties:
|
||||
invites:
|
||||
@@ -2863,33 +2834,6 @@ components:
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
TypesStorableAPIKey:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
createdBy:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
revoked:
|
||||
type: boolean
|
||||
role:
|
||||
type: string
|
||||
token:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
updatedBy:
|
||||
type: string
|
||||
userId:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
TypesUpdatableUser:
|
||||
properties:
|
||||
displayName:
|
||||
@@ -4966,222 +4910,6 @@ paths:
|
||||
summary: Update org preference
|
||||
tags:
|
||||
- preferences
|
||||
/api/v1/pats:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint lists all api keys
|
||||
operationId: ListAPIKeys
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/TypesGettableAPIKey'
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: List api keys
|
||||
tags:
|
||||
- users
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint creates an api key
|
||||
operationId: CreateAPIKey
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TypesPostableAPIKey'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TypesGettableAPIKey'
|
||||
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
|
||||
"409":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Conflict
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Create api key
|
||||
tags:
|
||||
- users
|
||||
/api/v1/pats/{id}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: This endpoint revokes an api key
|
||||
operationId: RevokeAPIKey
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Revoke api key
|
||||
tags:
|
||||
- users
|
||||
put:
|
||||
deprecated: false
|
||||
description: This endpoint updates an api key
|
||||
operationId: UpdateAPIKey
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TypesStorableAPIKey'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Update api key
|
||||
tags:
|
||||
- users
|
||||
/api/v1/public/dashboards/{id}:
|
||||
get:
|
||||
deprecated: false
|
||||
@@ -5956,7 +5684,7 @@ paths:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesServiceAccount'
|
||||
$ref: '#/components/schemas/ServiceaccounttypesServiceAccountWithRoles'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
@@ -6010,7 +5738,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesUpdatableServiceAccount'
|
||||
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccount'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
@@ -6075,7 +5803,7 @@ paths:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesFactorAPIKey'
|
||||
$ref: '#/components/schemas/ServiceaccounttypesGettableFactorAPIKey'
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
@@ -6298,35 +6026,35 @@ paths:
|
||||
summary: Updates a service account key
|
||||
tags:
|
||||
- serviceaccount
|
||||
/api/v1/service_accounts/{id}/status:
|
||||
put:
|
||||
/api/v1/service_accounts/{id}/roles:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint updates an existing service account status
|
||||
operationId: UpdateServiceAccountStatus
|
||||
description: This endpoint gets all the roles for the existing service account
|
||||
operationId: GetServiceAccountRoles
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesUpdatableServiceAccountStatus'
|
||||
responses:
|
||||
"204":
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/AuthtypesRole'
|
||||
nullable: true
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
@@ -6356,7 +6084,184 @@ paths:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Updates a service account status
|
||||
summary: Gets service account roles
|
||||
tags:
|
||||
- serviceaccount
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint assigns a role to a service account
|
||||
operationId: CreateServiceAccountRole
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
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
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Create service account role
|
||||
tags:
|
||||
- serviceaccount
|
||||
/api/v1/service_accounts/{id}/roles/{rid}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: This endpoint revokes a role from service account
|
||||
operationId: DeleteServiceAccountRole
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: rid
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Delete service account role
|
||||
tags:
|
||||
- serviceaccount
|
||||
/api/v1/service_accounts/me:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint gets my service account
|
||||
operationId: GetMyServiceAccount
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesServiceAccountWithRoles'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"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
|
||||
summary: Gets my service account
|
||||
tags:
|
||||
- serviceaccount
|
||||
put:
|
||||
deprecated: false
|
||||
description: This endpoint gets my service account
|
||||
operationId: UpdateMyServiceAccount
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccount'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"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
|
||||
summary: Updates my service account
|
||||
tags:
|
||||
- serviceaccount
|
||||
/api/v1/user:
|
||||
|
||||
@@ -34,9 +34,22 @@ func (server *Server) Stop(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (server *Server) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector, _ []authtypes.Selector) error {
|
||||
subject, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
subject := ""
|
||||
switch claims.Principal {
|
||||
case authtypes.PrincipalUser:
|
||||
user, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
subject = user
|
||||
case authtypes.PrincipalServiceAccount:
|
||||
serviceAccount, err := authtypes.NewSubject(authtypes.TypeableServiceAccount, claims.ServiceAccountID, orgID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
subject = serviceAccount
|
||||
}
|
||||
|
||||
tupleSlice, err := typeable.Tuples(subject, relation, selectors, orgID)
|
||||
|
||||
@@ -213,8 +213,8 @@ func (module *module) Update(ctx context.Context, orgID valuer.UUID, id valuer.U
|
||||
return module.pkgDashboardModule.Update(ctx, orgID, id, updatedBy, data, diff)
|
||||
}
|
||||
|
||||
func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, role types.Role, lock bool) error {
|
||||
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedBy, role, lock)
|
||||
func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
|
||||
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedBy, isAdmin, lock)
|
||||
}
|
||||
|
||||
func (module *module) MustGetTypeables() []authtypes.Typeable {
|
||||
|
||||
@@ -14,10 +14,9 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
@@ -50,7 +49,7 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW
|
||||
return
|
||||
}
|
||||
|
||||
apiKey, apiErr := ah.getOrCreateCloudIntegrationPAT(r.Context(), claims.OrgID, cloudProvider)
|
||||
apiKey, apiErr := ah.getOrCreateCloudIntegrationFactorAPIKey(r.Context(), valuer.MustNewUUID(claims.OrgID), cloudProvider)
|
||||
if apiErr != nil {
|
||||
RespondError(w, basemodel.WrapApiError(
|
||||
apiErr, "couldn't provision PAT for cloud integration:",
|
||||
@@ -110,84 +109,44 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW
|
||||
ah.Respond(w, result)
|
||||
}
|
||||
|
||||
func (ah *APIHandler) getOrCreateCloudIntegrationPAT(ctx context.Context, orgId string, cloudProvider string) (
|
||||
func (ah *APIHandler) getOrCreateCloudIntegrationFactorAPIKey(ctx context.Context, orgID valuer.UUID, cloudProvider string) (
|
||||
string, *basemodel.ApiError,
|
||||
) {
|
||||
integrationPATName := fmt.Sprintf("%s integration", cloudProvider)
|
||||
|
||||
integrationUser, apiErr := ah.getOrCreateCloudIntegrationUser(ctx, orgId, cloudProvider)
|
||||
integrationPATName := fmt.Sprintf("%s", cloudProvider)
|
||||
serviceAccount, apiErr := ah.getOrCreateCloudIntegrationServiceAccount(ctx, orgID)
|
||||
if apiErr != nil {
|
||||
return "", apiErr
|
||||
}
|
||||
|
||||
orgIdUUID, err := valuer.NewUUID(orgId)
|
||||
if err != nil {
|
||||
return "", basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't parse orgId: %w", err,
|
||||
))
|
||||
}
|
||||
|
||||
allPats, err := ah.Signoz.Modules.UserSetter.ListAPIKeys(ctx, orgIdUUID)
|
||||
if err != nil {
|
||||
return "", basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't list PATs: %w", err,
|
||||
))
|
||||
}
|
||||
for _, p := range allPats {
|
||||
if p.UserID == integrationUser.ID && p.Name == integrationPATName {
|
||||
return p.Token, nil
|
||||
}
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "no PAT found for cloud integration, creating a new one",
|
||||
"cloud_provider", cloudProvider,
|
||||
)
|
||||
|
||||
newPAT, err := types.NewStorableAPIKey(
|
||||
integrationPATName,
|
||||
integrationUser.ID,
|
||||
types.RoleViewer,
|
||||
0,
|
||||
)
|
||||
factorAPIKey, err := serviceAccount.NewFactorAPIKey(integrationPATName, 0)
|
||||
if err != nil {
|
||||
return "", basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't create cloud integration PAT: %w", err,
|
||||
))
|
||||
}
|
||||
|
||||
err = ah.Signoz.Modules.UserSetter.CreateAPIKey(ctx, newPAT)
|
||||
factorAPIKey, err = ah.Signoz.Modules.ServiceAccount.GetOrCreateFactorAPIKey(ctx, factorAPIKey)
|
||||
if err != nil {
|
||||
return "", basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't create cloud integration PAT: %w", err,
|
||||
))
|
||||
}
|
||||
return newPAT.Token, nil
|
||||
return factorAPIKey.Key, nil
|
||||
}
|
||||
|
||||
func (ah *APIHandler) getOrCreateCloudIntegrationUser(
|
||||
ctx context.Context, orgId string, cloudProvider string,
|
||||
) (*types.User, *basemodel.ApiError) {
|
||||
cloudIntegrationUserName := fmt.Sprintf("%s-integration", cloudProvider)
|
||||
email := valuer.MustNewEmail(fmt.Sprintf("%s@signoz.io", cloudIntegrationUserName))
|
||||
|
||||
cloudIntegrationUser, err := types.NewUser(cloudIntegrationUserName, email, valuer.MustNewUUID(orgId), types.UserStatusActive)
|
||||
func (ah *APIHandler) getOrCreateCloudIntegrationServiceAccount(ctx context.Context, orgId valuer.UUID) (*serviceaccounttypes.ServiceAccount, *basemodel.ApiError) {
|
||||
domain := ah.Signoz.Modules.ServiceAccount.Config().Email.Domain
|
||||
cloudIntegrationServiceAccount := serviceaccounttypes.NewServiceAccount("integration", domain, serviceaccounttypes.ServiceAccountStatusActive, orgId)
|
||||
cloudIntegrationServiceAccount, err := ah.Signoz.Modules.ServiceAccount.GetOrCreate(ctx, orgId, cloudIntegrationServiceAccount)
|
||||
if err != nil {
|
||||
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration user: %w", err))
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration service account: %w", err))
|
||||
}
|
||||
|
||||
password := types.MustGenerateFactorPassword(cloudIntegrationUser.ID.StringValue())
|
||||
|
||||
cloudIntegrationUser, err = ah.Signoz.Modules.UserSetter.GetOrCreateUser(
|
||||
ctx,
|
||||
cloudIntegrationUser,
|
||||
user.WithFactorPassword(password),
|
||||
user.WithRoleNames([]string{authtypes.SigNozViewerRoleName}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, basemodel.InternalError(fmt.Errorf("couldn't look for integration user: %w", err))
|
||||
}
|
||||
|
||||
return cloudIntegrationUser, nil
|
||||
return cloudIntegrationServiceAccount, nil
|
||||
}
|
||||
|
||||
func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licenseKey string) (
|
||||
|
||||
@@ -68,8 +68,8 @@
|
||||
"@signozhq/toggle-group": "0.0.1",
|
||||
"@signozhq/tooltip": "0.0.2",
|
||||
"@signozhq/ui": "0.0.5",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-virtual": "3.13.22",
|
||||
"@tanstack/react-table": "8.20.6",
|
||||
"@tanstack/react-virtual": "3.11.2",
|
||||
"@uiw/codemirror-theme-copilot": "4.23.11",
|
||||
"@uiw/codemirror-theme-github": "4.24.1",
|
||||
"@uiw/react-codemirror": "4.23.10",
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { ReactChild, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { matchPath, Redirect, useLocation } from 'react-router-dom';
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import getAll from 'api/v1/user/get';
|
||||
import { useListUsers } from 'api/generated/services/users';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
@@ -12,12 +11,9 @@ import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import history from 'lib/history';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
|
||||
import { OrgPreference } from 'types/api/preferences/preference';
|
||||
import { Organization } from 'types/api/user/getOrganization';
|
||||
import { UserResponse } from 'types/api/user/getUser';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { routePermission } from 'utils/permission';
|
||||
|
||||
@@ -63,18 +59,10 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
|
||||
const [orgData, setOrgData] = useState<Organization | undefined>(undefined);
|
||||
|
||||
const { data: usersData, isFetching: isFetchingUsers } = useQuery<
|
||||
SuccessResponseV2<UserResponse[]> | undefined,
|
||||
APIError
|
||||
>({
|
||||
queryFn: () => {
|
||||
if (orgData && orgData.id !== undefined) {
|
||||
return getAll();
|
||||
}
|
||||
return undefined;
|
||||
const { data: usersData, isFetching: isFetchingUsers } = useListUsers({
|
||||
query: {
|
||||
enabled: !isEmpty(orgData) && user.role === 'ADMIN',
|
||||
},
|
||||
queryKey: ['getOrgUser'],
|
||||
enabled: !isEmpty(orgData) && user.role === 'ADMIN',
|
||||
});
|
||||
|
||||
const checkFirstTimeUser = useCallback((): boolean => {
|
||||
|
||||
@@ -306,11 +306,19 @@ describe('PrivateRoute', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should redirect /settings/access-tokens to /settings/api-keys', () => {
|
||||
it('should redirect /settings/access-tokens to /settings/service-accounts', () => {
|
||||
renderPrivateRoute({ initialRoute: '/settings/access-tokens' });
|
||||
|
||||
expect(screen.getByTestId('location-display')).toHaveTextContent(
|
||||
'/settings/api-keys',
|
||||
'/settings/service-accounts',
|
||||
);
|
||||
});
|
||||
|
||||
it('should redirect /settings/api-keys to /settings/service-accounts', () => {
|
||||
renderPrivateRoute({ initialRoute: '/settings/api-keys' });
|
||||
|
||||
expect(screen.getByTestId('location-display')).toHaveTextContent(
|
||||
'/settings/service-accounts',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -157,10 +157,6 @@ export const IngestionSettings = Loadable(
|
||||
() => import(/* webpackChunkName: "Ingestion Settings" */ 'pages/Settings'),
|
||||
);
|
||||
|
||||
export const APIKeys = Loadable(
|
||||
() => import(/* webpackChunkName: "All Settings" */ 'pages/Settings'),
|
||||
);
|
||||
|
||||
export const MySettings = Loadable(
|
||||
() => import(/* webpackChunkName: "All MySettings" */ 'pages/Settings'),
|
||||
);
|
||||
|
||||
@@ -513,6 +513,7 @@ export const oldRoutes = [
|
||||
'/logs-save-views',
|
||||
'/traces-save-views',
|
||||
'/settings/access-tokens',
|
||||
'/settings/api-keys',
|
||||
'/messaging-queues',
|
||||
'/alerts/edit',
|
||||
];
|
||||
@@ -523,7 +524,8 @@ export const oldNewRoutesMapping: Record<string, string> = {
|
||||
'/logs-explorer/live': '/logs/logs-explorer/live',
|
||||
'/logs-save-views': '/logs/saved-views',
|
||||
'/traces-save-views': '/traces/saved-views',
|
||||
'/settings/access-tokens': '/settings/api-keys',
|
||||
'/settings/access-tokens': '/settings/service-accounts',
|
||||
'/settings/api-keys': '/settings/service-accounts',
|
||||
'/messaging-queues': '/messaging-queues/overview',
|
||||
'/alerts/edit': '/alerts/overview',
|
||||
};
|
||||
|
||||
@@ -23,9 +23,15 @@ import type {
|
||||
CreateServiceAccount201,
|
||||
CreateServiceAccountKey201,
|
||||
CreateServiceAccountKeyPathParameters,
|
||||
CreateServiceAccountRole201,
|
||||
CreateServiceAccountRolePathParameters,
|
||||
DeleteServiceAccountPathParameters,
|
||||
DeleteServiceAccountRolePathParameters,
|
||||
GetMyServiceAccount200,
|
||||
GetServiceAccount200,
|
||||
GetServiceAccountPathParameters,
|
||||
GetServiceAccountRoles200,
|
||||
GetServiceAccountRolesPathParameters,
|
||||
ListServiceAccountKeys200,
|
||||
ListServiceAccountKeysPathParameters,
|
||||
ListServiceAccounts200,
|
||||
@@ -33,12 +39,10 @@ import type {
|
||||
RevokeServiceAccountKeyPathParameters,
|
||||
ServiceaccounttypesPostableFactorAPIKeyDTO,
|
||||
ServiceaccounttypesPostableServiceAccountDTO,
|
||||
ServiceaccounttypesPostableServiceAccountRoleDTO,
|
||||
ServiceaccounttypesUpdatableFactorAPIKeyDTO,
|
||||
ServiceaccounttypesUpdatableServiceAccountDTO,
|
||||
ServiceaccounttypesUpdatableServiceAccountStatusDTO,
|
||||
UpdateServiceAccountKeyPathParameters,
|
||||
UpdateServiceAccountPathParameters,
|
||||
UpdateServiceAccountStatusPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
/**
|
||||
@@ -399,13 +403,13 @@ export const invalidateGetServiceAccount = async (
|
||||
*/
|
||||
export const updateServiceAccount = (
|
||||
{ id }: UpdateServiceAccountPathParameters,
|
||||
serviceaccounttypesUpdatableServiceAccountDTO: BodyType<ServiceaccounttypesUpdatableServiceAccountDTO>,
|
||||
serviceaccounttypesPostableServiceAccountDTO: BodyType<ServiceaccounttypesPostableServiceAccountDTO>,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/service_accounts/${id}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: serviceaccounttypesUpdatableServiceAccountDTO,
|
||||
data: serviceaccounttypesPostableServiceAccountDTO,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -418,7 +422,7 @@ export const getUpdateServiceAccountMutationOptions = <
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateServiceAccountPathParameters;
|
||||
data: BodyType<ServiceaccounttypesUpdatableServiceAccountDTO>;
|
||||
data: BodyType<ServiceaccounttypesPostableServiceAccountDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
@@ -427,7 +431,7 @@ export const getUpdateServiceAccountMutationOptions = <
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateServiceAccountPathParameters;
|
||||
data: BodyType<ServiceaccounttypesUpdatableServiceAccountDTO>;
|
||||
data: BodyType<ServiceaccounttypesPostableServiceAccountDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
@@ -444,7 +448,7 @@ export const getUpdateServiceAccountMutationOptions = <
|
||||
Awaited<ReturnType<typeof updateServiceAccount>>,
|
||||
{
|
||||
pathParams: UpdateServiceAccountPathParameters;
|
||||
data: BodyType<ServiceaccounttypesUpdatableServiceAccountDTO>;
|
||||
data: BodyType<ServiceaccounttypesPostableServiceAccountDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
@@ -458,7 +462,7 @@ export const getUpdateServiceAccountMutationOptions = <
|
||||
export type UpdateServiceAccountMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateServiceAccount>>
|
||||
>;
|
||||
export type UpdateServiceAccountMutationBody = BodyType<ServiceaccounttypesUpdatableServiceAccountDTO>;
|
||||
export type UpdateServiceAccountMutationBody = BodyType<ServiceaccounttypesPostableServiceAccountDTO>;
|
||||
export type UpdateServiceAccountMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
@@ -473,7 +477,7 @@ export const useUpdateServiceAccount = <
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateServiceAccountPathParameters;
|
||||
data: BodyType<ServiceaccounttypesUpdatableServiceAccountDTO>;
|
||||
data: BodyType<ServiceaccounttypesPostableServiceAccountDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
@@ -482,7 +486,7 @@ export const useUpdateServiceAccount = <
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateServiceAccountPathParameters;
|
||||
data: BodyType<ServiceaccounttypesUpdatableServiceAccountDTO>;
|
||||
data: BodyType<ServiceaccounttypesPostableServiceAccountDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
@@ -871,44 +875,150 @@ export const useUpdateServiceAccountKey = <
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint updates an existing service account status
|
||||
* @summary Updates a service account status
|
||||
* This endpoint gets all the roles for the existing service account
|
||||
* @summary Gets service account roles
|
||||
*/
|
||||
export const updateServiceAccountStatus = (
|
||||
{ id }: UpdateServiceAccountStatusPathParameters,
|
||||
serviceaccounttypesUpdatableServiceAccountStatusDTO: BodyType<ServiceaccounttypesUpdatableServiceAccountStatusDTO>,
|
||||
export const getServiceAccountRoles = (
|
||||
{ id }: GetServiceAccountRolesPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/service_accounts/${id}/status`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: serviceaccounttypesUpdatableServiceAccountStatusDTO,
|
||||
return GeneratedAPIInstance<GetServiceAccountRoles200>({
|
||||
url: `/api/v1/service_accounts/${id}/roles`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateServiceAccountStatusMutationOptions = <
|
||||
export const getGetServiceAccountRolesQueryKey = ({
|
||||
id,
|
||||
}: GetServiceAccountRolesPathParameters) => {
|
||||
return [`/api/v1/service_accounts/${id}/roles`] as const;
|
||||
};
|
||||
|
||||
export const getGetServiceAccountRolesQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getServiceAccountRoles>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(
|
||||
{ id }: GetServiceAccountRolesPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getServiceAccountRoles>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetServiceAccountRolesQueryKey({ id });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getServiceAccountRoles>>
|
||||
> = ({ signal }) => getServiceAccountRoles({ id }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getServiceAccountRoles>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetServiceAccountRolesQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getServiceAccountRoles>>
|
||||
>;
|
||||
export type GetServiceAccountRolesQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Gets service account roles
|
||||
*/
|
||||
|
||||
export function useGetServiceAccountRoles<
|
||||
TData = Awaited<ReturnType<typeof getServiceAccountRoles>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(
|
||||
{ id }: GetServiceAccountRolesPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getServiceAccountRoles>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetServiceAccountRolesQueryOptions({ id }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
query.queryKey = queryOptions.queryKey;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Gets service account roles
|
||||
*/
|
||||
export const invalidateGetServiceAccountRoles = async (
|
||||
queryClient: QueryClient,
|
||||
{ id }: GetServiceAccountRolesPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetServiceAccountRolesQueryKey({ id }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint assigns a role to a service account
|
||||
* @summary Create service account role
|
||||
*/
|
||||
export const createServiceAccountRole = (
|
||||
{ id }: CreateServiceAccountRolePathParameters,
|
||||
serviceaccounttypesPostableServiceAccountRoleDTO: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateServiceAccountRole201>({
|
||||
url: `/api/v1/service_accounts/${id}/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 updateServiceAccountStatus>>,
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateServiceAccountStatusPathParameters;
|
||||
data: BodyType<ServiceaccounttypesUpdatableServiceAccountStatusDTO>;
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateServiceAccountStatus>>,
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateServiceAccountStatusPathParameters;
|
||||
data: BodyType<ServiceaccounttypesUpdatableServiceAccountStatusDTO>;
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateServiceAccountStatus'];
|
||||
const mutationKey = ['createServiceAccountRole'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
@@ -918,52 +1028,299 @@ export const getUpdateServiceAccountStatusMutationOptions = <
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof updateServiceAccountStatus>>,
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
{
|
||||
pathParams: UpdateServiceAccountStatusPathParameters;
|
||||
data: BodyType<ServiceaccounttypesUpdatableServiceAccountStatusDTO>;
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateServiceAccountStatus(pathParams, data);
|
||||
return createServiceAccountRole(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateServiceAccountStatusMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateServiceAccountStatus>>
|
||||
export type CreateServiceAccountRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>
|
||||
>;
|
||||
export type UpdateServiceAccountStatusMutationBody = BodyType<ServiceaccounttypesUpdatableServiceAccountStatusDTO>;
|
||||
export type UpdateServiceAccountStatusMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
export type CreateServiceAccountRoleMutationBody = BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
export type CreateServiceAccountRoleMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Updates a service account status
|
||||
* @summary Create service account role
|
||||
*/
|
||||
export const useUpdateServiceAccountStatus = <
|
||||
export const useCreateServiceAccountRole = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateServiceAccountStatus>>,
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateServiceAccountStatusPathParameters;
|
||||
data: BodyType<ServiceaccounttypesUpdatableServiceAccountStatusDTO>;
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateServiceAccountStatus>>,
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateServiceAccountStatusPathParameters;
|
||||
data: BodyType<ServiceaccounttypesUpdatableServiceAccountStatusDTO>;
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getUpdateServiceAccountStatusMutationOptions(options);
|
||||
const mutationOptions = getCreateServiceAccountRoleMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint revokes a role from service account
|
||||
* @summary Delete service account role
|
||||
*/
|
||||
export const deleteServiceAccountRole = ({
|
||||
id,
|
||||
rid,
|
||||
}: DeleteServiceAccountRolePathParameters) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/service_accounts/${id}/roles/${rid}`,
|
||||
method: 'DELETE',
|
||||
});
|
||||
};
|
||||
|
||||
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
|
||||
> => {
|
||||
const mutationOptions = getDeleteServiceAccountRoleMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint gets my service account
|
||||
* @summary Gets my service account
|
||||
*/
|
||||
export const getMyServiceAccount = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<GetMyServiceAccount200>({
|
||||
url: `/api/v1/service_accounts/me`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetMyServiceAccountQueryKey = () => {
|
||||
return [`/api/v1/service_accounts/me`] as const;
|
||||
};
|
||||
|
||||
export const getGetMyServiceAccountQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getMyServiceAccount>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMyServiceAccount>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetMyServiceAccountQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getMyServiceAccount>>
|
||||
> = ({ signal }) => getMyServiceAccount(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMyServiceAccount>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetMyServiceAccountQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getMyServiceAccount>>
|
||||
>;
|
||||
export type GetMyServiceAccountQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Gets my service account
|
||||
*/
|
||||
|
||||
export function useGetMyServiceAccount<
|
||||
TData = Awaited<ReturnType<typeof getMyServiceAccount>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMyServiceAccount>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetMyServiceAccountQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
query.queryKey = queryOptions.queryKey;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Gets my service account
|
||||
*/
|
||||
export const invalidateGetMyServiceAccount = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetMyServiceAccountQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint gets my service account
|
||||
* @summary Updates my service account
|
||||
*/
|
||||
export const updateMyServiceAccount = (
|
||||
serviceaccounttypesPostableServiceAccountDTO: BodyType<ServiceaccounttypesPostableServiceAccountDTO>,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/service_accounts/me`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: serviceaccounttypesPostableServiceAccountDTO,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateMyServiceAccountMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateMyServiceAccount>>,
|
||||
TError,
|
||||
{ data: BodyType<ServiceaccounttypesPostableServiceAccountDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateMyServiceAccount>>,
|
||||
TError,
|
||||
{ data: BodyType<ServiceaccounttypesPostableServiceAccountDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateMyServiceAccount'];
|
||||
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 updateMyServiceAccount>>,
|
||||
{ data: BodyType<ServiceaccounttypesPostableServiceAccountDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return updateMyServiceAccount(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateMyServiceAccountMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateMyServiceAccount>>
|
||||
>;
|
||||
export type UpdateMyServiceAccountMutationBody = BodyType<ServiceaccounttypesPostableServiceAccountDTO>;
|
||||
export type UpdateMyServiceAccountMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Updates my service account
|
||||
*/
|
||||
export const useUpdateMyServiceAccount = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateMyServiceAccount>>,
|
||||
TError,
|
||||
{ data: BodyType<ServiceaccounttypesPostableServiceAccountDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateMyServiceAccount>>,
|
||||
TError,
|
||||
{ data: BodyType<ServiceaccounttypesPostableServiceAccountDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getUpdateMyServiceAccountMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
|
||||
@@ -2843,7 +2843,7 @@ export interface RulestatehistorytypesGettableRuleStateWindowDTO {
|
||||
state: RulestatehistorytypesAlertStateDTO;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesFactorAPIKeyDTO {
|
||||
export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
@@ -2858,10 +2858,6 @@ export interface ServiceaccounttypesFactorAPIKeyDTO {
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
@@ -2909,15 +2905,14 @@ export interface ServiceaccounttypesPostableServiceAccountDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesPostableServiceAccountRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
roles: string[];
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesServiceAccountDTO {
|
||||
@@ -2926,11 +2921,65 @@ export interface ServiceaccounttypesServiceAccountDTO {
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
email: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
deletedAt: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesServiceAccountRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
role: AuthtypesRoleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
roleId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
serviceAccountId: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesServiceAccountWithRolesDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2949,8 +2998,9 @@ export interface ServiceaccounttypesServiceAccountDTO {
|
||||
orgId: string;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
roles: string[];
|
||||
serviceAccountRoles: ServiceaccounttypesServiceAccountRoleDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2974,28 +3024,6 @@ export interface ServiceaccounttypesUpdatableFactorAPIKeyDTO {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesUpdatableServiceAccountDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
email: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesUpdatableServiceAccountStatusDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
}
|
||||
|
||||
export enum TelemetrytypesFieldContextDTO {
|
||||
metric = 'metric',
|
||||
log = 'log',
|
||||
@@ -3139,63 +3167,6 @@ export interface TypesDeprecatedUserDTO {
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface TypesGettableAPIKeyDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createdBy?: string;
|
||||
createdByUser?: TypesUserDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
expiresAt?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
lastUsed?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
revoked?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
role?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
token?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
updatedByUser?: TypesUserDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface TypesIdentifiableDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -3278,22 +3249,6 @@ export interface TypesOrganizationDTO {
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface TypesPostableAPIKeyDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
expiresInDays?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface TypesPostableBulkInviteRequestDTO {
|
||||
/**
|
||||
* @type array
|
||||
@@ -3373,51 +3328,6 @@ export interface TypesResetPasswordTokenDTO {
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface TypesStorableAPIKeyDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createdBy?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
revoked?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
role?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
token?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface TypesUpdatableUserDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -3956,31 +3866,6 @@ export type GetOrgPreference200 = {
|
||||
export type UpdateOrgPreferencePathParameters = {
|
||||
name: string;
|
||||
};
|
||||
export type ListAPIKeys200 = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
data: TypesGettableAPIKeyDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateAPIKey201 = {
|
||||
data: TypesGettableAPIKeyDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type RevokeAPIKeyPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type UpdateAPIKeyPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetPublicDashboardDataPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
@@ -4085,7 +3970,7 @@ export type GetServiceAccountPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetServiceAccount200 = {
|
||||
data: ServiceaccounttypesServiceAccountDTO;
|
||||
data: ServiceaccounttypesServiceAccountWithRolesDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4102,7 +3987,7 @@ export type ListServiceAccountKeys200 = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
data: ServiceaccounttypesFactorAPIKeyDTO[];
|
||||
data: ServiceaccounttypesGettableFactorAPIKeyDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4128,9 +4013,44 @@ export type UpdateServiceAccountKeyPathParameters = {
|
||||
id: string;
|
||||
fid: string;
|
||||
};
|
||||
export type UpdateServiceAccountStatusPathParameters = {
|
||||
export type GetServiceAccountRolesPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetServiceAccountRoles200 = {
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
data: AuthtypesRoleDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateServiceAccountRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type CreateServiceAccountRole201 = {
|
||||
data: TypesIdentifiableDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteServiceAccountRolePathParameters = {
|
||||
id: string;
|
||||
rid: string;
|
||||
};
|
||||
export type GetMyServiceAccount200 = {
|
||||
data: ServiceaccounttypesServiceAccountWithRolesDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListUsersDeprecated200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -21,7 +21,6 @@ import type { BodyType, ErrorType } from '../../../generatedAPIInstance';
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type {
|
||||
ChangePasswordPathParameters,
|
||||
CreateAPIKey201,
|
||||
CreateInvite201,
|
||||
DeleteUserPathParameters,
|
||||
GetMyUser200,
|
||||
@@ -36,24 +35,19 @@ import type {
|
||||
GetUserPathParameters,
|
||||
GetUsersByRoleID200,
|
||||
GetUsersByRoleIDPathParameters,
|
||||
ListAPIKeys200,
|
||||
ListUsers200,
|
||||
ListUsersDeprecated200,
|
||||
RemoveUserRoleByUserIDAndRoleIDPathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
RevokeAPIKeyPathParameters,
|
||||
SetRoleByUserIDPathParameters,
|
||||
TypesChangePasswordRequestDTO,
|
||||
TypesDeprecatedUserDTO,
|
||||
TypesPostableAPIKeyDTO,
|
||||
TypesPostableBulkInviteRequestDTO,
|
||||
TypesPostableForgotPasswordDTO,
|
||||
TypesPostableInviteDTO,
|
||||
TypesPostableResetPasswordDTO,
|
||||
TypesPostableRoleDTO,
|
||||
TypesStorableAPIKeyDTO,
|
||||
TypesUpdatableUserDTO,
|
||||
UpdateAPIKeyPathParameters,
|
||||
UpdateUserDeprecated200,
|
||||
UpdateUserDeprecatedPathParameters,
|
||||
UpdateUserPathParameters,
|
||||
@@ -428,349 +422,6 @@ export const useCreateBulkInvite = <
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint lists all api keys
|
||||
* @summary List api keys
|
||||
*/
|
||||
export const listAPIKeys = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<ListAPIKeys200>({
|
||||
url: `/api/v1/pats`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListAPIKeysQueryKey = () => {
|
||||
return [`/api/v1/pats`] as const;
|
||||
};
|
||||
|
||||
export const getListAPIKeysQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listAPIKeys>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listAPIKeys>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListAPIKeysQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof listAPIKeys>>> = ({
|
||||
signal,
|
||||
}) => listAPIKeys(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listAPIKeys>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListAPIKeysQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listAPIKeys>>
|
||||
>;
|
||||
export type ListAPIKeysQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List api keys
|
||||
*/
|
||||
|
||||
export function useListAPIKeys<
|
||||
TData = Awaited<ReturnType<typeof listAPIKeys>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listAPIKeys>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListAPIKeysQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
query.queryKey = queryOptions.queryKey;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List api keys
|
||||
*/
|
||||
export const invalidateListAPIKeys = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListAPIKeysQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint creates an api key
|
||||
* @summary Create api key
|
||||
*/
|
||||
export const createAPIKey = (
|
||||
typesPostableAPIKeyDTO: BodyType<TypesPostableAPIKeyDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateAPIKey201>({
|
||||
url: `/api/v1/pats`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: typesPostableAPIKeyDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateAPIKeyMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createAPIKey>>,
|
||||
TError,
|
||||
{ data: BodyType<TypesPostableAPIKeyDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createAPIKey>>,
|
||||
TError,
|
||||
{ data: BodyType<TypesPostableAPIKeyDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createAPIKey'];
|
||||
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 createAPIKey>>,
|
||||
{ data: BodyType<TypesPostableAPIKeyDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createAPIKey(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateAPIKeyMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createAPIKey>>
|
||||
>;
|
||||
export type CreateAPIKeyMutationBody = BodyType<TypesPostableAPIKeyDTO>;
|
||||
export type CreateAPIKeyMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create api key
|
||||
*/
|
||||
export const useCreateAPIKey = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createAPIKey>>,
|
||||
TError,
|
||||
{ data: BodyType<TypesPostableAPIKeyDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createAPIKey>>,
|
||||
TError,
|
||||
{ data: BodyType<TypesPostableAPIKeyDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getCreateAPIKeyMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint revokes an api key
|
||||
* @summary Revoke api key
|
||||
*/
|
||||
export const revokeAPIKey = ({ id }: RevokeAPIKeyPathParameters) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/pats/${id}`,
|
||||
method: 'DELETE',
|
||||
});
|
||||
};
|
||||
|
||||
export const getRevokeAPIKeyMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof revokeAPIKey>>,
|
||||
TError,
|
||||
{ pathParams: RevokeAPIKeyPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof revokeAPIKey>>,
|
||||
TError,
|
||||
{ pathParams: RevokeAPIKeyPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['revokeAPIKey'];
|
||||
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 revokeAPIKey>>,
|
||||
{ pathParams: RevokeAPIKeyPathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return revokeAPIKey(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type RevokeAPIKeyMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof revokeAPIKey>>
|
||||
>;
|
||||
|
||||
export type RevokeAPIKeyMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Revoke api key
|
||||
*/
|
||||
export const useRevokeAPIKey = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof revokeAPIKey>>,
|
||||
TError,
|
||||
{ pathParams: RevokeAPIKeyPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof revokeAPIKey>>,
|
||||
TError,
|
||||
{ pathParams: RevokeAPIKeyPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getRevokeAPIKeyMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint updates an api key
|
||||
* @summary Update api key
|
||||
*/
|
||||
export const updateAPIKey = (
|
||||
{ id }: UpdateAPIKeyPathParameters,
|
||||
typesStorableAPIKeyDTO: BodyType<TypesStorableAPIKeyDTO>,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/pats/${id}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: typesStorableAPIKeyDTO,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateAPIKeyMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateAPIKey>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateAPIKeyPathParameters;
|
||||
data: BodyType<TypesStorableAPIKeyDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateAPIKey>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateAPIKeyPathParameters;
|
||||
data: BodyType<TypesStorableAPIKeyDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateAPIKey'];
|
||||
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 updateAPIKey>>,
|
||||
{
|
||||
pathParams: UpdateAPIKeyPathParameters;
|
||||
data: BodyType<TypesStorableAPIKeyDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateAPIKey(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateAPIKeyMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateAPIKey>>
|
||||
>;
|
||||
export type UpdateAPIKeyMutationBody = BodyType<TypesStorableAPIKeyDTO>;
|
||||
export type UpdateAPIKeyMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update api key
|
||||
*/
|
||||
export const useUpdateAPIKey = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateAPIKey>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateAPIKeyPathParameters;
|
||||
data: BodyType<TypesStorableAPIKeyDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateAPIKey>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateAPIKeyPathParameters;
|
||||
data: BodyType<TypesStorableAPIKeyDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getUpdateAPIKeyMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint resets the password by token
|
||||
* @summary Reset password
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { ApiV2Instance as axios } from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/user/editOrg';
|
||||
|
||||
const editOrg = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
||||
try {
|
||||
const response = await axios.put(`/orgs/me`, {
|
||||
displayName: props.displayName,
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 204,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default editOrg;
|
||||
@@ -1,28 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { PayloadProps } from 'types/api/user/getOrganization';
|
||||
|
||||
const getOrganization = async (
|
||||
token?: string,
|
||||
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
||||
try {
|
||||
const response = await axios.get(`/org`, {
|
||||
headers: {
|
||||
Authorization: `bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default getOrganization;
|
||||
@@ -1,28 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import {
|
||||
APIKeyProps,
|
||||
CreateAPIKeyProps,
|
||||
CreatePayloadProps,
|
||||
} from 'types/api/pat/types';
|
||||
|
||||
const create = async (
|
||||
props: CreateAPIKeyProps,
|
||||
): Promise<SuccessResponseV2<APIKeyProps>> => {
|
||||
try {
|
||||
const response = await axios.post<CreatePayloadProps>('/pats', {
|
||||
...props,
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default create;
|
||||
@@ -1,19 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
|
||||
const deleteAPIKey = async (id: string): Promise<SuccessResponseV2<null>> => {
|
||||
try {
|
||||
const response = await axios.delete(`/pats/${id}`);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: null,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default deleteAPIKey;
|
||||
@@ -1,20 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { AllAPIKeyProps, APIKeyProps } from 'types/api/pat/types';
|
||||
|
||||
const list = async (): Promise<SuccessResponseV2<APIKeyProps[]>> => {
|
||||
try {
|
||||
const response = await axios.get<AllAPIKeyProps>('/pats');
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default list;
|
||||
@@ -1,24 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { UpdateAPIKeyProps } from 'types/api/pat/types';
|
||||
|
||||
const updateAPIKey = async (
|
||||
props: UpdateAPIKeyProps,
|
||||
): Promise<SuccessResponseV2<null>> => {
|
||||
try {
|
||||
const response = await axios.put(`/pats/${props.id}`, {
|
||||
...props.data,
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: null,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default updateAPIKey;
|
||||
@@ -1,21 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { UserResponse } from 'types/api/user/getUser';
|
||||
import { PayloadProps } from 'types/api/user/getUsers';
|
||||
|
||||
const getAll = async (): Promise<SuccessResponseV2<UserResponse[]>> => {
|
||||
try {
|
||||
const response = await axios.get<PayloadProps>(`/user`);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default getAll;
|
||||
@@ -1,22 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props, UserResponse } from 'types/api/user/getUser';
|
||||
|
||||
const getUser = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<UserResponse>> => {
|
||||
try {
|
||||
const response = await axios.get<PayloadProps>(`/user/${props.userId}`);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default getUser;
|
||||
@@ -1,23 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { Props } from 'types/api/user/editUser';
|
||||
|
||||
const update = async (props: Props): Promise<SuccessResponseV2<null>> => {
|
||||
try {
|
||||
const response = await axios.put(`/user/${props.userId}`, {
|
||||
displayName: props.displayName,
|
||||
role: props.role,
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: null,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default update;
|
||||
@@ -1,20 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, UserResponse } from 'types/api/user/getUser';
|
||||
|
||||
const get = async (): Promise<SuccessResponseV2<UserResponse>> => {
|
||||
try {
|
||||
const response = await axios.get<PayloadProps>(`/user/me`);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default get;
|
||||
@@ -12,17 +12,13 @@ import {
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import RolesSelect, { useRoles } from 'components/RolesSelect';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
import { EMAIL_REGEX } from 'utils/app';
|
||||
|
||||
import './CreateServiceAccountModal.styles.scss';
|
||||
|
||||
interface FormValues {
|
||||
name: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
function CreateServiceAccountModal(): JSX.Element {
|
||||
@@ -41,8 +37,6 @@ function CreateServiceAccountModal(): JSX.Element {
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
name: '',
|
||||
email: '',
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -70,13 +64,6 @@ function CreateServiceAccountModal(): JSX.Element {
|
||||
},
|
||||
},
|
||||
});
|
||||
const {
|
||||
roles,
|
||||
isLoading: rolesLoading,
|
||||
isError: rolesError,
|
||||
error: rolesErrorObj,
|
||||
refetch: refetchRoles,
|
||||
} = useRoles();
|
||||
|
||||
function handleClose(): void {
|
||||
reset();
|
||||
@@ -87,8 +74,6 @@ function CreateServiceAccountModal(): JSX.Element {
|
||||
createServiceAccount({
|
||||
data: {
|
||||
name: values.name.trim(),
|
||||
email: values.email.trim(),
|
||||
roles: values.roles,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -134,68 +119,6 @@ function CreateServiceAccountModal(): JSX.Element {
|
||||
<p className="create-sa-form__error">{errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="create-sa-form__item">
|
||||
<label htmlFor="sa-email">Email Address</label>
|
||||
<Controller
|
||||
name="email"
|
||||
control={control}
|
||||
rules={{
|
||||
required: 'Email Address is required',
|
||||
pattern: {
|
||||
value: EMAIL_REGEX,
|
||||
message: 'Please enter a valid email address',
|
||||
},
|
||||
}}
|
||||
render={({ field }): JSX.Element => (
|
||||
<Input
|
||||
id="sa-email"
|
||||
type="email"
|
||||
placeholder="email@example.com"
|
||||
className="create-sa-form__input"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="create-sa-form__error">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="create-sa-form__helper">
|
||||
Used only for notifications about this service account. It is not used for
|
||||
authentication.
|
||||
</p>
|
||||
|
||||
<div className="create-sa-form__item">
|
||||
<label htmlFor="sa-roles">Roles</label>
|
||||
<Controller
|
||||
name="roles"
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (value): string | true =>
|
||||
value.length > 0 || 'At least one role is required',
|
||||
}}
|
||||
render={({ field }): JSX.Element => (
|
||||
<RolesSelect
|
||||
id="sa-roles"
|
||||
mode="multiple"
|
||||
roles={roles}
|
||||
loading={rolesLoading}
|
||||
isError={rolesError}
|
||||
error={rolesErrorObj}
|
||||
onRefetch={refetchRoles}
|
||||
placeholder="Select roles"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.roles && (
|
||||
<p className="create-sa-form__error">{errors.roles.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
@@ -12,7 +11,6 @@ jest.mock('@signozhq/sonner', () => ({
|
||||
|
||||
const mockToast = jest.mocked(toast);
|
||||
|
||||
const ROLES_ENDPOINT = '*/api/v1/roles';
|
||||
const SERVICE_ACCOUNTS_ENDPOINT = '*/api/v1/service_accounts';
|
||||
|
||||
function renderModal(): ReturnType<typeof render> {
|
||||
@@ -27,9 +25,6 @@ describe('CreateServiceAccountModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
rest.post(SERVICE_ACCOUNTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
@@ -48,38 +43,11 @@ describe('CreateServiceAccountModal', () => {
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('submit button remains disabled when email is invalid', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Enter a name'), 'My Bot');
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('email@example.com'),
|
||||
'not-an-email',
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Select roles'));
|
||||
await user.click(await screen.findByTitle('signoz-admin'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Create Service Account/i }),
|
||||
).toBeDisabled(),
|
||||
);
|
||||
});
|
||||
|
||||
it('successful submit shows toast.success and closes modal', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Enter a name'), 'Deploy Bot');
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('email@example.com'),
|
||||
'deploy@acme.io',
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Select roles'));
|
||||
await user.click(await screen.findByTitle('signoz-admin'));
|
||||
|
||||
const submitBtn = screen.getByRole('button', {
|
||||
name: /Create Service Account/i,
|
||||
@@ -116,13 +84,6 @@ describe('CreateServiceAccountModal', () => {
|
||||
renderModal();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Enter a name'), 'Dupe Bot');
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('email@example.com'),
|
||||
'dupe@acme.io',
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Select roles'));
|
||||
await user.click(await screen.findByTitle('signoz-admin'));
|
||||
|
||||
const submitBtn = screen.getByRole('button', {
|
||||
name: /Create Service Account/i,
|
||||
@@ -164,16 +125,4 @@ describe('CreateServiceAccountModal', () => {
|
||||
|
||||
await screen.findByText('Name is required');
|
||||
});
|
||||
|
||||
it('shows "Please enter a valid email address" for a malformed email', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('email@example.com'),
|
||||
'not-an-email',
|
||||
);
|
||||
|
||||
await screen.findByText('Please enter a valid email address');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,22 +28,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
// In table/column view, keep action buttons visible at the viewport's right edge
|
||||
.log-line-action-buttons.table-view-log-actions {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 8px;
|
||||
left: auto;
|
||||
transform: translateY(-50%);
|
||||
margin: 0;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.log-line-action-buttons {
|
||||
border: 1px solid var(--bg-vanilla-400);
|
||||
background: var(--bg-vanilla-400);
|
||||
|
||||
.ant-btn-default {
|
||||
}
|
||||
|
||||
.copy-log-btn {
|
||||
border-left: 1px solid var(--bg-vanilla-400);
|
||||
border-color: var(--bg-vanilla-400) !important;
|
||||
|
||||
@@ -15,12 +15,13 @@ export function getDefaultCellStyle(isDarkMode?: boolean): CSSProperties {
|
||||
letterSpacing: '-0.07px',
|
||||
marginBottom: '0px',
|
||||
minWidth: '10rem',
|
||||
width: 'auto',
|
||||
width: '10rem',
|
||||
};
|
||||
}
|
||||
|
||||
export const defaultTableStyle: CSSProperties = {
|
||||
minWidth: '40rem',
|
||||
maxWidth: '90rem',
|
||||
};
|
||||
|
||||
export const defaultListViewPanelStyle: CSSProperties = {
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
p {
|
||||
.ant-typography {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,6 @@
|
||||
}
|
||||
|
||||
.paragraph {
|
||||
margin: 0;
|
||||
padding: 0px !important;
|
||||
&.small {
|
||||
font-size: 11px !important;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { TableColumnsType as ColumnsType } from 'antd';
|
||||
import { TableColumnsType as ColumnsType, Typography } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
|
||||
@@ -43,7 +43,7 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
|
||||
const bodyColumnStyle = useMemo(
|
||||
() => ({
|
||||
...defaultTableStyle,
|
||||
...(fields.length > 2 ? { width: 'auto' } : {}),
|
||||
...(fields.length > 2 ? { width: '50rem' } : {}),
|
||||
}),
|
||||
[fields.length],
|
||||
);
|
||||
@@ -63,7 +63,14 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
|
||||
? defaultListViewPanelStyle
|
||||
: getDefaultCellStyle(isDarkMode),
|
||||
},
|
||||
children: <p className={cx('paragraph', fontSize)}>{field}</p>,
|
||||
children: (
|
||||
<Typography.Paragraph
|
||||
ellipsis={{ rows: linesPerRow }}
|
||||
className={cx('paragraph', fontSize)}
|
||||
>
|
||||
{field}
|
||||
</Typography.Paragraph>
|
||||
),
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -116,7 +123,9 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
|
||||
return {
|
||||
children: (
|
||||
<div className="table-timestamp">
|
||||
<p className={cx('text', fontSize)}>{date}</p>
|
||||
<Typography.Paragraph ellipsis className={cx('text', fontSize)}>
|
||||
{date}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -4,9 +4,7 @@ import { Table, Tooltip } from 'antd';
|
||||
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { MemberStatus } from 'container/MembersSettings/utils';
|
||||
import { capitalize } from 'lodash-es';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { ROLES } from 'types/roles';
|
||||
|
||||
import './MembersTable.styles.scss';
|
||||
|
||||
@@ -14,7 +12,6 @@ export interface MemberRow {
|
||||
id: string;
|
||||
name?: string;
|
||||
email: string;
|
||||
role: ROLES;
|
||||
status: MemberStatus;
|
||||
joinedOn: string | null;
|
||||
updatedAt?: string | null;
|
||||
@@ -141,17 +138,6 @@ function MembersTable({
|
||||
<NameEmailCell name={record.name} email={record.email} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Roles',
|
||||
dataIndex: 'role',
|
||||
key: 'role',
|
||||
width: 180,
|
||||
sorter: (a, b): number => a.role.localeCompare(b.role),
|
||||
render: (role: ROLES): JSX.Element => (
|
||||
<Badge color="vanilla">{capitalize(role)}</Badge>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
|
||||
@@ -1,35 +1,21 @@
|
||||
.quick-filters-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
|
||||
.quick-filters-settings-container {
|
||||
flex: 0 0 0;
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
align-self: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-filters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
border-right: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
|
||||
.overlay-scrollbar {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 999;
|
||||
width: 342px;
|
||||
background: var(--bg-slate-500);
|
||||
|
||||
@@ -34,7 +34,7 @@ export function useRoles(): {
|
||||
export function getRoleOptions(roles: AuthtypesRoleDTO[]): RoleOption[] {
|
||||
return roles.map((role) => ({
|
||||
label: role.name ?? '',
|
||||
value: role.name ?? '',
|
||||
value: role.id ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
|
||||
import { PowerOff, X } from '@signozhq/icons';
|
||||
import { Trash2, X } from '@signozhq/icons';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
getGetServiceAccountQueryKey,
|
||||
invalidateListServiceAccounts,
|
||||
useUpdateServiceAccountStatus,
|
||||
useDeleteServiceAccount,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import type {
|
||||
RenderErrorResponseDTO,
|
||||
@@ -17,14 +17,14 @@ import { AxiosError } from 'axios';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
|
||||
function DisableAccountModal(): JSX.Element {
|
||||
function DeleteAccountModal(): JSX.Element {
|
||||
const queryClient = useQueryClient();
|
||||
const [accountId, setAccountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
|
||||
const [isDisableOpen, setIsDisableOpen] = useQueryState(
|
||||
SA_QUERY_PARAMS.DISABLE_SA,
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useQueryState(
|
||||
SA_QUERY_PARAMS.DELETE_SA,
|
||||
parseAsBoolean.withDefault(false),
|
||||
);
|
||||
const open = !!isDisableOpen && !!accountId;
|
||||
const open = !!isDeleteOpen && !!accountId;
|
||||
|
||||
const cachedAccount = accountId
|
||||
? queryClient.getQueryData<{
|
||||
@@ -34,13 +34,13 @@ function DisableAccountModal(): JSX.Element {
|
||||
const accountName = cachedAccount?.data?.name;
|
||||
|
||||
const {
|
||||
mutate: updateStatus,
|
||||
isLoading: isDisabling,
|
||||
} = useUpdateServiceAccountStatus({
|
||||
mutate: deleteAccount,
|
||||
isLoading: isDeleting,
|
||||
} = useDeleteServiceAccount({
|
||||
mutation: {
|
||||
onSuccess: async () => {
|
||||
toast.success('Service account disabled', { richColors: true });
|
||||
await setIsDisableOpen(null);
|
||||
toast.success('Service account deleted', { richColors: true });
|
||||
await setIsDeleteOpen(null);
|
||||
await setAccountId(null);
|
||||
await invalidateListServiceAccounts(queryClient);
|
||||
},
|
||||
@@ -48,7 +48,7 @@ function DisableAccountModal(): JSX.Element {
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'Failed to disable service account';
|
||||
)?.getErrorMessage() || 'Failed to delete service account';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
},
|
||||
},
|
||||
@@ -58,14 +58,13 @@ function DisableAccountModal(): JSX.Element {
|
||||
if (!accountId) {
|
||||
return;
|
||||
}
|
||||
updateStatus({
|
||||
deleteAccount({
|
||||
pathParams: { id: accountId },
|
||||
data: { status: 'DISABLED' },
|
||||
});
|
||||
}
|
||||
|
||||
function handleCancel(): void {
|
||||
setIsDisableOpen(null);
|
||||
setIsDeleteOpen(null);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -76,17 +75,18 @@ function DisableAccountModal(): JSX.Element {
|
||||
handleCancel();
|
||||
}
|
||||
}}
|
||||
title={`Disable service account ${accountName ?? ''}?`}
|
||||
title={`Delete service account ${accountName ?? ''}?`}
|
||||
width="narrow"
|
||||
className="alert-dialog sa-disable-dialog"
|
||||
className="alert-dialog sa-delete-dialog"
|
||||
showCloseButton={false}
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
<p className="sa-disable-dialog__body">
|
||||
Disabling this service account will revoke access for all its keys. Any
|
||||
systems using this account will lose access immediately.
|
||||
<p className="sa-delete-dialog__body">
|
||||
Are you sure you want to delete <strong>{accountName}</strong>? This action
|
||||
cannot be undone. All keys associated with this service account will be
|
||||
permanently removed.
|
||||
</p>
|
||||
<DialogFooter className="sa-disable-dialog__footer">
|
||||
<DialogFooter className="sa-delete-dialog__footer">
|
||||
<Button variant="solid" color="secondary" size="sm" onClick={handleCancel}>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
@@ -95,15 +95,15 @@ function DisableAccountModal(): JSX.Element {
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
size="sm"
|
||||
loading={isDisabling}
|
||||
loading={isDeleting}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
<PowerOff size={12} />
|
||||
Disable
|
||||
<Trash2 size={12} />
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default DisableAccountModal;
|
||||
export default DeleteAccountModal;
|
||||
@@ -6,7 +6,7 @@ import { LockKeyhole, Trash2, X } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
|
||||
import { DatePicker } from 'antd';
|
||||
import type { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { disabledDate, formatLastObservedAt } from '../utils';
|
||||
@@ -17,7 +17,7 @@ export interface EditKeyFormProps {
|
||||
register: UseFormRegister<FormValues>;
|
||||
control: Control<FormValues>;
|
||||
expiryMode: ExpiryMode;
|
||||
keyItem: ServiceaccounttypesFactorAPIKeyDTO | null;
|
||||
keyItem: ServiceaccounttypesGettableFactorAPIKeyDTO | null;
|
||||
isSaving: boolean;
|
||||
isDirty: boolean;
|
||||
onSubmit: () => void;
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import type {
|
||||
RenderErrorResponseDTO,
|
||||
ServiceaccounttypesFactorAPIKeyDTO,
|
||||
ServiceaccounttypesGettableFactorAPIKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
@@ -27,7 +27,7 @@ import { DEFAULT_FORM_VALUES, ExpiryMode } from './types';
|
||||
import './EditKeyModal.styles.scss';
|
||||
|
||||
export interface EditKeyModalProps {
|
||||
keyItem: ServiceaccounttypesFactorAPIKeyDTO | null;
|
||||
keyItem: ServiceaccounttypesGettableFactorAPIKeyDTO | null;
|
||||
}
|
||||
|
||||
function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Button } from '@signozhq/button';
|
||||
import { KeyRound, X } from '@signozhq/icons';
|
||||
import { Skeleton, Table, Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table/interface';
|
||||
import type { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs';
|
||||
@@ -14,7 +14,7 @@ import RevokeKeyModal from './RevokeKeyModal';
|
||||
import { formatLastObservedAt } from './utils';
|
||||
|
||||
interface KeysTabProps {
|
||||
keys: ServiceaccounttypesFactorAPIKeyDTO[];
|
||||
keys: ServiceaccounttypesGettableFactorAPIKeyDTO[];
|
||||
isLoading: boolean;
|
||||
isDisabled?: boolean;
|
||||
currentPage: number;
|
||||
@@ -44,7 +44,7 @@ function buildColumns({
|
||||
isDisabled,
|
||||
onRevokeClick,
|
||||
handleformatLastObservedAt,
|
||||
}: BuildColumnsParams): ColumnsType<ServiceaccounttypesFactorAPIKeyDTO> {
|
||||
}: BuildColumnsParams): ColumnsType<ServiceaccounttypesGettableFactorAPIKeyDTO> {
|
||||
return [
|
||||
{
|
||||
title: 'Name',
|
||||
@@ -183,7 +183,7 @@ function KeysTab({
|
||||
return (
|
||||
<>
|
||||
{/* Todo: use new table component from periscope when ready */}
|
||||
<Table<ServiceaccounttypesFactorAPIKeyDTO>
|
||||
<Table<ServiceaccounttypesGettableFactorAPIKeyDTO>
|
||||
columns={columns}
|
||||
dataSource={keys}
|
||||
rowKey="id"
|
||||
|
||||
@@ -9,6 +9,9 @@ import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import SaveErrorItem from './SaveErrorItem';
|
||||
import type { SaveError } from './utils';
|
||||
|
||||
interface OverviewTabProps {
|
||||
account: ServiceAccountRow;
|
||||
localName: string;
|
||||
@@ -21,6 +24,7 @@ interface OverviewTabProps {
|
||||
rolesError?: boolean;
|
||||
rolesErrorObj?: APIError | undefined;
|
||||
onRefetchRoles?: () => void;
|
||||
saveErrors?: SaveError[];
|
||||
}
|
||||
|
||||
function OverviewTab({
|
||||
@@ -35,6 +39,7 @@ function OverviewTab({
|
||||
rolesError,
|
||||
rolesErrorObj,
|
||||
onRefetchRoles,
|
||||
saveErrors = [],
|
||||
}: OverviewTabProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
@@ -92,11 +97,14 @@ function OverviewTab({
|
||||
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
|
||||
<div className="sa-drawer__disabled-roles">
|
||||
{localRoles.length > 0 ? (
|
||||
localRoles.map((r) => (
|
||||
<Badge key={r} color="vanilla">
|
||||
{r}
|
||||
</Badge>
|
||||
))
|
||||
localRoles.map((roleId) => {
|
||||
const role = availableRoles.find((r) => r.id === roleId);
|
||||
return (
|
||||
<Badge key={roleId} color="vanilla">
|
||||
{role?.name ?? roleId}
|
||||
</Badge>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span className="sa-drawer__input-text">—</span>
|
||||
)}
|
||||
@@ -126,9 +134,13 @@ function OverviewTab({
|
||||
<Badge color="forest" variant="outline">
|
||||
ACTIVE
|
||||
</Badge>
|
||||
) : account.status?.toUpperCase() === 'DELETED' ? (
|
||||
<Badge color="cherry" variant="outline">
|
||||
DELETED
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color="vanilla" variant="outline" className="sa-status-badge">
|
||||
DISABLED
|
||||
{account.status ? account.status.toUpperCase() : 'UNKNOWN'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -143,6 +155,19 @@ function OverviewTab({
|
||||
<Badge color="vanilla">{formatTimestamp(account.updatedAt)}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveErrors.length > 0 && (
|
||||
<div className="sa-drawer__save-errors">
|
||||
{saveErrors.map(({ context, apiError, onRetry }) => (
|
||||
<SaveErrorItem
|
||||
key={context}
|
||||
context={context}
|
||||
apiError={apiError}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import type {
|
||||
RenderErrorResponseDTO,
|
||||
ServiceaccounttypesFactorAPIKeyDTO,
|
||||
ServiceaccounttypesGettableFactorAPIKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
@@ -64,9 +64,9 @@ function RevokeKeyModal(): JSX.Element {
|
||||
const open = !!revokeKeyId && !!accountId;
|
||||
|
||||
const cachedKeys = accountId
|
||||
? queryClient.getQueryData<{ data: ServiceaccounttypesFactorAPIKeyDTO[] }>(
|
||||
getListServiceAccountKeysQueryKey({ id: accountId }),
|
||||
)
|
||||
? queryClient.getQueryData<{
|
||||
data: ServiceaccounttypesGettableFactorAPIKeyDTO[];
|
||||
}>(getListServiceAccountKeysQueryKey({ id: accountId }))
|
||||
: null;
|
||||
const keyName = cachedKeys?.data?.find((k) => k.id === revokeKeyId)?.name;
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { ChevronDown, ChevronUp, CircleAlert, RotateCw } from '@signozhq/icons';
|
||||
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
interface SaveErrorItemProps {
|
||||
context: string;
|
||||
apiError: APIError;
|
||||
onRetry?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
function SaveErrorItem({
|
||||
context,
|
||||
apiError,
|
||||
onRetry,
|
||||
}: SaveErrorItemProps): JSX.Element {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [isRetrying, setIsRetrying] = useState(false);
|
||||
|
||||
const ChevronIcon = expanded ? ChevronUp : ChevronDown;
|
||||
|
||||
return (
|
||||
<div className="sa-error-item">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="sa-error-item__header"
|
||||
aria-disabled={isRetrying}
|
||||
onClick={(): void => {
|
||||
if (!isRetrying) {
|
||||
setExpanded((prev) => !prev);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CircleAlert size={12} className="sa-error-item__icon" />
|
||||
<span className="sa-error-item__title">
|
||||
{isRetrying ? 'Retrying...' : `${context}: ${apiError.getErrorMessage()}`}
|
||||
</span>
|
||||
{onRetry && !isRetrying && (
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="Retry"
|
||||
size="xs"
|
||||
onClick={async (e): Promise<void> => {
|
||||
e.stopPropagation();
|
||||
setIsRetrying(true);
|
||||
setExpanded(false);
|
||||
try {
|
||||
await onRetry();
|
||||
} finally {
|
||||
setIsRetrying(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RotateCw size={12} color={Color.BG_CHERRY_400} />
|
||||
</Button>
|
||||
)}
|
||||
{!isRetrying && (
|
||||
<ChevronIcon size={14} className="sa-error-item__chevron" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && !isRetrying && (
|
||||
<div className="sa-error-item__body">
|
||||
<ErrorContent error={apiError} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SaveErrorItem;
|
||||
@@ -92,6 +92,23 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.25rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(136, 136, 136, 0.4);
|
||||
border-radius: 0.125rem;
|
||||
|
||||
&:hover {
|
||||
background: rgba(136, 136, 136, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
@@ -239,6 +256,113 @@
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&__save-errors {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
}
|
||||
|
||||
.sa-error-item {
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
width: 100%;
|
||||
padding: var(--padding-2) var(--padding-4);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
outline: none;
|
||||
|
||||
&:hover {
|
||||
background: rgba(229, 72, 77, 0.08);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
&[aria-disabled='true'] {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--callout-error-border);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--bg-cherry-500);
|
||||
}
|
||||
|
||||
&__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--bg-cherry-500);
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: -0.06px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__chevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
&__body {
|
||||
border-top: 1px solid var(--l1-border);
|
||||
|
||||
.error-content {
|
||||
&__summary {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
&__summary-left {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&__error-code {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
&__error-message {
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
&__docs-button {
|
||||
font-size: 11px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
&__message-badge {
|
||||
padding: 0 12px 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__message-item {
|
||||
font-size: 11px;
|
||||
padding: 2px 12px 2px 22px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.keys-tab {
|
||||
@@ -429,7 +553,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.sa-disable-dialog {
|
||||
.sa-delete-dialog {
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DrawerWrapper } from '@signozhq/drawer';
|
||||
import { Key, LayoutGrid, Plus, PowerOff, X } from '@signozhq/icons';
|
||||
import { Key, LayoutGrid, Plus, Trash2, X } from '@signozhq/icons';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
|
||||
import { Pagination, Skeleton } from 'antd';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
getListServiceAccountsQueryKey,
|
||||
useGetServiceAccount,
|
||||
useListServiceAccountKeys,
|
||||
useUpdateServiceAccount,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import { useRoles } from 'components/RolesSelect';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
import {
|
||||
ServiceAccountRow,
|
||||
ServiceAccountStatus,
|
||||
toServiceAccountRow,
|
||||
} from 'container/ServiceAccountsSettings/utils';
|
||||
import { useServiceAccountRoleManager } from 'hooks/serviceAccount/useServiceAccountRoleManager';
|
||||
import {
|
||||
parseAsBoolean,
|
||||
parseAsInteger,
|
||||
@@ -27,12 +31,14 @@ import {
|
||||
parseAsStringEnum,
|
||||
useQueryState,
|
||||
} from 'nuqs';
|
||||
import APIError from 'types/api/error';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import AddKeyModal from './AddKeyModal';
|
||||
import DisableAccountModal from './DisableAccountModal';
|
||||
import DeleteAccountModal from './DeleteAccountModal';
|
||||
import KeysTab from './KeysTab';
|
||||
import OverviewTab from './OverviewTab';
|
||||
import type { SaveError } from './utils';
|
||||
import { ServiceAccountDrawerTab } from './utils';
|
||||
|
||||
import './ServiceAccountDrawer.styles.scss';
|
||||
@@ -69,12 +75,16 @@ function ServiceAccountDrawer({
|
||||
SA_QUERY_PARAMS.ADD_KEY,
|
||||
parseAsBoolean.withDefault(false),
|
||||
);
|
||||
const [, setIsDisableOpen] = useQueryState(
|
||||
SA_QUERY_PARAMS.DISABLE_SA,
|
||||
const [, setIsDeleteOpen] = useQueryState(
|
||||
SA_QUERY_PARAMS.DELETE_SA,
|
||||
parseAsBoolean.withDefault(false),
|
||||
);
|
||||
const [localName, setLocalName] = useState('');
|
||||
const [localRoles, setLocalRoles] = useState<string[]>([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveErrors, setSaveErrors] = useState<SaveError[]>([]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: accountData,
|
||||
@@ -93,21 +103,30 @@ function ServiceAccountDrawer({
|
||||
[accountData],
|
||||
);
|
||||
|
||||
const { currentRoles, applyDiff } = useServiceAccountRoleManager(
|
||||
selectedAccountId ?? '',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (account) {
|
||||
setLocalName(account.name ?? '');
|
||||
setLocalRoles(account.roles ?? []);
|
||||
if (account?.id) {
|
||||
setLocalName(account?.name ?? '');
|
||||
setKeysPage(1);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [account?.id]);
|
||||
setSaveErrors([]);
|
||||
}, [account?.id, account?.name, setKeysPage]);
|
||||
|
||||
const isDisabled = account?.status?.toUpperCase() !== 'ACTIVE';
|
||||
useEffect(() => {
|
||||
setLocalRoles(currentRoles.map((r) => r.id).filter(Boolean) as string[]);
|
||||
}, [currentRoles]);
|
||||
|
||||
const isDeleted =
|
||||
account?.status?.toUpperCase() === ServiceAccountStatus.Deleted;
|
||||
|
||||
const isDirty =
|
||||
account !== null &&
|
||||
(localName !== (account.name ?? '') ||
|
||||
JSON.stringify(localRoles) !== JSON.stringify(account.roles ?? []));
|
||||
JSON.stringify([...localRoles].sort()) !==
|
||||
JSON.stringify([...currentRoles.map((r) => r.id).filter(Boolean)].sort()));
|
||||
|
||||
const {
|
||||
roles: availableRoles,
|
||||
@@ -133,51 +152,189 @@ function ServiceAccountDrawer({
|
||||
}
|
||||
}, [keysLoading, keys.length, keysPage, setKeysPage]);
|
||||
|
||||
const { mutate: updateAccount, isLoading: isSaving } = useUpdateServiceAccount(
|
||||
{
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
toast.success('Service account updated successfully', {
|
||||
richColors: true,
|
||||
});
|
||||
refetchAccount();
|
||||
onSuccess({ closeDrawer: false });
|
||||
},
|
||||
onError: (error) => {
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'Failed to update service account';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
},
|
||||
},
|
||||
},
|
||||
// the retry for this mutation is safe due to the api being idempotent on backend
|
||||
const { mutateAsync: updateMutateAsync } = useUpdateServiceAccount();
|
||||
|
||||
const toSaveApiError = useCallback(
|
||||
(err: unknown): APIError =>
|
||||
convertToApiError(err as AxiosError<RenderErrorResponseDTO>) ??
|
||||
toAPIError(err as AxiosError<RenderErrorResponseDTO>),
|
||||
[],
|
||||
);
|
||||
|
||||
function handleSave(): void {
|
||||
const retryNameUpdate = useCallback(async (): Promise<void> => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateMutateAsync({
|
||||
pathParams: { id: account.id },
|
||||
data: { name: localName },
|
||||
});
|
||||
setSaveErrors((prev) => prev.filter((e) => e.context !== 'Name update'));
|
||||
refetchAccount();
|
||||
queryClient.invalidateQueries(getListServiceAccountsQueryKey());
|
||||
} catch (err) {
|
||||
setSaveErrors((prev) =>
|
||||
prev.map((e) =>
|
||||
e.context === 'Name update' ? { ...e, apiError: toSaveApiError(err) } : e,
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [
|
||||
account,
|
||||
localName,
|
||||
updateMutateAsync,
|
||||
refetchAccount,
|
||||
queryClient,
|
||||
toSaveApiError,
|
||||
]);
|
||||
|
||||
const handleNameChange = useCallback((name: string): void => {
|
||||
setLocalName(name);
|
||||
setSaveErrors((prev) => prev.filter((e) => e.context !== 'Name update'));
|
||||
}, []);
|
||||
|
||||
const makeRoleRetry = useCallback(
|
||||
(
|
||||
context: string,
|
||||
rawRetry: () => Promise<void>,
|
||||
) => async (): Promise<void> => {
|
||||
try {
|
||||
await rawRetry();
|
||||
setSaveErrors((prev) => prev.filter((e) => e.context !== context));
|
||||
} catch (err) {
|
||||
setSaveErrors((prev) =>
|
||||
prev.map((e) =>
|
||||
e.context === context ? { ...e, apiError: toSaveApiError(err) } : e,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
[toSaveApiError],
|
||||
);
|
||||
|
||||
const retryRolesUpdate = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const failures = await applyDiff(localRoles, availableRoles);
|
||||
if (failures.length === 0) {
|
||||
setSaveErrors((prev) => prev.filter((e) => e.context !== 'Roles update'));
|
||||
} else {
|
||||
setSaveErrors((prev) => {
|
||||
const rest = prev.filter((e) => e.context !== 'Roles update');
|
||||
const roleErrors = failures.map((f) => {
|
||||
const ctx = `Role '${f.roleName}'`;
|
||||
return {
|
||||
context: ctx,
|
||||
apiError: toSaveApiError(f.error),
|
||||
onRetry: makeRoleRetry(ctx, f.onRetry),
|
||||
};
|
||||
});
|
||||
return [...rest, ...roleErrors];
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setSaveErrors((prev) =>
|
||||
prev.map((e) =>
|
||||
e.context === 'Roles update' ? { ...e, apiError: toSaveApiError(err) } : e,
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [localRoles, availableRoles, applyDiff, toSaveApiError, makeRoleRetry]);
|
||||
|
||||
const handleSave = useCallback(async (): Promise<void> => {
|
||||
if (!account || !isDirty) {
|
||||
return;
|
||||
}
|
||||
updateAccount({
|
||||
pathParams: { id: account.id },
|
||||
data: { name: localName, email: account.email, roles: localRoles },
|
||||
});
|
||||
}
|
||||
setSaveErrors([]);
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const namePromise =
|
||||
localName !== (account.name ?? '')
|
||||
? updateMutateAsync({
|
||||
pathParams: { id: account.id },
|
||||
data: { name: localName },
|
||||
})
|
||||
: Promise.resolve();
|
||||
|
||||
const [nameResult, rolesResult] = await Promise.allSettled([
|
||||
namePromise,
|
||||
applyDiff(localRoles, availableRoles),
|
||||
]);
|
||||
|
||||
const errors: SaveError[] = [];
|
||||
|
||||
if (nameResult.status === 'rejected') {
|
||||
errors.push({
|
||||
context: 'Name update',
|
||||
apiError: toSaveApiError(nameResult.reason),
|
||||
onRetry: retryNameUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
if (rolesResult.status === 'rejected') {
|
||||
errors.push({
|
||||
context: 'Roles update',
|
||||
apiError: toSaveApiError(rolesResult.reason),
|
||||
onRetry: retryRolesUpdate,
|
||||
});
|
||||
} else {
|
||||
for (const failure of rolesResult.value) {
|
||||
const context = `Role '${failure.roleName}'`;
|
||||
errors.push({
|
||||
context,
|
||||
apiError: toSaveApiError(failure.error),
|
||||
onRetry: makeRoleRetry(context, failure.onRetry),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
setSaveErrors(errors);
|
||||
} else {
|
||||
toast.success('Service account updated successfully', {
|
||||
richColors: true,
|
||||
});
|
||||
onSuccess({ closeDrawer: false });
|
||||
}
|
||||
|
||||
refetchAccount();
|
||||
queryClient.invalidateQueries(getListServiceAccountsQueryKey());
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [
|
||||
account,
|
||||
isDirty,
|
||||
localName,
|
||||
localRoles,
|
||||
availableRoles,
|
||||
updateMutateAsync,
|
||||
applyDiff,
|
||||
refetchAccount,
|
||||
onSuccess,
|
||||
queryClient,
|
||||
toSaveApiError,
|
||||
retryNameUpdate,
|
||||
makeRoleRetry,
|
||||
retryRolesUpdate,
|
||||
]);
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
setIsDisableOpen(null);
|
||||
setIsDeleteOpen(null);
|
||||
setIsAddKeyOpen(null);
|
||||
setSelectedAccountId(null);
|
||||
setActiveTab(null);
|
||||
setKeysPage(null);
|
||||
setEditKeyId(null);
|
||||
setSaveErrors([]);
|
||||
}, [
|
||||
setSelectedAccountId,
|
||||
setActiveTab,
|
||||
setKeysPage,
|
||||
setEditKeyId,
|
||||
setIsAddKeyOpen,
|
||||
setIsDisableOpen,
|
||||
setIsDeleteOpen,
|
||||
]);
|
||||
|
||||
const drawerContent = (
|
||||
@@ -220,7 +377,7 @@ function ServiceAccountDrawer({
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
disabled={isDisabled}
|
||||
disabled={isDeleted}
|
||||
onClick={(): void => {
|
||||
setIsAddKeyOpen(true);
|
||||
}}
|
||||
@@ -251,22 +408,23 @@ function ServiceAccountDrawer({
|
||||
<OverviewTab
|
||||
account={account}
|
||||
localName={localName}
|
||||
onNameChange={setLocalName}
|
||||
onNameChange={handleNameChange}
|
||||
localRoles={localRoles}
|
||||
onRolesChange={setLocalRoles}
|
||||
isDisabled={isDisabled}
|
||||
isDisabled={isDeleted}
|
||||
availableRoles={availableRoles}
|
||||
rolesLoading={rolesLoading}
|
||||
rolesError={rolesError}
|
||||
rolesErrorObj={rolesErrorObj}
|
||||
onRefetchRoles={refetchRoles}
|
||||
saveErrors={saveErrors}
|
||||
/>
|
||||
)}
|
||||
{activeTab === ServiceAccountDrawerTab.Keys && (
|
||||
<KeysTab
|
||||
keys={keys}
|
||||
isLoading={keysLoading}
|
||||
isDisabled={isDisabled}
|
||||
isDisabled={isDeleted}
|
||||
currentPage={keysPage}
|
||||
pageSize={PAGE_SIZE}
|
||||
/>
|
||||
@@ -298,20 +456,20 @@ function ServiceAccountDrawer({
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{!isDisabled && (
|
||||
{!isDeleted && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
className="sa-drawer__footer-btn"
|
||||
onClick={(): void => {
|
||||
setIsDisableOpen(true);
|
||||
setIsDeleteOpen(true);
|
||||
}}
|
||||
>
|
||||
<PowerOff size={12} />
|
||||
Disable Service Account
|
||||
<Trash2 size={12} />
|
||||
Delete Service Account
|
||||
</Button>
|
||||
)}
|
||||
{!isDisabled && (
|
||||
{!isDeleted && (
|
||||
<div className="sa-drawer__footer-right">
|
||||
<Button
|
||||
variant="solid"
|
||||
@@ -359,7 +517,7 @@ function ServiceAccountDrawer({
|
||||
className="sa-drawer"
|
||||
/>
|
||||
|
||||
<DisableAccountModal />
|
||||
<DeleteAccountModal />
|
||||
|
||||
<AddKeyModal />
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import type { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
@@ -14,17 +14,16 @@ const mockToast = jest.mocked(toast);
|
||||
|
||||
const SA_KEY_ENDPOINT = '*/api/v1/service_accounts/sa-1/keys/key-1';
|
||||
|
||||
const mockKey: ServiceaccounttypesFactorAPIKeyDTO = {
|
||||
const mockKey: ServiceaccounttypesGettableFactorAPIKeyDTO = {
|
||||
id: 'key-1',
|
||||
name: 'Original Key Name',
|
||||
expiresAt: 0,
|
||||
lastObservedAt: null as any,
|
||||
key: 'snz_abc123',
|
||||
serviceAccountId: 'sa-1',
|
||||
};
|
||||
|
||||
function renderModal(
|
||||
keyItem: ServiceaccounttypesFactorAPIKeyDTO | null = mockKey,
|
||||
keyItem: ServiceaccounttypesGettableFactorAPIKeyDTO | null = mockKey,
|
||||
searchParams: Record<string, string> = {
|
||||
account: 'sa-1',
|
||||
'edit-key': 'key-1',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
@@ -14,13 +14,12 @@ const mockToast = jest.mocked(toast);
|
||||
|
||||
const SA_KEY_ENDPOINT = '*/api/v1/service_accounts/sa-1/keys/:fid';
|
||||
|
||||
const keys: ServiceaccounttypesFactorAPIKeyDTO[] = [
|
||||
const keys: ServiceaccounttypesGettableFactorAPIKeyDTO[] = [
|
||||
{
|
||||
id: 'key-1',
|
||||
name: 'Production Key',
|
||||
expiresAt: 0,
|
||||
lastObservedAt: null as any,
|
||||
key: 'snz_prod_123',
|
||||
serviceAccountId: 'sa-1',
|
||||
},
|
||||
{
|
||||
@@ -28,7 +27,6 @@ const keys: ServiceaccounttypesFactorAPIKeyDTO[] = [
|
||||
name: 'Staging Key',
|
||||
expiresAt: 1924905600, // 2030-12-31
|
||||
lastObservedAt: new Date('2026-03-10T10:00:00Z'),
|
||||
key: 'snz_stag_456',
|
||||
serviceAccountId: 'sa-1',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -23,7 +23,9 @@ jest.mock('@signozhq/sonner', () => ({
|
||||
const ROLES_ENDPOINT = '*/api/v1/roles';
|
||||
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
|
||||
const SA_ENDPOINT = '*/api/v1/service_accounts/sa-1';
|
||||
const SA_STATUS_ENDPOINT = '*/api/v1/service_accounts/sa-1/status';
|
||||
const SA_DELETE_ENDPOINT = '*/api/v1/service_accounts/sa-1';
|
||||
const SA_ROLES_ENDPOINT = '*/api/v1/service_accounts/:id/roles';
|
||||
const SA_ROLE_DELETE_ENDPOINT = '*/api/v1/service_accounts/:id/roles/:rid';
|
||||
|
||||
const activeAccountResponse = {
|
||||
id: 'sa-1',
|
||||
@@ -35,10 +37,10 @@ const activeAccountResponse = {
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
};
|
||||
|
||||
const disabledAccountResponse = {
|
||||
const deletedAccountResponse = {
|
||||
...activeAccountResponse,
|
||||
id: 'sa-2',
|
||||
status: 'DISABLED',
|
||||
status: 'DELETED',
|
||||
};
|
||||
|
||||
function renderDrawer(
|
||||
@@ -67,7 +69,23 @@ describe('ServiceAccountDrawer', () => {
|
||||
rest.put(SA_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
rest.put(SA_STATUS_ENDPOINT, (_, res, ctx) =>
|
||||
rest.delete(SA_DELETE_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
rest.get(SA_ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: listRolesSuccessResponse.data.filter(
|
||||
(r) => r.name === 'signoz-admin',
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
);
|
||||
@@ -115,8 +133,6 @@ describe('ServiceAccountDrawer', () => {
|
||||
expect(updateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'CI Bot Updated',
|
||||
email: 'ci-bot@signoz.io',
|
||||
roles: ['signoz-admin'],
|
||||
}),
|
||||
);
|
||||
expect(onSuccess).toHaveBeenCalledWith({ closeDrawer: false });
|
||||
@@ -125,6 +141,7 @@ describe('ServiceAccountDrawer', () => {
|
||||
|
||||
it('changing roles enables Save; clicking Save sends updated roles in payload', async () => {
|
||||
const updateSpy = jest.fn();
|
||||
const roleSpy = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
@@ -132,6 +149,10 @@ describe('ServiceAccountDrawer', () => {
|
||||
updateSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
}),
|
||||
rest.post(SA_ROLES_ENDPOINT, async (req, res, ctx) => {
|
||||
roleSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
}),
|
||||
);
|
||||
|
||||
renderDrawer();
|
||||
@@ -146,21 +167,22 @@ describe('ServiceAccountDrawer', () => {
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSpy).toHaveBeenCalledWith(
|
||||
expect(updateSpy).not.toHaveBeenCalled();
|
||||
expect(roleSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
roles: expect.arrayContaining(['signoz-admin', 'signoz-viewer']),
|
||||
id: '019c24aa-2248-7585-a129-4188b3473c27',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('"Disable Service Account" opens confirm dialog; confirming sends correct status payload', async () => {
|
||||
const statusSpy = jest.fn();
|
||||
it('"Delete Service Account" opens confirm dialog; confirming sends delete request', async () => {
|
||||
const deleteSpy = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.put(SA_STATUS_ENDPOINT, async (req, res, ctx) => {
|
||||
statusSpy(await req.json());
|
||||
rest.delete(SA_DELETE_ENDPOINT, (_, res, ctx) => {
|
||||
deleteSpy();
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
}),
|
||||
);
|
||||
@@ -170,19 +192,19 @@ describe('ServiceAccountDrawer', () => {
|
||||
await screen.findByDisplayValue('CI Bot');
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /Disable Service Account/i }),
|
||||
screen.getByRole('button', { name: /Delete Service Account/i }),
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: /Disable service account CI Bot/i,
|
||||
name: /Delete service account CI Bot/i,
|
||||
});
|
||||
expect(dialog).toBeInTheDocument();
|
||||
|
||||
const confirmBtns = screen.getAllByRole('button', { name: /^Disable$/i });
|
||||
const confirmBtns = screen.getAllByRole('button', { name: /^Delete$/i });
|
||||
await user.click(confirmBtns[confirmBtns.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(statusSpy).toHaveBeenCalledWith({ status: 'DISABLED' });
|
||||
expect(deleteSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -190,14 +212,17 @@ describe('ServiceAccountDrawer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('disabled account shows read-only name, no Save button, no Disable button', async () => {
|
||||
it('deleted account shows read-only name, no Save button, no Delete button', async () => {
|
||||
server.use(
|
||||
rest.get('*/api/v1/service_accounts/sa-2', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: disabledAccountResponse })),
|
||||
res(ctx.status(200), ctx.json({ data: deletedAccountResponse })),
|
||||
),
|
||||
rest.get('*/api/v1/service_accounts/sa-2/keys', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: [] })),
|
||||
),
|
||||
rest.get('*/api/v1/service_accounts/sa-2/roles', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: [] })),
|
||||
),
|
||||
);
|
||||
|
||||
renderDrawer({ account: 'sa-2' });
|
||||
@@ -208,7 +233,7 @@ describe('ServiceAccountDrawer', () => {
|
||||
screen.queryByRole('button', { name: /Save Changes/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /Disable Service Account/i }),
|
||||
screen.queryByRole('button', { name: /Delete Service Account/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue('CI Bot')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -248,3 +273,169 @@ describe('ServiceAccountDrawer', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ServiceAccountDrawer – save-error UX', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
rest.get(SA_KEYS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: [] })),
|
||||
),
|
||||
rest.get(SA_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: activeAccountResponse })),
|
||||
),
|
||||
rest.put(SA_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
rest.delete(SA_DELETE_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
rest.get(SA_ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: listRolesSuccessResponse.data.filter(
|
||||
(r) => r.name === 'signoz-admin',
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('name update failure shows SaveErrorItem with "Name update" context', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.put(SA_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(500),
|
||||
ctx.json({
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'name update failed',
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
renderDrawer();
|
||||
|
||||
const nameInput = await screen.findByDisplayValue('CI Bot');
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, 'New Name');
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /Save Changes/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await user.click(saveBtn);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Name update.*name update failed/i, undefined, {
|
||||
timeout: 5000,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('role update failure shows SaveErrorItem with the role name context', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(500),
|
||||
ctx.json({
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'role assign failed',
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
renderDrawer();
|
||||
|
||||
await screen.findByDisplayValue('CI Bot');
|
||||
|
||||
// Add the signoz-viewer role (which is not currently assigned)
|
||||
await user.click(screen.getByLabelText('Roles'));
|
||||
await user.click(await screen.findByTitle('signoz-viewer'));
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /Save Changes/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await user.click(saveBtn);
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
/Role 'signoz-viewer'.*role assign failed/i,
|
||||
undefined,
|
||||
{
|
||||
timeout: 5000,
|
||||
},
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking Retry on a name-update error re-triggers the request; on success the error item is removed', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
// First: PUT always fails so the error appears
|
||||
server.use(
|
||||
rest.put(SA_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(500),
|
||||
ctx.json({
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'name update failed',
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
renderDrawer();
|
||||
|
||||
const nameInput = await screen.findByDisplayValue('CI Bot');
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, 'Retry Test');
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /Save Changes/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await user.click(saveBtn);
|
||||
|
||||
await screen.findByText(/Name update.*name update failed/i, undefined, {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
server.use(
|
||||
rest.put(SA_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
);
|
||||
|
||||
const retryBtn = screen.getByRole('button', { name: /Retry/i });
|
||||
await user.click(retryBtn);
|
||||
|
||||
// Error item should be removed after successful retry
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText(/Name update.*name update failed/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import dayjs from 'dayjs';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
export interface SaveError {
|
||||
context: string;
|
||||
apiError: APIError;
|
||||
onRetry: () => Promise<void>;
|
||||
}
|
||||
|
||||
export enum ServiceAccountDrawerTab {
|
||||
Overview = 'overview',
|
||||
|
||||
@@ -8,7 +8,6 @@ const mockActiveAccount: ServiceAccountRow = {
|
||||
id: 'sa-1',
|
||||
name: 'CI Bot',
|
||||
email: 'ci-bot@signoz.io',
|
||||
roles: ['signoz-admin'],
|
||||
status: 'ACTIVE',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
@@ -18,7 +17,6 @@ const mockDisabledAccount: ServiceAccountRow = {
|
||||
id: 'sa-2',
|
||||
name: 'Legacy Bot',
|
||||
email: 'legacy@signoz.io',
|
||||
roles: ['signoz-viewer', 'signoz-editor', 'billing-manager'],
|
||||
status: 'DISABLED',
|
||||
createdAt: '2025-06-01T00:00:00Z',
|
||||
updatedAt: '2025-12-01T00:00:00Z',
|
||||
@@ -39,7 +37,6 @@ describe('ServiceAccountsTable', () => {
|
||||
|
||||
expect(screen.getByText('CI Bot')).toBeInTheDocument();
|
||||
expect(screen.getByText('ci-bot@signoz.io')).toBeInTheDocument();
|
||||
expect(screen.getByText('signoz-admin')).toBeInTheDocument();
|
||||
expect(screen.getByText('ACTIVE')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -49,8 +46,6 @@ describe('ServiceAccountsTable', () => {
|
||||
);
|
||||
|
||||
expect(screen.getByText('DISABLED')).toBeInTheDocument();
|
||||
expect(screen.getByText('signoz-viewer')).toBeInTheDocument();
|
||||
expect(screen.getByText('+2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onRowClick with the correct account when a row is clicked', async () => {
|
||||
|
||||
@@ -25,32 +25,6 @@ export function NameEmailCell({
|
||||
);
|
||||
}
|
||||
|
||||
export function RolesCell({ roles }: { roles: string[] }): JSX.Element {
|
||||
if (!roles || roles.length === 0) {
|
||||
return <span className="sa-dash">—</span>;
|
||||
}
|
||||
const first = roles[0];
|
||||
const overflow = roles.length - 1;
|
||||
const tooltipContent = roles.slice(1).join(', ');
|
||||
|
||||
return (
|
||||
<div className="sa-roles-cell">
|
||||
<Badge color="vanilla">{first}</Badge>
|
||||
{overflow > 0 && (
|
||||
<Tooltip
|
||||
title={tooltipContent}
|
||||
overlayClassName="sa-tooltip"
|
||||
overlayStyle={{ maxWidth: '600px' }}
|
||||
>
|
||||
<Badge color="vanilla" variant="outline" className="sa-status-badge">
|
||||
+{overflow}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: string }): JSX.Element {
|
||||
if (status?.toUpperCase() === 'ACTIVE') {
|
||||
return (
|
||||
@@ -59,9 +33,16 @@ export function StatusBadge({ status }: { status: string }): JSX.Element {
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (status?.toUpperCase() === 'DELETED') {
|
||||
return (
|
||||
<Badge color="cherry" variant="outline">
|
||||
DELETED
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge color="vanilla" variant="outline" className="sa-status-badge">
|
||||
DISABLED
|
||||
{status ? status.toUpperCase() : 'UNKNOWN'}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -98,13 +79,6 @@ export const columns: ColumnsType<ServiceAccountRow> = [
|
||||
<NameEmailCell name={record.name} email={record.email} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Roles',
|
||||
dataIndex: 'roles',
|
||||
key: 'roles',
|
||||
width: 420,
|
||||
render: (roles: string[]): JSX.Element => <RolesCell roles={roles} />,
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
|
||||
@@ -12,7 +12,6 @@ export enum LOCALSTORAGE {
|
||||
GRAPH_VISIBILITY_STATES = 'GRAPH_VISIBILITY_STATES',
|
||||
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
|
||||
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
|
||||
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
|
||||
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',
|
||||
LOGGED_IN_USER_EMAIL = 'LOGGED_IN_USER_EMAIL',
|
||||
CHAT_SUPPORT = 'CHAT_SUPPORT',
|
||||
|
||||
@@ -38,7 +38,6 @@ const ROUTES = {
|
||||
SETTINGS: '/settings',
|
||||
MY_SETTINGS: '/settings/my-settings',
|
||||
ORG_SETTINGS: '/settings/org-settings',
|
||||
API_KEYS: '/settings/api-keys',
|
||||
INGESTION_SETTINGS: '/settings/ingestion-settings',
|
||||
SOMETHING_WENT_WRONG: '/something-went-wrong',
|
||||
UN_AUTHORIZED: '/un-authorized',
|
||||
|
||||
@@ -248,15 +248,5 @@ export function createShortcutActions(deps: ActionDeps): CmdAction[] {
|
||||
roles: ['ADMIN', 'EDITOR'],
|
||||
perform: (): void => navigate(ROUTES.BILLING),
|
||||
},
|
||||
{
|
||||
id: 'my-settings-api-keys',
|
||||
name: 'Go to Account Settings API Keys',
|
||||
shortcut: [GlobalShortcutsName.NavigateToSettingsAPIKeys],
|
||||
keywords: 'account settings api keys',
|
||||
section: 'Settings',
|
||||
icon: <Settings size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR'],
|
||||
perform: (): void => navigate(ROUTES.API_KEYS),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ export const GlobalShortcuts = {
|
||||
NavigateToSettings: 'shift+g',
|
||||
NavigateToSettingsIngestion: 'shift+g+i',
|
||||
NavigateToSettingsBilling: 'shift+g+b',
|
||||
NavigateToSettingsAPIKeys: 'shift+g+k',
|
||||
NavigateToSettingsNotificationChannels: 'shift+g+n',
|
||||
};
|
||||
|
||||
@@ -47,7 +46,6 @@ export const GlobalShortcutsName = {
|
||||
NavigateToSettings: 'shift+g',
|
||||
NavigateToSettingsIngestion: 'shift+g+i',
|
||||
NavigateToSettingsBilling: 'shift+g+b',
|
||||
NavigateToSettingsAPIKeys: 'shift+g+k',
|
||||
NavigateToSettingsNotificationChannels: 'shift+g+n',
|
||||
NavigateToLogs: 'shift+l',
|
||||
NavigateToLogsPipelines: 'shift+l+p',
|
||||
@@ -72,7 +70,6 @@ export const GlobalShortcutsDescription = {
|
||||
NavigateToSettings: 'Navigate to Settings',
|
||||
NavigateToSettingsIngestion: 'Navigate to Ingestion Settings',
|
||||
NavigateToSettingsBilling: 'Navigate to Billing Settings',
|
||||
NavigateToSettingsAPIKeys: 'Navigate to API Keys Settings',
|
||||
NavigateToSettingsNotificationChannels:
|
||||
'Navigate to Notification Channels Settings',
|
||||
NavigateToLogsPipelines: 'Navigate to Logs Pipelines',
|
||||
|
||||
@@ -1,685 +0,0 @@
|
||||
.api-key-container {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
|
||||
.api-key-content {
|
||||
width: calc(100% - 30px);
|
||||
max-width: 736px;
|
||||
|
||||
.title {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: var(--font-size-lg);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 28px; /* 155.556% */
|
||||
letter-spacing: -0.09px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.api-keys-search-add-new {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
padding: 16px 0;
|
||||
|
||||
.add-new-api-key-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-row {
|
||||
.ant-table-cell {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: var(--bg-ink-500);
|
||||
}
|
||||
.column-render {
|
||||
margin: 8px 0 !important;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
background: var(--bg-ink-400);
|
||||
|
||||
.title-with-action {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
|
||||
.api-key-data {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
.api-key-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.api-key-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
border-radius: 20px;
|
||||
padding: 0px 12px;
|
||||
|
||||
background: var(--bg-ink-200);
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.copy-key-btn {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.visibility-btn {
|
||||
border: 1px solid rgba(113, 144, 249, 0.2);
|
||||
background: rgba(113, 144, 249, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-collapse {
|
||||
border: none;
|
||||
|
||||
.ant-collapse-header {
|
||||
padding: 0px 8px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: #121317;
|
||||
}
|
||||
|
||||
.ant-collapse-content {
|
||||
border-top: 1px solid var(--bg-slate-500);
|
||||
}
|
||||
|
||||
.ant-collapse-item {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ant-collapse-expand-icon {
|
||||
padding-inline-end: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.api-key-details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
border-top: 1px solid var(--bg-slate-500);
|
||||
padding: 8px;
|
||||
|
||||
.api-key-tag {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50px;
|
||||
background: var(--bg-slate-300);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.tag-text {
|
||||
color: var(--bg-vanilla-400);
|
||||
leading-trim: both;
|
||||
text-edge: cap;
|
||||
font-size: 10px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: normal;
|
||||
letter-spacing: -0.05px;
|
||||
}
|
||||
}
|
||||
|
||||
.api-key-created-by {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.api-key-last-used-at {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
font-variant-numeric: lining-nums tabular-nums stacked-fractions
|
||||
slashed-zero;
|
||||
font-feature-settings: 'dlig' on, 'salt' on, 'cpsp' on, 'case' on;
|
||||
}
|
||||
}
|
||||
|
||||
.api-key-expires-in {
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.dot {
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
color: var(--bg-amber-400);
|
||||
|
||||
.dot {
|
||||
background: var(--bg-amber-400);
|
||||
box-shadow: 0px 0px 6px 0px var(--bg-amber-400);
|
||||
}
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: var(--bg-cherry-400);
|
||||
|
||||
.dot {
|
||||
background: var(--bg-cherry-400);
|
||||
box-shadow: 0px 0px 6px 0px var(--bg-cherry-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-pagination-item {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
> a {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-variant-numeric: lining-nums tabular-nums slashed-zero;
|
||||
font-feature-settings: 'dlig' on, 'salt' on, 'case' on, 'cpsp' on;
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 20px; /* 142.857% */
|
||||
}
|
||||
}
|
||||
|
||||
.ant-pagination-item-active {
|
||||
background-color: var(--bg-robin-500);
|
||||
> a {
|
||||
color: var(--bg-ink-500) !important;
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.api-key-info-container {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-direction: column;
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.user-avatar {
|
||||
background-color: lightslategray;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.user-email {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-radius: 20px;
|
||||
padding: 0px 12px;
|
||||
background: var(--bg-ink-200);
|
||||
|
||||
font-family: 'Space Mono', monospace;
|
||||
}
|
||||
|
||||
.role {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.api-key-modal {
|
||||
.ant-modal-content {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
background: var(--bg-ink-400);
|
||||
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
padding: 0;
|
||||
|
||||
.ant-modal-header {
|
||||
background: none;
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.ant-modal-close-x {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.ant-modal-footer {
|
||||
padding: 16px;
|
||||
margin-top: 0;
|
||||
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.api-key-access-role {
|
||||
display: flex;
|
||||
|
||||
.ant-radio-button-wrapper {
|
||||
font-size: 12px;
|
||||
text-transform: capitalize;
|
||||
|
||||
&.ant-radio-button-wrapper-checked {
|
||||
color: #fff;
|
||||
background: var(--bg-slate-400, #1d212d);
|
||||
border-color: var(--bg-slate-400, #1d212d);
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
background: var(--bg-slate-400, #1d212d);
|
||||
border-color: var(--bg-slate-400, #1d212d);
|
||||
|
||||
&::before {
|
||||
background-color: var(--bg-slate-400, #1d212d);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
color: #fff;
|
||||
background: var(--bg-slate-400, #1d212d);
|
||||
border-color: var(--bg-slate-400, #1d212d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
|
||||
flex: 1;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
&::before {
|
||||
background: var(--bg-slate-400);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: var(--bg-slate-400, #1d212d);
|
||||
}
|
||||
}
|
||||
|
||||
.role {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.delete-api-key-modal {
|
||||
width: calc(100% - 30px) !important; /* Adjust the 20px as needed */
|
||||
max-width: 384px;
|
||||
.ant-modal-content {
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
background: var(--bg-ink-400);
|
||||
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
|
||||
.ant-modal-header {
|
||||
padding: 16px;
|
||||
background: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 0px 16px 28px 16px;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.api-key-input {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ant-color-picker-trigger {
|
||||
padding: 6px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
.ant-color-picker-color-block {
|
||||
border-radius: 50px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.ant-color-picker-color-block-inner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 16px 16px;
|
||||
margin: 0;
|
||||
|
||||
.cancel-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-slate-500);
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-cherry-500);
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
color: var(--bg-vanilla-100);
|
||||
background: var(--bg-cherry-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 20px; /* 142.857% */
|
||||
}
|
||||
}
|
||||
|
||||
.expiration-selector {
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--bg-slate-400) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.newAPIKeyDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.copyable-text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-radius: 20px;
|
||||
padding: 0px 12px;
|
||||
background: var(--bg-ink-200, #23262e);
|
||||
|
||||
.copy-key-btn {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.api-key-container {
|
||||
.api-key-content {
|
||||
.title {
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
|
||||
.ant-table-row {
|
||||
.ant-table-cell {
|
||||
background: var(--bg-vanilla-200);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.ant-table-cell {
|
||||
background: var(--bg-vanilla-200) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.column-render {
|
||||
border: 1px solid var(--bg-vanilla-200);
|
||||
background: var(--bg-vanilla-100);
|
||||
|
||||
.ant-collapse {
|
||||
border: none;
|
||||
|
||||
.ant-collapse-header {
|
||||
background: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
.ant-collapse-content {
|
||||
border-top: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
|
||||
.title-with-action {
|
||||
.api-key-title {
|
||||
.ant-typography {
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
}
|
||||
|
||||
.api-key-value {
|
||||
background: var(--bg-vanilla-200);
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-slate-400);
|
||||
}
|
||||
|
||||
.copy-key-btn {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
.ant-typography {
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.api-key-details {
|
||||
border-top: 1px solid var(--bg-vanilla-200);
|
||||
.api-key-tag {
|
||||
background: var(--bg-vanilla-200);
|
||||
.tag-text {
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
}
|
||||
|
||||
.api-key-created-by {
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
|
||||
.api-key-last-used-at {
|
||||
.ant-typography {
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.delete-api-key-modal {
|
||||
.ant-modal-content {
|
||||
border: 1px solid var(--bg-vanilla-200);
|
||||
background: var(--bg-vanilla-100);
|
||||
|
||||
.ant-modal-header {
|
||||
background: var(--bg-vanilla-100);
|
||||
|
||||
.title {
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
.ant-typography {
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
|
||||
.api-key-input {
|
||||
.ant-input {
|
||||
background: var(--bg-vanilla-200);
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-footer {
|
||||
.cancel-btn {
|
||||
background: var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.api-key-info-container {
|
||||
.user-email {
|
||||
background: var(--bg-vanilla-200);
|
||||
}
|
||||
}
|
||||
|
||||
.api-key-modal {
|
||||
.ant-modal-content {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-vanilla-200);
|
||||
background: var(--bg-vanilla-100);
|
||||
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
padding: 0;
|
||||
|
||||
.ant-modal-header {
|
||||
background: none;
|
||||
border-bottom: 1px solid var(--bg-vanilla-200);
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.api-key-access-role {
|
||||
.ant-radio-button-wrapper {
|
||||
&.ant-radio-button-wrapper-checked {
|
||||
color: var(--bg-ink-400);
|
||||
background: var(--bg-vanilla-300);
|
||||
border-color: var(--bg-vanilla-300);
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-ink-400);
|
||||
background: var(--bg-vanilla-300);
|
||||
border-color: var(--bg-vanilla-300);
|
||||
|
||||
&::before {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
color: var(--bg-ink-400);
|
||||
background: var(--bg-vanilla-300);
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
&::before {
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.copyable-text {
|
||||
background: var(--bg-vanilla-200);
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import {
|
||||
createAPIKeyResponse,
|
||||
getAPIKeysResponse,
|
||||
} from 'mocks-server/__mockdata__/apiKeys';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import APIKeys from './APIKeys';
|
||||
|
||||
const apiKeysURL = 'http://localhost/api/v1/pats';
|
||||
|
||||
describe('APIKeys component', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get(apiKeysURL, (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(getAPIKeysResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<APIKeys />);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders APIKeys component without crashing', () => {
|
||||
expect(screen.getByText('API Keys')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Create and manage API keys for the SigNoz API'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('render list of Access Tokens', async () => {
|
||||
server.use(
|
||||
rest.get(apiKeysURL, (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(getAPIKeysResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No Expiry Key')).toBeInTheDocument();
|
||||
expect(screen.getByText('1-5 of 18 keys')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('opens add new key modal on button click', async () => {
|
||||
fireEvent.click(screen.getByText('New Key'));
|
||||
await waitFor(() => {
|
||||
const createNewKeyBtn = screen.getByRole('button', {
|
||||
name: /Create new key/i,
|
||||
});
|
||||
|
||||
expect(createNewKeyBtn).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('closes add new key modal on cancel button click', async () => {
|
||||
fireEvent.click(screen.getByText('New Key'));
|
||||
|
||||
const createNewKeyBtn = screen.getByRole('button', {
|
||||
name: /Create new key/i,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createNewKeyBtn).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
await waitFor(() => {
|
||||
expect(createNewKeyBtn).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a new key on form submission', async () => {
|
||||
server.use(
|
||||
rest.post(apiKeysURL, (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(createAPIKeyResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('New Key'));
|
||||
|
||||
const createNewKeyBtn = screen.getByRole('button', {
|
||||
name: /Create new key/i,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createNewKeyBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
const inputElement = screen.getByPlaceholderText('Enter Key Name');
|
||||
fireEvent.change(inputElement, { target: { value: 'Top Secret' } });
|
||||
fireEvent.click(screen.getByTestId('create-form-admin-role-btn'));
|
||||
fireEvent.click(createNewKeyBtn);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,874 +0,0 @@
|
||||
import { ChangeEvent, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation } from 'react-query';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Col,
|
||||
Collapse,
|
||||
CollapseProps,
|
||||
Flex,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Radio,
|
||||
Row,
|
||||
Select,
|
||||
Table,
|
||||
TableProps,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type { NotificationInstance } from 'antd/es/notification/interface';
|
||||
import createAPIKeyApi from 'api/v1/pats/create';
|
||||
import deleteAPIKeyApi from 'api/v1/pats/delete';
|
||||
import updateAPIKeyApi from 'api/v1/pats/update';
|
||||
import cx from 'classnames';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import { useGetAllAPIKeys } from 'hooks/APIKeys/useGetAllAPIKeys';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import {
|
||||
CalendarClock,
|
||||
Check,
|
||||
ClipboardEdit,
|
||||
Contact2,
|
||||
Copy,
|
||||
Eye,
|
||||
Minus,
|
||||
PenLine,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
View,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import APIError from 'types/api/error';
|
||||
import { APIKeyProps } from 'types/api/pat/types';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import './APIKeys.styles.scss';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export const showErrorNotification = (
|
||||
notifications: NotificationInstance,
|
||||
err: APIError,
|
||||
): void => {
|
||||
notifications.error({
|
||||
message: err.getErrorCode(),
|
||||
description: err.getErrorMessage(),
|
||||
});
|
||||
};
|
||||
|
||||
type ExpiryOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const EXPIRATION_WITHIN_SEVEN_DAYS = 7;
|
||||
|
||||
const API_KEY_EXPIRY_OPTIONS: ExpiryOption[] = [
|
||||
{ value: '1', label: '1 day' },
|
||||
{ value: '7', label: '1 week' },
|
||||
{ value: '30', label: '1 month' },
|
||||
{ value: '90', label: '3 months' },
|
||||
{ value: '365', label: '1 year' },
|
||||
{ value: '0', label: 'No Expiry' },
|
||||
];
|
||||
|
||||
export const isExpiredToken = (expiryTimestamp: number): boolean => {
|
||||
if (expiryTimestamp === 0) {
|
||||
return false;
|
||||
}
|
||||
const currentTime = dayjs();
|
||||
const tokenExpiresAt = dayjs.unix(expiryTimestamp);
|
||||
return tokenExpiresAt.isBefore(currentTime);
|
||||
};
|
||||
|
||||
export const getDateDifference = (
|
||||
createdTimestamp: number,
|
||||
expiryTimestamp: number,
|
||||
): number => {
|
||||
const differenceInSeconds = Math.abs(expiryTimestamp - createdTimestamp);
|
||||
|
||||
// Convert seconds to days
|
||||
return differenceInSeconds / (60 * 60 * 24);
|
||||
};
|
||||
|
||||
function APIKeys(): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const { notifications } = useNotifications();
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const [showNewAPIKeyDetails, setShowNewAPIKeyDetails] = useState(false);
|
||||
const [, handleCopyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [activeAPIKey, setActiveAPIKey] = useState<APIKeyProps | null>();
|
||||
|
||||
const [searchValue, setSearchValue] = useState<string>('');
|
||||
const [dataSource, setDataSource] = useState<APIKeyProps[]>([]);
|
||||
const { t } = useTranslation(['apiKeys']);
|
||||
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
|
||||
const handleFormReset = (): void => {
|
||||
editForm.resetFields();
|
||||
createForm.resetFields();
|
||||
};
|
||||
|
||||
const hideDeleteViewModal = (): void => {
|
||||
handleFormReset();
|
||||
setActiveAPIKey(null);
|
||||
setIsDeleteModalOpen(false);
|
||||
};
|
||||
|
||||
const showDeleteModal = (apiKey: APIKeyProps): void => {
|
||||
setActiveAPIKey(apiKey);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const hideEditViewModal = (): void => {
|
||||
handleFormReset();
|
||||
setActiveAPIKey(null);
|
||||
setIsEditModalOpen(false);
|
||||
};
|
||||
|
||||
const hideAddViewModal = (): void => {
|
||||
handleFormReset();
|
||||
setShowNewAPIKeyDetails(false);
|
||||
setActiveAPIKey(null);
|
||||
setIsAddModalOpen(false);
|
||||
};
|
||||
|
||||
const showEditModal = (apiKey: APIKeyProps): void => {
|
||||
handleFormReset();
|
||||
setActiveAPIKey(apiKey);
|
||||
|
||||
editForm.setFieldsValue({
|
||||
name: apiKey.name,
|
||||
role: apiKey.role || USER_ROLES.VIEWER,
|
||||
});
|
||||
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
const showAddModal = (): void => {
|
||||
setActiveAPIKey(null);
|
||||
setIsAddModalOpen(true);
|
||||
};
|
||||
|
||||
const handleModalClose = (): void => {
|
||||
setActiveAPIKey(null);
|
||||
};
|
||||
|
||||
const {
|
||||
data: APIKeys,
|
||||
isLoading,
|
||||
isRefetching,
|
||||
refetch: refetchAPIKeys,
|
||||
error,
|
||||
isError,
|
||||
} = useGetAllAPIKeys();
|
||||
|
||||
useEffect(() => {
|
||||
setActiveAPIKey(APIKeys?.data?.[0]);
|
||||
}, [APIKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
setDataSource(APIKeys?.data || []);
|
||||
}, [APIKeys?.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isError) {
|
||||
showErrorNotification(notifications, error as APIError);
|
||||
}
|
||||
}, [error, isError, notifications]);
|
||||
|
||||
const handleSearch = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
setSearchValue(e.target.value);
|
||||
const filteredData = APIKeys?.data?.filter(
|
||||
(key: APIKeyProps) =>
|
||||
key &&
|
||||
key.name &&
|
||||
key.name.toLowerCase().includes(e.target.value.toLowerCase()),
|
||||
);
|
||||
setDataSource(filteredData || []);
|
||||
};
|
||||
|
||||
const clearSearch = (): void => {
|
||||
setSearchValue('');
|
||||
};
|
||||
|
||||
const { mutate: createAPIKey, isLoading: isLoadingCreateAPIKey } = useMutation(
|
||||
createAPIKeyApi,
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
setShowNewAPIKeyDetails(true);
|
||||
setActiveAPIKey(data.data);
|
||||
|
||||
refetchAPIKeys();
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorNotification(notifications, error as APIError);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { mutate: updateAPIKey, isLoading: isLoadingUpdateAPIKey } = useMutation(
|
||||
updateAPIKeyApi,
|
||||
{
|
||||
onSuccess: () => {
|
||||
refetchAPIKeys();
|
||||
setIsEditModalOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorNotification(notifications, error as APIError);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { mutate: deleteAPIKey, isLoading: isDeleteingAPIKey } = useMutation(
|
||||
deleteAPIKeyApi,
|
||||
{
|
||||
onSuccess: () => {
|
||||
refetchAPIKeys();
|
||||
setIsDeleteModalOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorNotification(notifications, error as APIError);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const onDeleteHandler = (): void => {
|
||||
clearSearch();
|
||||
|
||||
if (activeAPIKey) {
|
||||
deleteAPIKey(activeAPIKey.id);
|
||||
}
|
||||
};
|
||||
|
||||
const onUpdateApiKey = (): void => {
|
||||
editForm
|
||||
.validateFields()
|
||||
.then((values) => {
|
||||
if (activeAPIKey) {
|
||||
updateAPIKey({
|
||||
id: activeAPIKey.id,
|
||||
data: {
|
||||
name: values.name,
|
||||
role: values.role,
|
||||
},
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((errorInfo) => {
|
||||
console.error('error info', errorInfo);
|
||||
});
|
||||
};
|
||||
|
||||
const onCreateAPIKey = (): void => {
|
||||
createForm
|
||||
.validateFields()
|
||||
.then((values) => {
|
||||
if (user) {
|
||||
createAPIKey({
|
||||
name: values.name,
|
||||
expiresInDays: parseInt(values.expiration, 10),
|
||||
role: values.role,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((errorInfo) => {
|
||||
console.error('error info', errorInfo);
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopyKey = (text: string): void => {
|
||||
handleCopyToClipboard(text);
|
||||
notifications.success({
|
||||
message: 'Copied to clipboard',
|
||||
});
|
||||
};
|
||||
|
||||
const getFormattedTime = (epochTime: number): string => {
|
||||
const timeOptions: Intl.DateTimeFormatOptions = {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
};
|
||||
const formattedTime = new Date(epochTime * 1000).toLocaleTimeString(
|
||||
'en-US',
|
||||
timeOptions,
|
||||
);
|
||||
|
||||
const dateOptions: Intl.DateTimeFormatOptions = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
};
|
||||
|
||||
const formattedDate = new Date(epochTime * 1000).toLocaleDateString(
|
||||
'en-US',
|
||||
dateOptions,
|
||||
);
|
||||
|
||||
return `${formattedDate} ${formattedTime}`;
|
||||
};
|
||||
|
||||
const handleCopyClose = (): void => {
|
||||
if (activeAPIKey) {
|
||||
handleCopyKey(activeAPIKey?.token);
|
||||
}
|
||||
|
||||
hideAddViewModal();
|
||||
};
|
||||
|
||||
const columns: TableProps<APIKeyProps>['columns'] = [
|
||||
{
|
||||
title: 'API Key',
|
||||
key: 'api-key',
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
render: (APIKey: APIKeyProps): JSX.Element => {
|
||||
const formattedDateAndTime =
|
||||
APIKey && APIKey?.lastUsed && APIKey?.lastUsed !== 0
|
||||
? getFormattedTime(APIKey?.lastUsed)
|
||||
: 'Never';
|
||||
|
||||
const createdOn = new Date(APIKey.createdAt).toLocaleString();
|
||||
|
||||
const expiresIn =
|
||||
APIKey.expiresAt === 0
|
||||
? Number.POSITIVE_INFINITY
|
||||
: getDateDifference(
|
||||
new Date(APIKey?.createdAt).getTime() / 1000,
|
||||
APIKey?.expiresAt,
|
||||
);
|
||||
|
||||
const isExpired = isExpiredToken(APIKey.expiresAt);
|
||||
|
||||
const expiresOn =
|
||||
!APIKey.expiresAt || APIKey.expiresAt === 0
|
||||
? 'No Expiry'
|
||||
: getFormattedTime(APIKey.expiresAt);
|
||||
|
||||
const updatedOn =
|
||||
!APIKey.updatedAt || APIKey.updatedAt === ''
|
||||
? null
|
||||
: new Date(APIKey.updatedAt).toLocaleString();
|
||||
|
||||
const items: CollapseProps['items'] = [
|
||||
{
|
||||
key: '1',
|
||||
label: (
|
||||
<div className="title-with-action">
|
||||
<div className="api-key-data">
|
||||
<div className="api-key-title">
|
||||
<Typography.Text>{APIKey?.name}</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className="api-key-value">
|
||||
<Typography.Text>
|
||||
{APIKey?.token.substring(0, 2)}********
|
||||
{APIKey?.token.substring(APIKey.token.length - 2).trim()}
|
||||
</Typography.Text>
|
||||
|
||||
<Copy
|
||||
className="copy-key-btn"
|
||||
size={12}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleCopyKey(APIKey.token);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{APIKey.role === USER_ROLES.ADMIN && (
|
||||
<Tooltip title={USER_ROLES.ADMIN}>
|
||||
<Contact2 size={14} color={Color.BG_ROBIN_400} />
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{APIKey.role === USER_ROLES.EDITOR && (
|
||||
<Tooltip title={USER_ROLES.EDITOR}>
|
||||
<ClipboardEdit size={14} color={Color.BG_ROBIN_400} />
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{APIKey.role === USER_ROLES.VIEWER && (
|
||||
<Tooltip title={USER_ROLES.VIEWER}>
|
||||
<View size={14} color={Color.BG_ROBIN_400} />
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{!APIKey.role && (
|
||||
<Tooltip title={USER_ROLES.ADMIN}>
|
||||
<Contact2 size={14} color={Color.BG_ROBIN_400} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="action-btn">
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
icon={<PenLine size={14} />}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
showEditModal(APIKey);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
icon={<Trash2 color={Color.BG_CHERRY_500} size={14} />}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
showDeleteModal(APIKey);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<div className="api-key-info-container">
|
||||
{APIKey?.createdByUser && (
|
||||
<Row>
|
||||
<Col span={6}> Creator </Col>
|
||||
<Col span={12} className="user-info">
|
||||
<Avatar className="user-avatar" size="small">
|
||||
{APIKey?.createdByUser?.displayName?.substring(0, 1)}
|
||||
</Avatar>
|
||||
|
||||
<Typography.Text>
|
||||
{APIKey.createdByUser?.displayName}
|
||||
</Typography.Text>
|
||||
|
||||
<div className="user-email">{APIKey.createdByUser?.email}</div>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
<Row>
|
||||
<Col span={6}> Created on </Col>
|
||||
<Col span={12}>
|
||||
<Typography.Text>{createdOn}</Typography.Text>
|
||||
</Col>
|
||||
</Row>
|
||||
{updatedOn && (
|
||||
<Row>
|
||||
<Col span={6}> Updated on </Col>
|
||||
<Col span={12}>
|
||||
<Typography.Text>{updatedOn}</Typography.Text>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Row>
|
||||
<Col span={6}> Expires on </Col>
|
||||
<Col span={12}>
|
||||
<Typography.Text>{expiresOn}</Typography.Text>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="column-render">
|
||||
<Collapse items={items} />
|
||||
|
||||
<div className="api-key-details">
|
||||
<div className="api-key-last-used-at">
|
||||
<CalendarClock size={14} />
|
||||
Last used <Minus size={12} />
|
||||
<Typography.Text>{formattedDateAndTime}</Typography.Text>
|
||||
</div>
|
||||
|
||||
{!isExpired && expiresIn <= EXPIRATION_WITHIN_SEVEN_DAYS && (
|
||||
<div
|
||||
className={cx(
|
||||
'api-key-expires-in',
|
||||
expiresIn <= 3 ? 'danger' : 'warning',
|
||||
)}
|
||||
>
|
||||
<span className="dot" /> Expires {dayjs().to(expiresOn)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isExpired && (
|
||||
<div className={cx('api-key-expires-in danger')}>
|
||||
<span className="dot" /> Expired
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="api-key-container">
|
||||
<div className="api-key-content">
|
||||
<header>
|
||||
<Typography.Title className="title">API Keys</Typography.Title>
|
||||
<Typography.Text className="subtitle">
|
||||
Create and manage API keys for the SigNoz API
|
||||
</Typography.Text>
|
||||
</header>
|
||||
|
||||
<div className="api-keys-search-add-new">
|
||||
<Input
|
||||
placeholder="Search for keys..."
|
||||
prefix={<Search size={12} color={Color.BG_VANILLA_400} />}
|
||||
value={searchValue}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="add-new-api-key-btn"
|
||||
type="primary"
|
||||
onClick={showAddModal}
|
||||
>
|
||||
<Plus size={14} /> New Key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
loading={isLoading || isRefetching}
|
||||
showHeader={false}
|
||||
pagination={{
|
||||
pageSize: 5,
|
||||
hideOnSinglePage: true,
|
||||
showTotal: (total: number, range: number[]): string =>
|
||||
`${range[0]}-${range[1]} of ${total} keys`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Delete Key Modal */}
|
||||
<Modal
|
||||
className="delete-api-key-modal"
|
||||
title={<span className="title">Delete Key</span>}
|
||||
open={isDeleteModalOpen}
|
||||
closable
|
||||
afterClose={handleModalClose}
|
||||
onCancel={hideDeleteViewModal}
|
||||
destroyOnClose
|
||||
footer={[
|
||||
<Button
|
||||
key="cancel"
|
||||
onClick={hideDeleteViewModal}
|
||||
className="cancel-btn"
|
||||
icon={<X size={16} />}
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
icon={<Trash2 size={16} />}
|
||||
loading={isDeleteingAPIKey}
|
||||
onClick={onDeleteHandler}
|
||||
className="delete-btn"
|
||||
>
|
||||
Delete key
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Typography.Text className="delete-text">
|
||||
{t('delete_confirm_message', {
|
||||
keyName: activeAPIKey?.name,
|
||||
})}
|
||||
</Typography.Text>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Key Modal */}
|
||||
<Modal
|
||||
className="api-key-modal"
|
||||
title="Edit key"
|
||||
open={isEditModalOpen}
|
||||
key="edit-api-key-modal"
|
||||
afterClose={handleModalClose}
|
||||
// closable
|
||||
onCancel={hideEditViewModal}
|
||||
destroyOnClose
|
||||
footer={[
|
||||
<Button
|
||||
key="cancel"
|
||||
onClick={hideEditViewModal}
|
||||
className="periscope-btn cancel-btn"
|
||||
icon={<X size={16} />}
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button
|
||||
className="periscope-btn primary"
|
||||
key="submit"
|
||||
type="primary"
|
||||
loading={isLoadingUpdateAPIKey}
|
||||
icon={<Check size={14} />}
|
||||
onClick={onUpdateApiKey}
|
||||
>
|
||||
Update key
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Form
|
||||
name="edit-api-key-form"
|
||||
key={activeAPIKey?.id}
|
||||
form={editForm}
|
||||
layout="vertical"
|
||||
autoComplete="off"
|
||||
initialValues={{
|
||||
name: activeAPIKey?.name,
|
||||
role: activeAPIKey?.role,
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="Name"
|
||||
rules={[{ required: true }, { type: 'string', min: 6 }]}
|
||||
>
|
||||
<Input placeholder="Enter Key Name" autoFocus />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="role" label="Role">
|
||||
<Flex vertical gap="middle">
|
||||
<Radio.Group
|
||||
buttonStyle="solid"
|
||||
className="api-key-access-role"
|
||||
defaultValue={activeAPIKey?.role}
|
||||
>
|
||||
<Radio.Button value={USER_ROLES.ADMIN} className={cx('tab')}>
|
||||
<div className="role">
|
||||
<Contact2 size={14} /> Admin
|
||||
</div>
|
||||
</Radio.Button>
|
||||
<Radio.Button value={USER_ROLES.EDITOR} className={cx('tab')}>
|
||||
<div className="role">
|
||||
<ClipboardEdit size={14} /> Editor
|
||||
</div>
|
||||
</Radio.Button>
|
||||
<Radio.Button value={USER_ROLES.VIEWER} className={cx('tab')}>
|
||||
<div className="role">
|
||||
<Eye size={14} /> Viewer
|
||||
</div>
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Create New Key Modal */}
|
||||
<Modal
|
||||
className="api-key-modal"
|
||||
title="Create new key"
|
||||
open={isAddModalOpen}
|
||||
key="create-api-key-modal"
|
||||
closable
|
||||
onCancel={hideAddViewModal}
|
||||
destroyOnClose
|
||||
footer={
|
||||
showNewAPIKeyDetails
|
||||
? [
|
||||
<Button
|
||||
key="copy-key-close"
|
||||
className="periscope-btn primary"
|
||||
data-testid="copy-key-close-btn"
|
||||
type="primary"
|
||||
onClick={handleCopyClose}
|
||||
icon={<Check size={12} />}
|
||||
>
|
||||
Copy key and close
|
||||
</Button>,
|
||||
]
|
||||
: [
|
||||
<Button
|
||||
key="cancel"
|
||||
onClick={hideAddViewModal}
|
||||
className="periscope-btn cancel-btn"
|
||||
icon={<X size={16} />}
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button
|
||||
className="periscope-btn primary"
|
||||
test-id="create-new-key"
|
||||
key="submit"
|
||||
type="primary"
|
||||
icon={<Check size={14} />}
|
||||
loading={isLoadingCreateAPIKey}
|
||||
onClick={onCreateAPIKey}
|
||||
>
|
||||
Create new key
|
||||
</Button>,
|
||||
]
|
||||
}
|
||||
>
|
||||
{!showNewAPIKeyDetails && (
|
||||
<Form
|
||||
key="createForm"
|
||||
name="create-api-key-form"
|
||||
form={createForm}
|
||||
initialValues={{
|
||||
role: USER_ROLES.ADMIN,
|
||||
expiration: '1',
|
||||
name: '',
|
||||
}}
|
||||
layout="vertical"
|
||||
autoComplete="off"
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="Name"
|
||||
rules={[{ required: true }, { type: 'string', min: 6 }]}
|
||||
validateTrigger="onFinish"
|
||||
>
|
||||
<Input placeholder="Enter Key Name" autoFocus />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="role" label="Role">
|
||||
<Flex vertical gap="middle">
|
||||
<Radio.Group
|
||||
buttonStyle="solid"
|
||||
className="api-key-access-role"
|
||||
defaultValue={USER_ROLES.ADMIN}
|
||||
>
|
||||
<Radio.Button value={USER_ROLES.ADMIN} className={cx('tab')}>
|
||||
<div className="role" data-testid="create-form-admin-role-btn">
|
||||
<Contact2 size={14} /> Admin
|
||||
</div>
|
||||
</Radio.Button>
|
||||
<Radio.Button value={USER_ROLES.EDITOR} className="tab">
|
||||
<div className="role" data-testid="create-form-editor-role-btn">
|
||||
<ClipboardEdit size={14} /> Editor
|
||||
</div>
|
||||
</Radio.Button>
|
||||
<Radio.Button value={USER_ROLES.VIEWER} className="tab">
|
||||
<div className="role" data-testid="create-form-viewer-role-btn">
|
||||
<Eye size={14} /> Viewer
|
||||
</div>
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
<Form.Item name="expiration" label="Expiration">
|
||||
<Select
|
||||
className="expiration-selector"
|
||||
placeholder="Expiration"
|
||||
options={API_KEY_EXPIRY_OPTIONS}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{showNewAPIKeyDetails && (
|
||||
<div className="api-key-info-container">
|
||||
<Row>
|
||||
<Col span={8}>Key</Col>
|
||||
<Col span={16}>
|
||||
<span className="copyable-text">
|
||||
<Typography.Text>
|
||||
{activeAPIKey?.token.substring(0, 2)}****************
|
||||
{activeAPIKey?.token.substring(activeAPIKey.token.length - 2).trim()}
|
||||
</Typography.Text>
|
||||
|
||||
<Copy
|
||||
className="copy-key-btn"
|
||||
size={12}
|
||||
onClick={(): void => {
|
||||
if (activeAPIKey) {
|
||||
handleCopyKey(activeAPIKey.token);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Col span={8}>Name</Col>
|
||||
<Col span={16}>{activeAPIKey?.name}</Col>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Col span={8}>Role</Col>
|
||||
<Col span={16}>
|
||||
{activeAPIKey?.role === USER_ROLES.ADMIN && (
|
||||
<div className="role">
|
||||
<Contact2 size={14} /> Admin
|
||||
</div>
|
||||
)}
|
||||
{activeAPIKey?.role === USER_ROLES.EDITOR && (
|
||||
<div className="role">
|
||||
{' '}
|
||||
<ClipboardEdit size={14} /> Editor
|
||||
</div>
|
||||
)}
|
||||
{activeAPIKey?.role === USER_ROLES.VIEWER && (
|
||||
<div className="role">
|
||||
{' '}
|
||||
<View size={14} /> Viewer
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Col span={8}>Creator</Col>
|
||||
|
||||
<Col span={16} className="user-info">
|
||||
<Avatar className="user-avatar" size="small">
|
||||
{activeAPIKey?.createdByUser?.displayName?.substring(0, 1)}
|
||||
</Avatar>
|
||||
|
||||
<Typography.Text>
|
||||
{activeAPIKey?.createdByUser?.displayName}
|
||||
</Typography.Text>
|
||||
|
||||
<div className="user-email">{activeAPIKey?.createdByUser?.email}</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{activeAPIKey?.createdAt && (
|
||||
<Row>
|
||||
<Col span={8}>Created on</Col>
|
||||
<Col span={16}>
|
||||
{new Date(activeAPIKey?.createdAt).toLocaleString()}
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{activeAPIKey?.expiresAt !== 0 && activeAPIKey?.expiresAt && (
|
||||
<Row>
|
||||
<Col span={8}>Expires on</Col>
|
||||
<Col span={16}>{getFormattedTime(activeAPIKey?.expiresAt)}</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{activeAPIKey?.expiresAt === 0 && (
|
||||
<Row>
|
||||
<Col span={8}>Expires on</Col>
|
||||
<Col span={16}> No Expiry </Col>
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default APIKeys;
|
||||
@@ -16,7 +16,6 @@ import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { IS_SERVICE_ACCOUNTS_ENABLED } from 'container/ServiceAccountsSettings/config';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
@@ -265,23 +264,21 @@ export default function Home(): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className="home-container">
|
||||
{IS_SERVICE_ACCOUNTS_ENABLED && (
|
||||
<PersistedAnnouncementBanner
|
||||
type="warning"
|
||||
storageKey={LOCALSTORAGE.DISMISSED_API_KEYS_DEPRECATION_BANNER}
|
||||
message={
|
||||
<>
|
||||
<strong>API Keys</strong> have been deprecated and replaced by{' '}
|
||||
<strong>Service Accounts</strong>. Please migrate to Service Accounts for
|
||||
programmatic API access.
|
||||
</>
|
||||
}
|
||||
action={{
|
||||
label: 'Go to Service Accounts',
|
||||
onClick: (): void => history.push(ROUTES.SERVICE_ACCOUNTS_SETTINGS),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<PersistedAnnouncementBanner
|
||||
type="warning"
|
||||
storageKey={LOCALSTORAGE.DISMISSED_API_KEYS_DEPRECATION_BANNER}
|
||||
message={
|
||||
<>
|
||||
<strong>API Keys</strong> have been deprecated and replaced by{' '}
|
||||
<strong>Service Accounts</strong>. Please migrate to Service Accounts for
|
||||
programmatic API access.
|
||||
</>
|
||||
}
|
||||
action={{
|
||||
label: 'Go to Service Accounts',
|
||||
onClick: (): void => history.push(ROUTES.SERVICE_ACCOUNTS_SETTINGS),
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="sticky-header">
|
||||
<Header
|
||||
|
||||
@@ -1,16 +1,3 @@
|
||||
.live-logs-container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.live-logs-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.live-logs-chart-container {
|
||||
height: 200px;
|
||||
min-height: 200px;
|
||||
@@ -18,12 +5,6 @@
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.live-logs-list-container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.live-logs-settings-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -18,19 +18,6 @@
|
||||
}
|
||||
|
||||
.live-logs-list {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
|
||||
.ant-card,
|
||||
.ant-card-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.live-logs-list-loading {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
|
||||
@@ -8,8 +8,8 @@ import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { CARD_BODY_STYLE } from 'constants/card';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { OptionFormatTypes } from 'constants/optionsFormatTypes';
|
||||
import InfinityTableView from 'container/LogsExplorerList/InfinityTableView';
|
||||
import { InfinityWrapperStyled } from 'container/LogsExplorerList/styles';
|
||||
import TanStackTableView from 'container/LogsExplorerList/TanStackTableView';
|
||||
import { convertKeysToColumnFields } from 'container/LogsExplorerList/utils';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
|
||||
@@ -50,7 +50,7 @@ function LiveLogsList({
|
||||
[logs],
|
||||
);
|
||||
|
||||
const { options, config } = useOptionsMenu({
|
||||
const { options } = useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator: StringOperators.NOOP,
|
||||
@@ -158,7 +158,7 @@ function LiveLogsList({
|
||||
{formattedLogs.length !== 0 && (
|
||||
<InfinityWrapperStyled>
|
||||
{options.format === OptionFormatTypes.TABLE ? (
|
||||
<TanStackTableView
|
||||
<InfinityTableView
|
||||
ref={ref}
|
||||
isLoading={false}
|
||||
tableViewProps={{
|
||||
@@ -174,7 +174,6 @@ function LiveLogsList({
|
||||
onSetActiveLog={handleSetActiveLog}
|
||||
onClearActiveLog={handleCloseLogDetail}
|
||||
activeLog={activeLog}
|
||||
onRemoveColumn={config.addColumn?.onRemove}
|
||||
/>
|
||||
) : (
|
||||
<Card style={{ width: '100%' }} bodyStyle={CARD_BODY_STYLE}>
|
||||
|
||||
@@ -4,11 +4,18 @@ import { FontSize } from 'container/OptionsMenu/types';
|
||||
export const infinityDefaultStyles: CSSProperties = {
|
||||
width: '100%',
|
||||
overflowX: 'scroll',
|
||||
marginTop: '15px',
|
||||
};
|
||||
|
||||
export function getInfinityDefaultStyles(_fontSize: FontSize): CSSProperties {
|
||||
export function getInfinityDefaultStyles(fontSize: FontSize): CSSProperties {
|
||||
return {
|
||||
width: '100%',
|
||||
overflowX: 'scroll',
|
||||
marginTop:
|
||||
fontSize === FontSize.SMALL
|
||||
? '10px'
|
||||
: fontSize === FontSize.MEDIUM
|
||||
? '12px'
|
||||
: '15px',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,78 +14,6 @@ interface TableHeaderCellStyledProps {
|
||||
|
||||
export const TableStyled = styled.table`
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
`;
|
||||
|
||||
/**
|
||||
* TanStack column sizing uses table-layout:fixed + colgroup widths; without clipping,
|
||||
* cell content overflows visually on top of neighbouring columns (overlap / "ghost" text).
|
||||
*/
|
||||
export const TanStackTableStyled = styled(TableStyled)`
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
|
||||
& td,
|
||||
& th {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
& td.table-actions-cell {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
& td.body {
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Let nested body HTML / line-clamp shrink inside fixed columns */
|
||||
& td.body > * {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Long column titles: ellipsis when wider than the column (TanStackHeaderRow) */
|
||||
& thead th .tanstack-header-title {
|
||||
min-width: 0;
|
||||
flex: 1 1 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
& thead th .tanstack-header-title > * {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
& td.logs-table-filler-cell,
|
||||
& th.logs-table-filler-header {
|
||||
padding: 0 !important;
|
||||
min-width: 0;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
& th.logs-table-actions-header {
|
||||
position: sticky;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
width: 0 !important;
|
||||
min-width: 0 !important;
|
||||
max-width: 0 !important;
|
||||
padding: 0 !important;
|
||||
overflow: visible;
|
||||
white-space: nowrap;
|
||||
border-left: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const getTimestampColumnWidth = (
|
||||
@@ -118,19 +46,6 @@ export const TableCellStyled = styled.td<TableHeaderCellStyledProps>`
|
||||
|
||||
${({ columnKey, $hasSingleColumn }): string =>
|
||||
getTimestampColumnWidth(columnKey, $hasSingleColumn)}
|
||||
|
||||
&.table-actions-cell {
|
||||
position: sticky;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
max-width: 0;
|
||||
padding: 0 !important;
|
||||
white-space: nowrap;
|
||||
overflow: visible;
|
||||
background-color: inherit;
|
||||
}
|
||||
`;
|
||||
|
||||
export const TableRowStyled = styled.tr<{
|
||||
@@ -149,10 +64,7 @@ export const TableRowStyled = styled.tr<{
|
||||
position: relative;
|
||||
|
||||
.log-line-action-buttons {
|
||||
display: flex;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 80ms linear;
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@@ -161,25 +73,13 @@ export const TableRowStyled = styled.tr<{
|
||||
getActiveLogBackground(true, $isDarkMode, $logType)}
|
||||
}
|
||||
.log-line-action-buttons {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
${({ $isActiveLog }): string =>
|
||||
$isActiveLog
|
||||
? `
|
||||
.log-line-action-buttons {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
`
|
||||
: ''}
|
||||
`;
|
||||
|
||||
export const TableHeaderCellStyled = styled.th<TableHeaderCellStyledProps>`
|
||||
padding: 0.5rem;
|
||||
height: 36px;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
@@ -198,12 +98,6 @@ export const TableHeaderCellStyled = styled.th<TableHeaderCellStyledProps>`
|
||||
: ``};
|
||||
${({ $isLogIndicator }): string =>
|
||||
$isLogIndicator ? 'padding: 0px; width: 1%;' : ''}
|
||||
border-top: 1px solid var(--l2-border);
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
box-shadow: inset 0 -1px 0 var(--l2-border);
|
||||
&:first-child {
|
||||
border-left: 1px solid var(--l2-border);
|
||||
}
|
||||
color: ${(props): string =>
|
||||
props.$isDarkMode ? 'var(--bg-vanilla-100, #fff)' : themeColors.bckgGrey};
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ import { ILog } from 'types/api/logs/log';
|
||||
|
||||
export type InfinityTableProps = {
|
||||
isLoading?: boolean;
|
||||
isFetching?: boolean;
|
||||
onRemoveColumn?: (columnKey: string) => void;
|
||||
tableViewProps: Omit<UseTableViewProps, 'onOpenLogsContext' | 'onClickExpand'>;
|
||||
infitiyTableProps?: {
|
||||
onEndReached: (index: number) => void;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.005em;
|
||||
text-align: left;
|
||||
min-height: 0;
|
||||
min-height: 500px;
|
||||
|
||||
.logs-list-table-view-container {
|
||||
.data-table-container {
|
||||
@@ -24,11 +24,11 @@
|
||||
color: white !important;
|
||||
|
||||
.cursor-col-resize {
|
||||
width: 24px !important;
|
||||
width: 3px !important;
|
||||
cursor: col-resize !important;
|
||||
opacity: 1 !important;
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
opacity: 0.5 !important;
|
||||
background-color: var(--bg-ink-500) !important;
|
||||
border: 1px solid var(--bg-ink-500) !important;
|
||||
|
||||
&:hover {
|
||||
opacity: 1 !important;
|
||||
@@ -85,7 +85,7 @@
|
||||
}
|
||||
|
||||
thead {
|
||||
z-index: 2 !important;
|
||||
z-index: 0 !important;
|
||||
}
|
||||
|
||||
.log-state-indicator {
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
const RowHoverContext = createContext(false);
|
||||
|
||||
export const useRowHover = (): boolean => useContext(RowHoverContext);
|
||||
|
||||
export default RowHoverContext;
|
||||
@@ -1,84 +0,0 @@
|
||||
import { ComponentProps, memo, useCallback, useState } from 'react';
|
||||
import { TableComponents } from 'react-virtuoso';
|
||||
import {
|
||||
getLogIndicatorType,
|
||||
getLogIndicatorTypeForTable,
|
||||
} from 'components/Logs/LogStateIndicator/utils';
|
||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
import { TableRowStyled } from '../InfinityTableView/styles';
|
||||
import RowHoverContext from '../RowHoverContext';
|
||||
import { TanStackTableRowData } from './types';
|
||||
|
||||
export type TableRowContext = {
|
||||
activeLog?: ILog | null;
|
||||
activeContextLog?: ILog | null;
|
||||
logsById: Map<string, ILog>;
|
||||
};
|
||||
|
||||
type VirtuosoTableRowProps = ComponentProps<
|
||||
NonNullable<TableComponents<TanStackTableRowData, TableRowContext>['TableRow']>
|
||||
>;
|
||||
|
||||
type TanStackCustomTableRowProps = VirtuosoTableRowProps;
|
||||
|
||||
function TanStackCustomTableRow({
|
||||
children,
|
||||
item,
|
||||
context,
|
||||
...props
|
||||
}: TanStackCustomTableRowProps): JSX.Element {
|
||||
const { isHighlighted } = useCopyLogLink(item.currentLog.id);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const [hasHovered, setHasHovered] = useState(false);
|
||||
const rowId = String(item.currentLog.id ?? '');
|
||||
const activeLog = context?.activeLog;
|
||||
const activeContextLog = context?.activeContextLog;
|
||||
const logsById = context?.logsById;
|
||||
const rowLog = logsById?.get(rowId) || item.currentLog;
|
||||
const logType = rowLog
|
||||
? getLogIndicatorType(rowLog)
|
||||
: getLogIndicatorTypeForTable(item.log);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
if (!hasHovered) {
|
||||
setHasHovered(true);
|
||||
}
|
||||
}, [hasHovered]);
|
||||
|
||||
return (
|
||||
<RowHoverContext.Provider value={hasHovered}>
|
||||
<TableRowStyled
|
||||
{...props}
|
||||
$isDarkMode={isDarkMode}
|
||||
$isActiveLog={
|
||||
isHighlighted ||
|
||||
rowId === String(activeLog?.id ?? '') ||
|
||||
rowId === String(activeContextLog?.id ?? '')
|
||||
}
|
||||
$logType={logType}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
>
|
||||
{children}
|
||||
</TableRowStyled>
|
||||
</RowHoverContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(TanStackCustomTableRow, (prev, next) => {
|
||||
const prevId = String(prev.item.currentLog.id ?? '');
|
||||
const nextId = String(next.item.currentLog.id ?? '');
|
||||
if (prevId !== nextId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const prevIsActive =
|
||||
prevId === String(prev.context?.activeLog?.id ?? '') ||
|
||||
prevId === String(prev.context?.activeContextLog?.id ?? '');
|
||||
const nextIsActive =
|
||||
nextId === String(next.context?.activeLog?.id ?? '') ||
|
||||
nextId === String(next.context?.activeContextLog?.id ?? '');
|
||||
return prevIsActive === nextIsActive;
|
||||
});
|
||||
@@ -1,193 +0,0 @@
|
||||
import type {
|
||||
CSSProperties,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
TouchEvent as ReactTouchEvent,
|
||||
} from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { CloseOutlined, MoreOutlined } from '@ant-design/icons';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/popover';
|
||||
import { flexRender, Header as TanStackHeader } from '@tanstack/react-table';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
|
||||
import { TableHeaderCellStyled } from '../InfinityTableView/styles';
|
||||
import { InfinityTableProps } from '../InfinityTableView/types';
|
||||
import { OrderedColumn, TanStackTableRowData } from './types';
|
||||
import { getColumnId } from './utils';
|
||||
|
||||
import './styles/TanStackHeaderRow.styles.scss';
|
||||
|
||||
type TanStackHeaderRowProps = {
|
||||
column: OrderedColumn;
|
||||
header?: TanStackHeader<TanStackTableRowData, unknown>;
|
||||
isDarkMode: boolean;
|
||||
fontSize: InfinityTableProps['tableViewProps']['fontSize'];
|
||||
hasSingleColumn: boolean;
|
||||
canRemoveColumn?: boolean;
|
||||
onRemoveColumn?: (columnKey: string) => void;
|
||||
};
|
||||
|
||||
const GRIP_ICON_SIZE = 12;
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function TanStackHeaderRow({
|
||||
column,
|
||||
header,
|
||||
isDarkMode,
|
||||
fontSize,
|
||||
hasSingleColumn,
|
||||
canRemoveColumn = false,
|
||||
onRemoveColumn,
|
||||
}: TanStackHeaderRowProps): JSX.Element {
|
||||
const columnId = getColumnId(column);
|
||||
const isDragColumn =
|
||||
column.key !== 'expand' && column.key !== 'state-indicator';
|
||||
const isResizableColumn = Boolean(header?.column.getCanResize());
|
||||
const isColumnRemovable = Boolean(
|
||||
canRemoveColumn &&
|
||||
onRemoveColumn &&
|
||||
column.key !== 'expand' &&
|
||||
column.key !== 'state-indicator',
|
||||
);
|
||||
const isResizing = Boolean(header?.column.getIsResizing());
|
||||
const resizeHandler = header?.getResizeHandler();
|
||||
const headerText =
|
||||
typeof column.title === 'string' && column.title
|
||||
? column.title
|
||||
: String(header?.id ?? columnId);
|
||||
const headerTitleAttr = headerText.replace(/^\w/, (c) => c.toUpperCase());
|
||||
const handleResizeStart = (
|
||||
event: ReactMouseEvent<HTMLElement> | ReactTouchEvent<HTMLElement>,
|
||||
): void => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
resizeHandler?.(event);
|
||||
};
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: columnId,
|
||||
disabled: !isDragColumn,
|
||||
});
|
||||
const headerCellStyle = useMemo(
|
||||
() =>
|
||||
({
|
||||
'--tanstack-header-translate-x': `${Math.round(transform?.x ?? 0)}px`,
|
||||
'--tanstack-header-translate-y': `${Math.round(transform?.y ?? 0)}px`,
|
||||
'--tanstack-header-transition': isResizing ? 'none' : transition || 'none',
|
||||
} as CSSProperties),
|
||||
[isResizing, transform?.x, transform?.y, transition],
|
||||
);
|
||||
const headerCellClassName = [
|
||||
'tanstack-header-cell',
|
||||
isDragging ? 'is-dragging' : '',
|
||||
isResizing ? 'is-resizing' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const headerContentClassName = [
|
||||
'tanstack-header-content',
|
||||
isResizableColumn ? 'has-resize-control' : '',
|
||||
isColumnRemovable ? 'has-action-control' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<TableHeaderCellStyled
|
||||
ref={setNodeRef}
|
||||
$isLogIndicator={column.key === 'state-indicator'}
|
||||
$isDarkMode={isDarkMode}
|
||||
$isDragColumn={false}
|
||||
className={headerCellClassName}
|
||||
key={columnId}
|
||||
fontSize={fontSize}
|
||||
$hasSingleColumn={hasSingleColumn}
|
||||
style={headerCellStyle}
|
||||
>
|
||||
<span className={headerContentClassName}>
|
||||
{isDragColumn ? (
|
||||
<span className="tanstack-grip-slot">
|
||||
<span
|
||||
ref={setActivatorNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
role="button"
|
||||
aria-label={`Drag ${String(
|
||||
column.title || header?.id || columnId,
|
||||
)} column`}
|
||||
className="tanstack-grip-activator"
|
||||
>
|
||||
<GripVertical size={GRIP_ICON_SIZE} />
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
<span className="tanstack-header-title" title={headerTitleAttr}>
|
||||
{header
|
||||
? flexRender(header.column.columnDef.header, header.getContext())
|
||||
: String(column.title || '').replace(/^\w/, (c) => c.toUpperCase())}
|
||||
</span>
|
||||
{isColumnRemovable && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<span
|
||||
role="button"
|
||||
aria-label={`Column actions for ${headerTitleAttr}`}
|
||||
className="tanstack-header-action-trigger"
|
||||
onMouseDown={(event): void => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<MoreOutlined />
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
sideOffset={6}
|
||||
className="tanstack-column-actions-content"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="tanstack-remove-column-action"
|
||||
onClick={(event): void => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onRemoveColumn?.(String(column.key));
|
||||
}}
|
||||
>
|
||||
<CloseOutlined className="tanstack-remove-column-action-icon" />
|
||||
Remove column
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</span>
|
||||
{isResizableColumn && (
|
||||
<span
|
||||
role="presentation"
|
||||
className="cursor-col-resize"
|
||||
title="Drag to resize column"
|
||||
onClick={(event): void => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onMouseDown={(event): void => {
|
||||
handleResizeStart(event);
|
||||
}}
|
||||
onTouchStart={(event): void => {
|
||||
handleResizeStart(event);
|
||||
}}
|
||||
>
|
||||
<span className="tanstack-resize-handle-line" />
|
||||
</span>
|
||||
)}
|
||||
</TableHeaderCellStyled>
|
||||
);
|
||||
}
|
||||
|
||||
export default TanStackHeaderRow;
|
||||
@@ -1,95 +0,0 @@
|
||||
import {
|
||||
MouseEvent as ReactMouseEvent,
|
||||
MouseEventHandler,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import { flexRender, Row as TanStackRowModel } from '@tanstack/react-table';
|
||||
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
||||
import LogLinesActionButtons from 'components/Logs/LogLinesActionButtons/LogLinesActionButtons';
|
||||
|
||||
import { TableCellStyled } from '../InfinityTableView/styles';
|
||||
import { InfinityTableProps } from '../InfinityTableView/types';
|
||||
import { useRowHover } from '../RowHoverContext';
|
||||
import { TanStackTableRowData } from './types';
|
||||
|
||||
type TanStackRowCellsProps = {
|
||||
row: TanStackRowModel<TanStackTableRowData>;
|
||||
fontSize: InfinityTableProps['tableViewProps']['fontSize'];
|
||||
onSetActiveLog?: InfinityTableProps['onSetActiveLog'];
|
||||
onClearActiveLog?: InfinityTableProps['onClearActiveLog'];
|
||||
isActiveLog?: boolean;
|
||||
isDarkMode: boolean;
|
||||
onLogCopy: (logId: string, event: ReactMouseEvent<HTMLElement>) => void;
|
||||
isLogsExplorerPage: boolean;
|
||||
};
|
||||
|
||||
function TanStackRowCells({
|
||||
row,
|
||||
fontSize,
|
||||
onSetActiveLog,
|
||||
onClearActiveLog,
|
||||
isActiveLog = false,
|
||||
isDarkMode,
|
||||
onLogCopy,
|
||||
isLogsExplorerPage,
|
||||
}: TanStackRowCellsProps): JSX.Element {
|
||||
const { currentLog } = row.original;
|
||||
const hasHovered = useRowHover();
|
||||
|
||||
const handleShowContext: MouseEventHandler<HTMLElement> = useCallback(
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onSetActiveLog?.(currentLog, VIEW_TYPES.CONTEXT);
|
||||
},
|
||||
[currentLog, onSetActiveLog],
|
||||
);
|
||||
|
||||
const handleShowLogDetails = useCallback(() => {
|
||||
if (!currentLog) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isActiveLog && onClearActiveLog) {
|
||||
onClearActiveLog();
|
||||
return;
|
||||
}
|
||||
|
||||
onSetActiveLog?.(currentLog);
|
||||
}, [currentLog, isActiveLog, onClearActiveLog, onSetActiveLog]);
|
||||
|
||||
const visibleCells = row.getVisibleCells();
|
||||
const lastCellIndex = visibleCells.length - 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
{visibleCells.map((cell, index) => {
|
||||
const columnKey = cell.column.id;
|
||||
const isLastCell = index === lastCellIndex;
|
||||
return (
|
||||
<TableCellStyled
|
||||
$isDragColumn={false}
|
||||
$isLogIndicator={columnKey === 'state-indicator'}
|
||||
$hasSingleColumn={visibleCells.length <= 2}
|
||||
$isDarkMode={isDarkMode}
|
||||
key={cell.id}
|
||||
fontSize={fontSize}
|
||||
className={columnKey}
|
||||
onClick={handleShowLogDetails}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
{isLastCell && isLogsExplorerPage && hasHovered && (
|
||||
<LogLinesActionButtons
|
||||
handleShowContext={handleShowContext}
|
||||
onLogCopy={(event): void => onLogCopy(currentLog.id, event)}
|
||||
customClassName="table-view-log-actions"
|
||||
/>
|
||||
)}
|
||||
</TableCellStyled>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default TanStackRowCells;
|
||||
@@ -1,105 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import TanStackCustomTableRow, {
|
||||
TableRowContext,
|
||||
} from '../TanStackCustomTableRow';
|
||||
import type { TanStackTableRowData } from '../types';
|
||||
|
||||
jest.mock('../../InfinityTableView/styles', () => ({
|
||||
TableRowStyled: 'tr',
|
||||
}));
|
||||
|
||||
jest.mock('hooks/logs/useCopyLogLink', () => ({
|
||||
useCopyLogLink: (): { isHighlighted: boolean } => ({ isHighlighted: false }),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: (): boolean => false,
|
||||
}));
|
||||
|
||||
jest.mock('components/Logs/LogStateIndicator/utils', () => ({
|
||||
getLogIndicatorType: (): string => 'info',
|
||||
getLogIndicatorTypeForTable: (): string => 'info',
|
||||
}));
|
||||
|
||||
const item: TanStackTableRowData = {
|
||||
log: {},
|
||||
currentLog: { id: 'row-1' } as TanStackTableRowData['currentLog'],
|
||||
rowIndex: 0,
|
||||
};
|
||||
|
||||
const virtuosoTableRowAttrs = {
|
||||
'data-index': 0,
|
||||
'data-item-index': 0,
|
||||
'data-known-size': 40,
|
||||
} as const;
|
||||
|
||||
const defaultContext: TableRowContext = {
|
||||
activeLog: null,
|
||||
activeContextLog: null,
|
||||
logsById: new Map(),
|
||||
};
|
||||
|
||||
describe('TanStackCustomTableRow', () => {
|
||||
it('renders children inside TableRowStyled', () => {
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<TanStackCustomTableRow
|
||||
{...virtuosoTableRowAttrs}
|
||||
item={item}
|
||||
context={defaultContext}
|
||||
>
|
||||
<td>cell</td>
|
||||
</TanStackCustomTableRow>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('cell')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks row active when activeLog matches item id', () => {
|
||||
const { container } = render(
|
||||
<table>
|
||||
<tbody>
|
||||
<TanStackCustomTableRow
|
||||
{...virtuosoTableRowAttrs}
|
||||
item={item}
|
||||
context={{
|
||||
...defaultContext,
|
||||
activeLog: { id: 'row-1' } as never,
|
||||
}}
|
||||
>
|
||||
<td>x</td>
|
||||
</TanStackCustomTableRow>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
|
||||
const row = container.querySelector('tr');
|
||||
expect(row).toBeTruthy();
|
||||
});
|
||||
|
||||
it('uses logsById entry when present for indicator type', () => {
|
||||
const logFromMap = { id: 'row-1', severity_text: 'error' } as never;
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<TanStackCustomTableRow
|
||||
{...virtuosoTableRowAttrs}
|
||||
item={item}
|
||||
context={{
|
||||
...defaultContext,
|
||||
logsById: new Map([['row-1', logFromMap]]),
|
||||
}}
|
||||
>
|
||||
<td>x</td>
|
||||
</TanStackCustomTableRow>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('x')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,152 +0,0 @@
|
||||
import type { Header } from '@tanstack/react-table';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
|
||||
import TanStackHeaderRow from '../TanStackHeaderRow';
|
||||
import type { OrderedColumn, TanStackTableRowData } from '../types';
|
||||
|
||||
jest.mock('../../InfinityTableView/styles', () => ({
|
||||
TableHeaderCellStyled: 'th',
|
||||
}));
|
||||
|
||||
const mockUseSortable = jest.fn((_args?: unknown) => ({
|
||||
attributes: {},
|
||||
listeners: {},
|
||||
setNodeRef: jest.fn(),
|
||||
setActivatorNodeRef: jest.fn(),
|
||||
transform: null,
|
||||
transition: undefined,
|
||||
isDragging: false,
|
||||
}));
|
||||
|
||||
jest.mock('@dnd-kit/sortable', () => ({
|
||||
useSortable: (args: unknown): ReturnType<typeof mockUseSortable> =>
|
||||
mockUseSortable(args),
|
||||
}));
|
||||
|
||||
jest.mock('@tanstack/react-table', () => ({
|
||||
flexRender: (def: unknown, ctx: unknown): unknown => {
|
||||
if (typeof def === 'string') {
|
||||
return def;
|
||||
}
|
||||
if (typeof def === 'function') {
|
||||
return (def as (c: unknown) => unknown)(ctx);
|
||||
}
|
||||
return def;
|
||||
},
|
||||
}));
|
||||
|
||||
const column = (key: string): OrderedColumn =>
|
||||
({ key, title: key } as OrderedColumn);
|
||||
|
||||
const mockHeader = (
|
||||
id: string,
|
||||
canResize = true,
|
||||
): Header<TanStackTableRowData, unknown> =>
|
||||
(({
|
||||
id,
|
||||
column: {
|
||||
getCanResize: (): boolean => canResize,
|
||||
getIsResizing: (): boolean => false,
|
||||
columnDef: { header: id },
|
||||
},
|
||||
getContext: (): unknown => ({}),
|
||||
getResizeHandler: (): (() => void) => jest.fn(),
|
||||
flexRender: undefined,
|
||||
} as unknown) as Header<TanStackTableRowData, unknown>);
|
||||
|
||||
describe('TanStackHeaderRow', () => {
|
||||
beforeEach(() => {
|
||||
mockUseSortable.mockClear();
|
||||
});
|
||||
|
||||
it('renders column title when header is undefined', () => {
|
||||
render(
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<TanStackHeaderRow
|
||||
column={column('timestamp')}
|
||||
isDarkMode={false}
|
||||
fontSize={FontSize.SMALL}
|
||||
hasSingleColumn={false}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Timestamp')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('enables useSortable for draggable columns', () => {
|
||||
render(
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<TanStackHeaderRow
|
||||
column={column('body')}
|
||||
header={mockHeader('body')}
|
||||
isDarkMode={false}
|
||||
fontSize={FontSize.SMALL}
|
||||
hasSingleColumn={false}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>,
|
||||
);
|
||||
|
||||
expect(mockUseSortable).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'body',
|
||||
disabled: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('disables sortable for expand column', () => {
|
||||
render(
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<TanStackHeaderRow
|
||||
column={column('expand')}
|
||||
header={mockHeader('expand', false)}
|
||||
isDarkMode={false}
|
||||
fontSize={FontSize.SMALL}
|
||||
hasSingleColumn={false}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>,
|
||||
);
|
||||
|
||||
expect(mockUseSortable).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
disabled: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows drag grip for draggable columns', () => {
|
||||
render(
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<TanStackHeaderRow
|
||||
column={column('body')}
|
||||
header={mockHeader('body')}
|
||||
isDarkMode={false}
|
||||
fontSize={FontSize.SMALL}
|
||||
hasSingleColumn={false}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Drag body column/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,193 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import RowHoverContext from 'container/LogsExplorerList/RowHoverContext';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
|
||||
import TanStackRowCells from '../TanStackRow';
|
||||
import type { TanStackTableRowData } from '../types';
|
||||
|
||||
jest.mock('../../InfinityTableView/styles', () => ({
|
||||
TableCellStyled: 'td',
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'components/Logs/LogLinesActionButtons/LogLinesActionButtons',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
onLogCopy,
|
||||
}: {
|
||||
onLogCopy: (e: React.MouseEvent) => void;
|
||||
}): JSX.Element => (
|
||||
<button type="button" data-testid="copy-btn" onClick={onLogCopy}>
|
||||
copy
|
||||
</button>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const flexRenderMock = jest.fn((def: unknown, _ctx?: unknown) =>
|
||||
typeof def === 'function' ? def({}) : def,
|
||||
);
|
||||
|
||||
jest.mock('@tanstack/react-table', () => ({
|
||||
flexRender: (def: unknown, ctx: unknown): unknown => flexRenderMock(def, ctx),
|
||||
}));
|
||||
|
||||
function buildMockRow(
|
||||
visibleCells: Array<{ columnId: string }>,
|
||||
): Parameters<typeof TanStackRowCells>[0]['row'] {
|
||||
return {
|
||||
original: {
|
||||
currentLog: { id: 'log-1' } as TanStackTableRowData['currentLog'],
|
||||
log: {},
|
||||
rowIndex: 0,
|
||||
},
|
||||
getVisibleCells: () =>
|
||||
visibleCells.map((cell, index) => ({
|
||||
id: `cell-${index}`,
|
||||
column: {
|
||||
id: cell.columnId,
|
||||
columnDef: {
|
||||
cell: (): string => `content-${cell.columnId}`,
|
||||
},
|
||||
},
|
||||
getContext: (): Record<string, unknown> => ({}),
|
||||
})),
|
||||
} as never;
|
||||
}
|
||||
|
||||
describe('TanStackRowCells', () => {
|
||||
beforeEach(() => {
|
||||
flexRenderMock.mockClear();
|
||||
});
|
||||
|
||||
it('renders a cell per visible column and calls flexRender', () => {
|
||||
const row = buildMockRow([
|
||||
{ columnId: 'state-indicator' },
|
||||
{ columnId: 'body' },
|
||||
]);
|
||||
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<TanStackRowCells
|
||||
row={row}
|
||||
fontSize={FontSize.SMALL}
|
||||
isDarkMode={false}
|
||||
onLogCopy={jest.fn()}
|
||||
isLogsExplorerPage={false}
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByRole('cell')).toHaveLength(2);
|
||||
expect(flexRenderMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies state-indicator styling class on the indicator cell', () => {
|
||||
const row = buildMockRow([{ columnId: 'state-indicator' }]);
|
||||
|
||||
const { container } = render(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<TanStackRowCells
|
||||
row={row}
|
||||
fontSize={FontSize.SMALL}
|
||||
isDarkMode={false}
|
||||
onLogCopy={jest.fn()}
|
||||
isLogsExplorerPage={false}
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('td.state-indicator')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders row actions on logs explorer page after hover', () => {
|
||||
const row = buildMockRow([{ columnId: 'body' }]);
|
||||
|
||||
render(
|
||||
<RowHoverContext.Provider value>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<TanStackRowCells
|
||||
row={row}
|
||||
fontSize={FontSize.SMALL}
|
||||
isDarkMode={false}
|
||||
onLogCopy={jest.fn()}
|
||||
isLogsExplorerPage
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</RowHoverContext.Provider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('copy-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('click on a data cell calls onSetActiveLog with current log', () => {
|
||||
const onSetActiveLog = jest.fn();
|
||||
const row = buildMockRow([{ columnId: 'body' }]);
|
||||
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<TanStackRowCells
|
||||
row={row}
|
||||
fontSize={FontSize.SMALL}
|
||||
isDarkMode={false}
|
||||
onSetActiveLog={onSetActiveLog}
|
||||
onLogCopy={jest.fn()}
|
||||
isLogsExplorerPage={false}
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getAllByRole('cell')[0]);
|
||||
|
||||
expect(onSetActiveLog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'log-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('when row is active log, click on cell clears active log', () => {
|
||||
const onSetActiveLog = jest.fn();
|
||||
const onClearActiveLog = jest.fn();
|
||||
const row = buildMockRow([{ columnId: 'body' }]);
|
||||
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<TanStackRowCells
|
||||
row={row}
|
||||
fontSize={FontSize.SMALL}
|
||||
isDarkMode={false}
|
||||
isActiveLog
|
||||
onSetActiveLog={onSetActiveLog}
|
||||
onClearActiveLog={onClearActiveLog}
|
||||
onLogCopy={jest.fn()}
|
||||
isLogsExplorerPage={false}
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getAllByRole('cell')[0]);
|
||||
|
||||
expect(onClearActiveLog).toHaveBeenCalled();
|
||||
expect(onSetActiveLog).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
|
||||
import type { InfinityTableProps } from '../../InfinityTableView/types';
|
||||
import TanStackTableView from '../index';
|
||||
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
TableVirtuoso: forwardRef<
|
||||
unknown,
|
||||
{
|
||||
fixedHeaderContent?: () => JSX.Element;
|
||||
itemContent: (i: number) => JSX.Element;
|
||||
}
|
||||
>(function MockVirtuoso({ fixedHeaderContent, itemContent }, _ref) {
|
||||
return (
|
||||
<div data-testid="virtuoso">
|
||||
{fixedHeaderContent?.()}
|
||||
{itemContent(0)}
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('components/Logs/TableView/useTableView', () => ({
|
||||
useTableView: (): {
|
||||
dataSource: Record<string, string>[];
|
||||
columns: unknown[];
|
||||
} => ({
|
||||
dataSource: [{ id: '1' }],
|
||||
columns: [
|
||||
{ key: 'body', title: 'body', render: (): string => 'x' },
|
||||
{ key: 'state-indicator', title: 's', render: (): string => 'y' },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useDragColumns', () => ({
|
||||
__esModule: true,
|
||||
default: (): {
|
||||
draggedColumns: unknown[];
|
||||
onColumnOrderChange: () => void;
|
||||
} => ({
|
||||
draggedColumns: [],
|
||||
onColumnOrderChange: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/logs/useActiveLog', () => ({
|
||||
useActiveLog: (): { activeLog: null } => ({ activeLog: null }),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/logs/useCopyLogLink', () => ({
|
||||
useCopyLogLink: (): { activeLogId: null } => ({ activeLogId: null }),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: (): boolean => false,
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
useLocation: (): { pathname: string } => ({ pathname: '/logs' }),
|
||||
}));
|
||||
|
||||
jest.mock('react-use', () => ({
|
||||
useCopyToClipboard: (): [unknown, () => void] => [null, jest.fn()],
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: { success: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('components/Spinner', () => ({
|
||||
__esModule: true,
|
||||
default: ({ tip }: { tip: string }): JSX.Element => (
|
||||
<div data-testid="spinner">{tip}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const baseProps: InfinityTableProps = {
|
||||
isLoading: false,
|
||||
tableViewProps: {
|
||||
logs: [{ id: '1' } as never],
|
||||
fields: [],
|
||||
linesPerRow: 3,
|
||||
fontSize: FontSize.SMALL,
|
||||
appendTo: 'end',
|
||||
activeLogIndex: 0,
|
||||
},
|
||||
};
|
||||
|
||||
describe('TanStackTableView', () => {
|
||||
it('shows spinner while loading', () => {
|
||||
render(<TanStackTableView {...baseProps} isLoading />);
|
||||
|
||||
expect(screen.getByTestId('spinner')).toHaveTextContent('Getting Logs');
|
||||
});
|
||||
|
||||
it('renders virtuoso when not loading', () => {
|
||||
render(<TanStackTableView {...baseProps} />);
|
||||
|
||||
expect(screen.getByTestId('virtuoso')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,173 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
import type { OrderedColumn } from '../types';
|
||||
import { useColumnSizingPersistence } from '../useColumnSizingPersistence';
|
||||
|
||||
const mockGet = jest.fn();
|
||||
const mockSet = jest.fn();
|
||||
|
||||
jest.mock('api/browser/localstorage/get', () => ({
|
||||
__esModule: true,
|
||||
default: (key: string): string | null => mockGet(key),
|
||||
}));
|
||||
|
||||
jest.mock('api/browser/localstorage/set', () => ({
|
||||
__esModule: true,
|
||||
default: (key: string, value: string): void => {
|
||||
mockSet(key, value);
|
||||
},
|
||||
}));
|
||||
|
||||
const col = (key: string): OrderedColumn =>
|
||||
({ key, title: key } as OrderedColumn);
|
||||
|
||||
describe('useColumnSizingPersistence', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGet.mockReturnValue(null);
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('initializes with empty sizing when localStorage is empty', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useColumnSizingPersistence([col('body'), col('timestamp')]),
|
||||
);
|
||||
|
||||
expect(result.current.columnSizing).toEqual({});
|
||||
});
|
||||
|
||||
it('parses flat ColumnSizingState from localStorage', () => {
|
||||
mockGet.mockReturnValue(JSON.stringify({ body: 400, timestamp: 180 }));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useColumnSizingPersistence([col('body'), col('timestamp')]),
|
||||
);
|
||||
|
||||
expect(result.current.columnSizing).toEqual({ body: 400, timestamp: 180 });
|
||||
});
|
||||
|
||||
it('parses PersistedColumnSizing wrapper with sizing + columnIdsSignature', () => {
|
||||
mockGet.mockReturnValue(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
columnIdsSignature: 'body|timestamp',
|
||||
sizing: { body: 300 },
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useColumnSizingPersistence([col('body'), col('timestamp')]),
|
||||
);
|
||||
|
||||
expect(result.current.columnSizing).toEqual({ body: 300 });
|
||||
});
|
||||
|
||||
it('drops invalid numeric entries when reading from localStorage', () => {
|
||||
mockGet.mockReturnValue(
|
||||
JSON.stringify({
|
||||
body: 200,
|
||||
bad: NaN,
|
||||
zero: 0,
|
||||
neg: -1,
|
||||
str: 'wide',
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useColumnSizingPersistence([col('body'), col('bad'), col('zero')]),
|
||||
);
|
||||
|
||||
expect(result.current.columnSizing).toEqual({ body: 200 });
|
||||
});
|
||||
|
||||
it('returns empty sizing when JSON is invalid', () => {
|
||||
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockGet.mockReturnValue('not-json');
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useColumnSizingPersistence([col('body')]),
|
||||
);
|
||||
|
||||
expect(result.current.columnSizing).toEqual({});
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('prunes sizing for columns not in orderedColumns and strips fixed columns', () => {
|
||||
mockGet.mockReturnValue(JSON.stringify({ body: 400, expand: 32, gone: 100 }));
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ columns }: { columns: OrderedColumn[] }) =>
|
||||
useColumnSizingPersistence(columns),
|
||||
{
|
||||
initialProps: {
|
||||
columns: [
|
||||
col('body'),
|
||||
col('expand'),
|
||||
col('state-indicator'),
|
||||
] as OrderedColumn[],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current.columnSizing).toEqual({ body: 400 });
|
||||
|
||||
act(() => {
|
||||
rerender({
|
||||
columns: [col('body'), col('expand'), col('state-indicator')],
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.columnSizing).toEqual({ body: 400 });
|
||||
});
|
||||
|
||||
it('updates setColumnSizing manually', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useColumnSizingPersistence([col('body')]),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setColumnSizing({ body: 500 });
|
||||
});
|
||||
|
||||
expect(result.current.columnSizing).toEqual({ body: 500 });
|
||||
});
|
||||
|
||||
it('debounces writes to localStorage', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useColumnSizingPersistence([col('body')]),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setColumnSizing({ body: 600 });
|
||||
});
|
||||
|
||||
expect(mockSet).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
expect(mockSet).toHaveBeenCalledWith(
|
||||
LOCALSTORAGE.LOGS_LIST_COLUMN_SIZING,
|
||||
expect.stringContaining('"body":600'),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not persist when ordered columns signature effect runs with empty ids early — still debounces empty sizing', () => {
|
||||
const { result } = renderHook(() => useColumnSizingPersistence([]));
|
||||
|
||||
expect(result.current.columnSizing).toEqual({});
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
expect(mockSet).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,222 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import type { OrderedColumn } from '../types';
|
||||
import { useOrderedColumns } from '../useOrderedColumns';
|
||||
|
||||
const mockGetDraggedColumns = jest.fn();
|
||||
|
||||
jest.mock('hooks/useDragColumns/utils', () => ({
|
||||
getDraggedColumns: <T,>(current: unknown[], dragged: unknown[]): T[] =>
|
||||
mockGetDraggedColumns(current, dragged) as T[],
|
||||
}));
|
||||
|
||||
const col = (key: string, title?: string): OrderedColumn =>
|
||||
({ key, title: title ?? key } as OrderedColumn);
|
||||
|
||||
describe('useOrderedColumns', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns columns from getDraggedColumns filtered to keys with string or number', () => {
|
||||
mockGetDraggedColumns.mockReturnValue([
|
||||
col('body'),
|
||||
col('timestamp'),
|
||||
{ title: 'no-key' },
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useOrderedColumns({
|
||||
columns: [],
|
||||
draggedColumns: [],
|
||||
onColumnOrderChange: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.orderedColumns).toEqual([
|
||||
col('body'),
|
||||
col('timestamp'),
|
||||
]);
|
||||
expect(result.current.orderedColumnIds).toEqual(['body', 'timestamp']);
|
||||
});
|
||||
|
||||
it('hasSingleColumn is true when exactly one column is not state-indicator', () => {
|
||||
mockGetDraggedColumns.mockReturnValue([col('state-indicator'), col('body')]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useOrderedColumns({
|
||||
columns: [],
|
||||
draggedColumns: [],
|
||||
onColumnOrderChange: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.hasSingleColumn).toBe(true);
|
||||
});
|
||||
|
||||
it('hasSingleColumn is false when more than one non-state-indicator column exists', () => {
|
||||
mockGetDraggedColumns.mockReturnValue([
|
||||
col('state-indicator'),
|
||||
col('body'),
|
||||
col('timestamp'),
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useOrderedColumns({
|
||||
columns: [],
|
||||
draggedColumns: [],
|
||||
onColumnOrderChange: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.hasSingleColumn).toBe(false);
|
||||
});
|
||||
|
||||
it('handleDragEnd reorders columns and calls onColumnOrderChange', () => {
|
||||
const onColumnOrderChange = jest.fn();
|
||||
mockGetDraggedColumns.mockReturnValue([col('a'), col('b'), col('c')]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useOrderedColumns({
|
||||
columns: [],
|
||||
draggedColumns: [],
|
||||
onColumnOrderChange,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragEnd({
|
||||
active: { id: 'a' },
|
||||
over: { id: 'c' },
|
||||
} as never);
|
||||
});
|
||||
|
||||
expect(onColumnOrderChange).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ key: 'b' }),
|
||||
expect.objectContaining({ key: 'c' }),
|
||||
expect.objectContaining({ key: 'a' }),
|
||||
]);
|
||||
|
||||
// Derived-only: orderedColumns should remain until draggedColumns (URL/localStorage) updates.
|
||||
expect(result.current.orderedColumns.map((c) => c.key)).toEqual([
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
]);
|
||||
});
|
||||
|
||||
it('handleDragEnd no-ops when over is null', () => {
|
||||
const onColumnOrderChange = jest.fn();
|
||||
mockGetDraggedColumns.mockReturnValue([col('a'), col('b')]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useOrderedColumns({
|
||||
columns: [],
|
||||
draggedColumns: [],
|
||||
onColumnOrderChange,
|
||||
}),
|
||||
);
|
||||
|
||||
const before = result.current.orderedColumns;
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragEnd({
|
||||
active: { id: 'a' },
|
||||
over: null,
|
||||
} as never);
|
||||
});
|
||||
|
||||
expect(result.current.orderedColumns).toBe(before);
|
||||
expect(onColumnOrderChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handleDragEnd no-ops when active.id equals over.id', () => {
|
||||
const onColumnOrderChange = jest.fn();
|
||||
mockGetDraggedColumns.mockReturnValue([col('a'), col('b')]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useOrderedColumns({
|
||||
columns: [],
|
||||
draggedColumns: [],
|
||||
onColumnOrderChange,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragEnd({
|
||||
active: { id: 'a' },
|
||||
over: { id: 'a' },
|
||||
} as never);
|
||||
});
|
||||
|
||||
expect(onColumnOrderChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handleDragEnd no-ops when indices cannot be resolved', () => {
|
||||
const onColumnOrderChange = jest.fn();
|
||||
mockGetDraggedColumns.mockReturnValue([col('a'), col('b')]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useOrderedColumns({
|
||||
columns: [],
|
||||
draggedColumns: [],
|
||||
onColumnOrderChange,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragEnd({
|
||||
active: { id: 'missing' },
|
||||
over: { id: 'a' },
|
||||
} as never);
|
||||
});
|
||||
|
||||
expect(onColumnOrderChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exposes sensors from useSensors', () => {
|
||||
mockGetDraggedColumns.mockReturnValue([col('a')]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useOrderedColumns({
|
||||
columns: [],
|
||||
draggedColumns: [],
|
||||
onColumnOrderChange: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.sensors).toBeDefined();
|
||||
});
|
||||
|
||||
it('syncs ordered columns when base order changes externally (e.g. URL / localStorage)', () => {
|
||||
mockGetDraggedColumns.mockReturnValue([col('a'), col('b'), col('c')]);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ draggedColumns }: { draggedColumns: unknown[] }) =>
|
||||
useOrderedColumns({
|
||||
columns: [],
|
||||
draggedColumns,
|
||||
onColumnOrderChange: jest.fn(),
|
||||
}),
|
||||
{ initialProps: { draggedColumns: [] as unknown[] } },
|
||||
);
|
||||
|
||||
expect(result.current.orderedColumns.map((column) => column.key)).toEqual([
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
]);
|
||||
|
||||
mockGetDraggedColumns.mockReturnValue([col('c'), col('b'), col('a')]);
|
||||
|
||||
act(() => {
|
||||
rerender({ draggedColumns: [{ title: 'from-url' }] as unknown[] });
|
||||
});
|
||||
|
||||
expect(result.current.orderedColumns.map((column) => column.key)).toEqual([
|
||||
'c',
|
||||
'b',
|
||||
'a',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,433 +0,0 @@
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { TableVirtuoso, TableVirtuosoHandle } from 'react-virtuoso';
|
||||
import { DndContext, pointerWithin } from '@dnd-kit/core';
|
||||
import {
|
||||
horizontalListSortingStrategy,
|
||||
SortableContext,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import {
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
||||
import { ColumnTypeRender } from 'components/Logs/TableView/types';
|
||||
import { useTableView } from 'components/Logs/TableView/useTableView';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useActiveLog } from 'hooks/logs/useActiveLog';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import useDragColumns from 'hooks/useDragColumns';
|
||||
|
||||
import { getInfinityDefaultStyles } from '../InfinityTableView/config';
|
||||
import { TanStackTableStyled } from '../InfinityTableView/styles';
|
||||
import { InfinityTableProps } from '../InfinityTableView/types';
|
||||
import TanStackCustomTableRow from './TanStackCustomTableRow';
|
||||
import TanStackHeaderRow from './TanStackHeaderRow';
|
||||
import TanStackRowCells from './TanStackRow';
|
||||
import { TableRecord, TanStackTableRowData } from './types';
|
||||
import { useColumnSizingPersistence } from './useColumnSizingPersistence';
|
||||
import { useOrderedColumns } from './useOrderedColumns';
|
||||
import {
|
||||
getColumnId,
|
||||
getColumnMinWidthPx,
|
||||
resolveColumnTypeRender,
|
||||
} from './utils';
|
||||
|
||||
import '../logsTableVirtuosoScrollbar.scss';
|
||||
import './styles/TanStackTableView.styles.scss';
|
||||
|
||||
const COLUMN_DND_AUTO_SCROLL = {
|
||||
layoutShiftCompensation: false as const,
|
||||
threshold: { x: 0.2, y: 0 },
|
||||
};
|
||||
|
||||
const TanStackTableView = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
|
||||
function TanStackTableView(
|
||||
{
|
||||
isLoading,
|
||||
isFetching,
|
||||
onRemoveColumn,
|
||||
tableViewProps,
|
||||
infitiyTableProps,
|
||||
onSetActiveLog,
|
||||
onClearActiveLog,
|
||||
activeLog,
|
||||
}: InfinityTableProps,
|
||||
forwardedRef,
|
||||
): JSX.Element {
|
||||
const { pathname } = useLocation();
|
||||
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
|
||||
// could avoid this if directly use forwardedRef in TableVirtuoso, but need to verify if it causes any issue with react-virtuoso internal ref handling
|
||||
useImperativeHandle(
|
||||
forwardedRef,
|
||||
() => virtuosoRef.current as TableVirtuosoHandle,
|
||||
[],
|
||||
);
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const isLogsExplorerPage = pathname === ROUTES.LOGS_EXPLORER;
|
||||
const { activeLog: activeContextLog } = useActiveLog();
|
||||
|
||||
// Column definitions (shared with existing logs table)
|
||||
const { dataSource, columns } = useTableView({
|
||||
...tableViewProps,
|
||||
onClickExpand: onSetActiveLog,
|
||||
onOpenLogsContext: (log): void => onSetActiveLog?.(log, VIEW_TYPES.CONTEXT),
|
||||
});
|
||||
|
||||
// Column order (drag + persisted order)
|
||||
const { draggedColumns, onColumnOrderChange } = useDragColumns<TableRecord>(
|
||||
LOCALSTORAGE.LOGS_LIST_COLUMNS,
|
||||
);
|
||||
const {
|
||||
orderedColumns,
|
||||
orderedColumnIds,
|
||||
hasSingleColumn,
|
||||
handleDragEnd,
|
||||
sensors,
|
||||
} = useOrderedColumns({
|
||||
columns,
|
||||
draggedColumns,
|
||||
onColumnOrderChange: onColumnOrderChange as (columns: unknown[]) => void,
|
||||
});
|
||||
|
||||
// Column sizing (persisted). stored to localStorage.
|
||||
const { columnSizing, setColumnSizing } = useColumnSizingPersistence(
|
||||
orderedColumns,
|
||||
);
|
||||
|
||||
// don't allow "remove column" when only state-indicator + one data col remain
|
||||
const isAtMinimumRemovableColumns = useMemo(
|
||||
() =>
|
||||
orderedColumns.filter(
|
||||
(column) => column.key !== 'state-indicator' && column.key !== 'expand',
|
||||
).length <= 1,
|
||||
[orderedColumns],
|
||||
);
|
||||
|
||||
// Table data (TanStack row data shape)
|
||||
// useTableView sends flattened log data. this would not be needed once we move to new log details view
|
||||
const tableData = useMemo<TanStackTableRowData[]>(
|
||||
() =>
|
||||
dataSource
|
||||
.map((log, rowIndex) => {
|
||||
const currentLog = tableViewProps.logs[rowIndex];
|
||||
if (!currentLog) {
|
||||
return null;
|
||||
}
|
||||
return { log, currentLog, rowIndex };
|
||||
})
|
||||
.filter(Boolean) as TanStackTableRowData[],
|
||||
[dataSource, tableViewProps.logs],
|
||||
);
|
||||
|
||||
// TanStack columns + table instance
|
||||
const tanstackColumns = useMemo<ColumnDef<TanStackTableRowData>[]>(
|
||||
() =>
|
||||
orderedColumns.map((column, index) => {
|
||||
const isStateIndicator = column.key === 'state-indicator';
|
||||
const isExpand = column.key === 'expand';
|
||||
const isFixedColumn = isStateIndicator || isExpand;
|
||||
const fixedWidth = isFixedColumn ? 32 : undefined;
|
||||
const minWidthPx = getColumnMinWidthPx(column, orderedColumns);
|
||||
const headerTitle = String(column.title || '');
|
||||
|
||||
return {
|
||||
id: getColumnId(column),
|
||||
header: headerTitle.replace(/^\w/, (character) =>
|
||||
character.toUpperCase(),
|
||||
),
|
||||
accessorFn: (row): unknown => row.log[column.key as keyof TableRecord],
|
||||
enableResizing: !isFixedColumn && index !== orderedColumns.length - 1,
|
||||
minSize: fixedWidth ?? minWidthPx,
|
||||
size: fixedWidth, // last column gets remaining space, so don't set initial size to avoid conflict with resizing
|
||||
maxSize: fixedWidth,
|
||||
cell: ({ row, getValue }): ReactElement | string | number | null => {
|
||||
if (!column.render) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolveColumnTypeRender(
|
||||
column.render(
|
||||
getValue(),
|
||||
row.original.log,
|
||||
row.original.rowIndex,
|
||||
) as ColumnTypeRender<Record<string, unknown>>,
|
||||
);
|
||||
},
|
||||
};
|
||||
}),
|
||||
[orderedColumns],
|
||||
);
|
||||
const table = useReactTable({
|
||||
data: tableData,
|
||||
columns: tanstackColumns,
|
||||
enableColumnResizing: true,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
columnResizeMode: 'onChange',
|
||||
onColumnSizingChange: setColumnSizing,
|
||||
state: {
|
||||
columnSizing,
|
||||
},
|
||||
});
|
||||
const tableRows = table.getRowModel().rows;
|
||||
|
||||
// Infinite-scroll footer UI state
|
||||
const [loadMoreState, setLoadMoreState] = useState<{
|
||||
active: boolean;
|
||||
startCount: number;
|
||||
}>({
|
||||
active: false,
|
||||
startCount: 0,
|
||||
});
|
||||
|
||||
// Map to resolve full log object by id (row highlighting + indicator)
|
||||
const logsById = useMemo(
|
||||
() => new Map(tableViewProps.logs.map((log) => [String(log.id), log])),
|
||||
[tableViewProps.logs],
|
||||
);
|
||||
|
||||
// this is already written in parent. Check if this is needed.
|
||||
useEffect(() => {
|
||||
const activeLogIndex = tableViewProps.activeLogIndex ?? -1;
|
||||
if (activeLogIndex < 0 || activeLogIndex >= tableRows.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
index: activeLogIndex,
|
||||
align: 'center',
|
||||
behavior: 'auto',
|
||||
});
|
||||
}, [tableRows.length, tableViewProps.activeLogIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loadMoreState.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isFetching || tableRows.length > loadMoreState.startCount) {
|
||||
setLoadMoreState((prev) =>
|
||||
prev.active ? { active: false, startCount: prev.startCount } : prev,
|
||||
);
|
||||
}
|
||||
}, [isFetching, loadMoreState, tableRows.length]);
|
||||
|
||||
const handleLogCopy = useCallback(
|
||||
(logId: string, event: ReactMouseEvent<HTMLElement>): void => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const urlQuery = new URLSearchParams(window.location.search);
|
||||
urlQuery.delete(QueryParams.activeLogId);
|
||||
urlQuery.delete(QueryParams.relativeTime);
|
||||
urlQuery.set(QueryParams.activeLogId, `"${logId}"`);
|
||||
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
|
||||
|
||||
setCopy(link);
|
||||
toast.success('Copied to clipboard', { position: 'top-right' });
|
||||
},
|
||||
[pathname, setCopy],
|
||||
);
|
||||
|
||||
const itemContent = useCallback(
|
||||
(index: number): JSX.Element | null => {
|
||||
const row = tableRows[index];
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TanStackRowCells
|
||||
row={row}
|
||||
fontSize={tableViewProps.fontSize}
|
||||
onSetActiveLog={onSetActiveLog}
|
||||
onClearActiveLog={onClearActiveLog}
|
||||
isActiveLog={
|
||||
String(activeLog?.id ?? '') === String(row.original.currentLog.id ?? '')
|
||||
}
|
||||
isDarkMode={isDarkMode}
|
||||
onLogCopy={handleLogCopy}
|
||||
isLogsExplorerPage={isLogsExplorerPage}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[
|
||||
activeLog?.id,
|
||||
handleLogCopy,
|
||||
isDarkMode,
|
||||
isLogsExplorerPage,
|
||||
onClearActiveLog,
|
||||
onSetActiveLog,
|
||||
tableRows,
|
||||
tableViewProps.fontSize,
|
||||
],
|
||||
);
|
||||
|
||||
const flatHeaders = useMemo(
|
||||
() => table.getFlatHeaders().filter((header) => !header.isPlaceholder),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[tanstackColumns],
|
||||
);
|
||||
|
||||
const tableHeader = useCallback(() => {
|
||||
const orderedColumnsById = new Map(
|
||||
orderedColumns.map((column) => [getColumnId(column), column] as const),
|
||||
);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={pointerWithin}
|
||||
onDragEnd={handleDragEnd}
|
||||
autoScroll={COLUMN_DND_AUTO_SCROLL}
|
||||
>
|
||||
<SortableContext
|
||||
items={orderedColumnIds}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
<tr>
|
||||
{flatHeaders.map((header) => {
|
||||
const column = orderedColumnsById.get(header.id);
|
||||
if (!column) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TanStackHeaderRow
|
||||
key={header.id}
|
||||
column={column}
|
||||
header={header}
|
||||
isDarkMode={isDarkMode}
|
||||
fontSize={tableViewProps.fontSize}
|
||||
hasSingleColumn={hasSingleColumn}
|
||||
onRemoveColumn={onRemoveColumn}
|
||||
canRemoveColumn={!isAtMinimumRemovableColumns}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
}, [
|
||||
flatHeaders,
|
||||
handleDragEnd,
|
||||
hasSingleColumn,
|
||||
isDarkMode,
|
||||
orderedColumnIds,
|
||||
orderedColumns,
|
||||
onRemoveColumn,
|
||||
isAtMinimumRemovableColumns,
|
||||
sensors,
|
||||
tableViewProps.fontSize,
|
||||
]);
|
||||
|
||||
const handleEndReached = useCallback(
|
||||
(index: number): void => {
|
||||
if (!infitiyTableProps?.onEndReached) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadMoreState({
|
||||
active: true,
|
||||
startCount: tableRows.length,
|
||||
});
|
||||
infitiyTableProps.onEndReached(index);
|
||||
},
|
||||
[infitiyTableProps, tableRows.length],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner height="35px" tip="Getting Logs" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tanstack-table-view-wrapper">
|
||||
<TableVirtuoso
|
||||
className="logs-table-virtuoso-scroll"
|
||||
ref={virtuosoRef}
|
||||
style={getInfinityDefaultStyles(tableViewProps.fontSize)}
|
||||
data={tableData}
|
||||
totalCount={tableRows.length}
|
||||
increaseViewportBy={{ top: 500, bottom: 500 }}
|
||||
initialTopMostItemIndex={
|
||||
tableViewProps.activeLogIndex !== -1 ? tableViewProps.activeLogIndex : 0
|
||||
}
|
||||
context={{ activeLog, activeContextLog, logsById }}
|
||||
fixedHeaderContent={tableHeader}
|
||||
itemContent={itemContent}
|
||||
components={{
|
||||
Table: ({ style, children }): JSX.Element => (
|
||||
<TanStackTableStyled style={style}>
|
||||
<colgroup>
|
||||
{orderedColumns.map((column, colIndex) => {
|
||||
const columnId = getColumnId(column);
|
||||
const isFixedColumn =
|
||||
column.key === 'expand' || column.key === 'state-indicator';
|
||||
const minWidthPx = getColumnMinWidthPx(column, orderedColumns);
|
||||
const persistedWidth = columnSizing[columnId];
|
||||
const computedWidth = table.getColumn(columnId)?.getSize();
|
||||
const effectiveWidth = persistedWidth ?? computedWidth;
|
||||
if (isFixedColumn) {
|
||||
return <col key={columnId} className="tanstack-fixed-col" />;
|
||||
}
|
||||
// Last data column should stretch to fill remaining space
|
||||
const isLastColumn = colIndex === orderedColumns.length - 1;
|
||||
if (isLastColumn) {
|
||||
return (
|
||||
<col
|
||||
key={columnId}
|
||||
style={{ width: '100%', minWidth: `${minWidthPx}px` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const widthPx =
|
||||
effectiveWidth != null
|
||||
? Math.max(effectiveWidth, minWidthPx)
|
||||
: minWidthPx;
|
||||
return (
|
||||
<col
|
||||
key={columnId}
|
||||
style={{ width: `${widthPx}px`, minWidth: `${minWidthPx}px` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</colgroup>
|
||||
{children}
|
||||
</TanStackTableStyled>
|
||||
),
|
||||
TableRow: TanStackCustomTableRow,
|
||||
}}
|
||||
{...(infitiyTableProps?.onEndReached
|
||||
? { endReached: handleEndReached }
|
||||
: {})}
|
||||
/>
|
||||
{loadMoreState.active && (
|
||||
<div className="tanstack-load-more-container">
|
||||
<Spinner height="20px" tip="Getting Logs" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default memo(TanStackTableView);
|
||||
@@ -1,153 +0,0 @@
|
||||
.tanstack-header-cell {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
padding: 0;
|
||||
transform: translate3d(
|
||||
var(--tanstack-header-translate-x, 0px),
|
||||
var(--tanstack-header-translate-y, 0px),
|
||||
0
|
||||
);
|
||||
transition: var(--tanstack-header-transition, none);
|
||||
|
||||
&.is-dragging {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&.is-resizing {
|
||||
background: var(--l2-background-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.tanstack-header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
cursor: default;
|
||||
max-width: 100%;
|
||||
|
||||
&.has-resize-control {
|
||||
max-width: calc(100% - 5px);
|
||||
}
|
||||
|
||||
&.has-action-control {
|
||||
max-width: calc(100% - 5px);
|
||||
}
|
||||
|
||||
&.has-resize-control.has-action-control {
|
||||
max-width: calc(100% - 10px);
|
||||
}
|
||||
}
|
||||
|
||||
.tanstack-grip-slot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tanstack-grip-activator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
cursor: grab;
|
||||
color: var(--l2-foreground);
|
||||
opacity: 1;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.tanstack-header-action-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.tanstack-column-actions-content {
|
||||
width: 140px;
|
||||
padding: 6px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 20px var(--l2-border);
|
||||
}
|
||||
|
||||
.tanstack-remove-column-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 25px;
|
||||
padding: 0 6px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
justify-content: flex-start;
|
||||
cursor: pointer;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
transition: background-color 120ms ease, color 120ms ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-background-hover);
|
||||
color: var(--l2-foreground);
|
||||
|
||||
.tanstack-remove-column-action-icon {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tanstack-remove-column-action-icon {
|
||||
font-size: 11px;
|
||||
color: var(--l2-foreground);
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.tanstack-header-cell .cursor-col-resize {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 5px;
|
||||
cursor: col-resize;
|
||||
z-index: 10;
|
||||
touch-action: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tanstack-header-cell.is-resizing .cursor-col-resize {
|
||||
background: var(--bg-robin-300);
|
||||
}
|
||||
|
||||
.tanstack-resize-handle-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 4px;
|
||||
transform: translateX(-50%);
|
||||
background: var(--l2-border);
|
||||
opacity: 1;
|
||||
pointer-events: none;
|
||||
transition: background 120ms ease, width 120ms ease;
|
||||
}
|
||||
|
||||
.tanstack-header-cell.is-resizing .tanstack-resize-handle-line {
|
||||
width: 2px;
|
||||
background: var(--bg-robin-500);
|
||||
transition: none;
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
.tanstack-table-view-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tanstack-fixed-col {
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
max-width: 32px;
|
||||
}
|
||||
|
||||
.tanstack-filler-col {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tanstack-actions-col {
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
max-width: 0;
|
||||
}
|
||||
|
||||
.tanstack-load-more-container {
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 0 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tanstack-table-virtuoso {
|
||||
width: 100%;
|
||||
overflow-x: scroll;
|
||||
}
|
||||
|
||||
.tanstack-fontSize-small {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tanstack-fontSize-medium {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tanstack-fontSize-large {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tanstack-table-foot-loader-cell {
|
||||
text-align: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { ColumnSizingState } from '@tanstack/react-table';
|
||||
import { ColumnTypeRender } from 'components/Logs/TableView/types';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
export type TableRecord = Record<string, unknown>;
|
||||
|
||||
export type LogsTableColumnDef = {
|
||||
key?: string | number;
|
||||
title?: string;
|
||||
render?: (
|
||||
value: unknown,
|
||||
record: TableRecord,
|
||||
index: number,
|
||||
) => ColumnTypeRender<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export type OrderedColumn = LogsTableColumnDef & {
|
||||
key: string | number;
|
||||
};
|
||||
|
||||
export type TanStackTableRowData = {
|
||||
log: TableRecord;
|
||||
currentLog: ILog;
|
||||
rowIndex: number;
|
||||
};
|
||||
|
||||
export type PersistedColumnSizing = {
|
||||
sizing: ColumnSizingState;
|
||||
};
|
||||
@@ -1,111 +0,0 @@
|
||||
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react';
|
||||
import { ColumnSizingState } from '@tanstack/react-table';
|
||||
import getFromLocalstorage from 'api/browser/localstorage/get';
|
||||
import setToLocalstorage from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
import { OrderedColumn, PersistedColumnSizing } from './types';
|
||||
import { getColumnId } from './utils';
|
||||
|
||||
const COLUMN_SIZING_PERSIST_DEBOUNCE_MS = 250;
|
||||
|
||||
const sanitizeSizing = (input: unknown): ColumnSizingState => {
|
||||
if (!input || typeof input !== 'object') {
|
||||
return {};
|
||||
}
|
||||
return Object.entries(
|
||||
input as Record<string, unknown>,
|
||||
).reduce<ColumnSizingState>((acc, [key, value]) => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
return acc;
|
||||
}
|
||||
acc[key] = value;
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const readPersistedColumnSizing = (): ColumnSizingState => {
|
||||
const rawSizing = getFromLocalstorage(LOCALSTORAGE.LOGS_LIST_COLUMN_SIZING);
|
||||
if (!rawSizing) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawSizing) as
|
||||
| PersistedColumnSizing
|
||||
| ColumnSizingState;
|
||||
const sizing = ('sizing' in parsed
|
||||
? parsed.sizing
|
||||
: parsed) as ColumnSizingState;
|
||||
return sanitizeSizing(sizing);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse persisted log column sizing', error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
type UseColumnSizingPersistenceResult = {
|
||||
columnSizing: ColumnSizingState;
|
||||
setColumnSizing: Dispatch<SetStateAction<ColumnSizingState>>;
|
||||
};
|
||||
|
||||
export const useColumnSizingPersistence = (
|
||||
orderedColumns: OrderedColumn[],
|
||||
): UseColumnSizingPersistenceResult => {
|
||||
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>(() =>
|
||||
readPersistedColumnSizing(),
|
||||
);
|
||||
const orderedColumnIds = useMemo(
|
||||
() => orderedColumns.map((column) => getColumnId(column)),
|
||||
[orderedColumns],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (orderedColumnIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validColumnIds = new Set(orderedColumnIds);
|
||||
const nonResizableColumnIds = new Set(
|
||||
orderedColumns
|
||||
.filter(
|
||||
(column) => column.key === 'expand' || column.key === 'state-indicator',
|
||||
)
|
||||
.map((column) => getColumnId(column)),
|
||||
);
|
||||
|
||||
setColumnSizing((previousSizing) => {
|
||||
const nextSizing = Object.entries(previousSizing).reduce<ColumnSizingState>(
|
||||
(acc, [columnId, size]) => {
|
||||
if (!validColumnIds.has(columnId) || nonResizableColumnIds.has(columnId)) {
|
||||
return acc;
|
||||
}
|
||||
acc[columnId] = size;
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
const hasChanged =
|
||||
Object.keys(nextSizing).length !== Object.keys(previousSizing).length ||
|
||||
Object.entries(nextSizing).some(
|
||||
([columnId, size]) => previousSizing[columnId] !== size,
|
||||
);
|
||||
|
||||
return hasChanged ? nextSizing : previousSizing;
|
||||
});
|
||||
}, [orderedColumnIds, orderedColumns]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
const persistedSizing = { sizing: columnSizing };
|
||||
setToLocalstorage(
|
||||
LOCALSTORAGE.LOGS_LIST_COLUMN_SIZING,
|
||||
JSON.stringify(persistedSizing),
|
||||
);
|
||||
}, COLUMN_SIZING_PERSIST_DEBOUNCE_MS);
|
||||
|
||||
return (): void => window.clearTimeout(timeoutId);
|
||||
}, [columnSizing]);
|
||||
|
||||
return { columnSizing, setColumnSizing };
|
||||
};
|
||||
@@ -1,108 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
DragEndEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { arrayMove } from '@dnd-kit/sortable';
|
||||
import { getDraggedColumns } from 'hooks/useDragColumns/utils';
|
||||
|
||||
import { OrderedColumn, TableRecord } from './types';
|
||||
import { getColumnId } from './utils';
|
||||
|
||||
type UseOrderedColumnsProps = {
|
||||
columns: unknown[];
|
||||
draggedColumns: unknown[];
|
||||
onColumnOrderChange: (columns: unknown[]) => void;
|
||||
};
|
||||
|
||||
type UseOrderedColumnsResult = {
|
||||
orderedColumns: OrderedColumn[];
|
||||
orderedColumnIds: string[];
|
||||
hasSingleColumn: boolean;
|
||||
handleDragEnd: (event: DragEndEvent) => void;
|
||||
sensors: ReturnType<typeof useSensors>;
|
||||
};
|
||||
|
||||
export const useOrderedColumns = ({
|
||||
columns,
|
||||
draggedColumns,
|
||||
onColumnOrderChange,
|
||||
}: UseOrderedColumnsProps): UseOrderedColumnsResult => {
|
||||
const baseColumns = useMemo<OrderedColumn[]>(
|
||||
() =>
|
||||
getDraggedColumns<TableRecord>(
|
||||
columns as never[],
|
||||
draggedColumns as never[],
|
||||
).filter(
|
||||
(column): column is OrderedColumn =>
|
||||
typeof column.key === 'string' || typeof column.key === 'number',
|
||||
),
|
||||
[columns, draggedColumns],
|
||||
);
|
||||
|
||||
const orderedColumns = useMemo(() => {
|
||||
const stateIndicatorIndex = baseColumns.findIndex(
|
||||
(column) => column.key === 'state-indicator',
|
||||
);
|
||||
if (stateIndicatorIndex <= 0) {
|
||||
return baseColumns;
|
||||
}
|
||||
const pinned = baseColumns[stateIndicatorIndex];
|
||||
const rest = baseColumns.filter((_, i) => i !== stateIndicatorIndex);
|
||||
return [pinned, ...rest];
|
||||
}, [baseColumns]);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent): void => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't allow moving the state-indicator column
|
||||
if (String(active.id) === 'state-indicator') {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldIndex = orderedColumns.findIndex(
|
||||
(column) => getColumnId(column) === String(active.id),
|
||||
);
|
||||
const newIndex = orderedColumns.findIndex(
|
||||
(column) => getColumnId(column) === String(over.id),
|
||||
);
|
||||
if (oldIndex === -1 || newIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextColumns = arrayMove(orderedColumns, oldIndex, newIndex);
|
||||
onColumnOrderChange(nextColumns as unknown[]);
|
||||
},
|
||||
[onColumnOrderChange, orderedColumns],
|
||||
);
|
||||
|
||||
const orderedColumnIds = useMemo(
|
||||
() => orderedColumns.map((column) => getColumnId(column)),
|
||||
[orderedColumns],
|
||||
);
|
||||
const hasSingleColumn = useMemo(
|
||||
() =>
|
||||
orderedColumns.filter((column) => column.key !== 'state-indicator')
|
||||
.length === 1,
|
||||
[orderedColumns],
|
||||
);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 4 },
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
orderedColumns,
|
||||
orderedColumnIds,
|
||||
hasSingleColumn,
|
||||
handleDragEnd,
|
||||
sensors,
|
||||
};
|
||||
};
|
||||
@@ -1,61 +0,0 @@
|
||||
import { cloneElement, isValidElement, ReactElement } from 'react';
|
||||
import { ColumnTypeRender } from 'components/Logs/TableView/types';
|
||||
|
||||
import { OrderedColumn } from './types';
|
||||
|
||||
export const getColumnId = (column: OrderedColumn): string =>
|
||||
String(column.key);
|
||||
|
||||
/** Browser default root font size; TanStack column sizing uses px. */
|
||||
const REM_PX = 16;
|
||||
const MIN_WIDTH_OTHER_REM = 12;
|
||||
const MIN_WIDTH_BODY_REM = 40;
|
||||
|
||||
/** When total column count is below this, body column min width is doubled (more horizontal space for few columns). */
|
||||
export const FEW_COLUMNS_BODY_MIN_WIDTH_THRESHOLD = 4;
|
||||
|
||||
/**
|
||||
* Minimum width (px) for TanStack column defs + colgroup.
|
||||
* Design: state/expand 32px; body min 40rem (doubled when fewer than
|
||||
* {@link FEW_COLUMNS_BODY_MIN_WIDTH_THRESHOLD} total columns); other columns use rem→px (16px root).
|
||||
*/
|
||||
export const getColumnMinWidthPx = (
|
||||
column: OrderedColumn,
|
||||
orderedColumns?: OrderedColumn[],
|
||||
): number => {
|
||||
const key = String(column.key);
|
||||
if (key === 'state-indicator' || key === 'expand') {
|
||||
return 32;
|
||||
}
|
||||
if (key === 'body') {
|
||||
const base = MIN_WIDTH_BODY_REM * REM_PX;
|
||||
const fewColumns =
|
||||
orderedColumns != null &&
|
||||
orderedColumns.length < FEW_COLUMNS_BODY_MIN_WIDTH_THRESHOLD;
|
||||
return fewColumns ? base * 1.5 : base;
|
||||
}
|
||||
return MIN_WIDTH_OTHER_REM * REM_PX;
|
||||
};
|
||||
|
||||
export const resolveColumnTypeRender = (
|
||||
rendered: ColumnTypeRender<Record<string, unknown>>,
|
||||
): ReactElement | string | number | null => {
|
||||
if (
|
||||
rendered &&
|
||||
typeof rendered === 'object' &&
|
||||
'children' in rendered &&
|
||||
isValidElement(rendered.children)
|
||||
) {
|
||||
const { children, props } = rendered as {
|
||||
children: ReactElement;
|
||||
props?: Record<string, unknown>;
|
||||
};
|
||||
return cloneElement(children, props || {});
|
||||
}
|
||||
if (rendered && typeof rendered === 'object' && isValidElement(rendered)) {
|
||||
return rendered;
|
||||
}
|
||||
return typeof rendered === 'string' || typeof rendered === 'number'
|
||||
? rendered
|
||||
: null;
|
||||
};
|
||||
@@ -25,9 +25,9 @@ import { ILog } from 'types/api/logs/log';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import NoLogs from '../NoLogs/NoLogs';
|
||||
import InfinityTableView from './InfinityTableView';
|
||||
import { LogsExplorerListProps } from './LogsExplorerList.interfaces';
|
||||
import { InfinityWrapperStyled } from './styles';
|
||||
import TanStackTableView from './TanStackTableView';
|
||||
import {
|
||||
convertKeysToColumnFields,
|
||||
getEmptyLogsListConfig,
|
||||
@@ -61,7 +61,7 @@ function LogsExplorerList({
|
||||
handleCloseLogDetail,
|
||||
} = useLogDetailHandlers();
|
||||
|
||||
const { options, config } = useOptionsMenu({
|
||||
const { options } = useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator:
|
||||
@@ -155,10 +155,9 @@ function LogsExplorerList({
|
||||
|
||||
if (options.format === 'table') {
|
||||
return (
|
||||
<TanStackTableView
|
||||
<InfinityTableView
|
||||
ref={ref}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
tableViewProps={{
|
||||
logs,
|
||||
fields: selectedFields,
|
||||
@@ -173,7 +172,6 @@ function LogsExplorerList({
|
||||
onSetActiveLog={handleSetActiveLog}
|
||||
onClearActiveLog={handleCloseLogDetail}
|
||||
activeLog={activeLog}
|
||||
onRemoveColumn={config.addColumn?.onRemove}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -218,13 +216,11 @@ function LogsExplorerList({
|
||||
logs,
|
||||
onEndReached,
|
||||
getItemContent,
|
||||
isFetching,
|
||||
selectedFields,
|
||||
handleChangeSelectedView,
|
||||
handleSetActiveLog,
|
||||
handleCloseLogDetail,
|
||||
activeLog,
|
||||
config.addColumn?.onRemove,
|
||||
]);
|
||||
|
||||
const isTraceToLogsNavigation = useMemo(() => {
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
.logs-table-virtuoso-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--bg-slate-300) transparent;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-slate-300);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--bg-slate-200);
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode .logs-table-virtuoso-scroll {
|
||||
scrollbar-color: var(--bg-vanilla-300) transparent;
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import styled from 'styled-components';
|
||||
|
||||
export const InfinityWrapperStyled = styled.div`
|
||||
flex: 1;
|
||||
height: 40rem !important;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
`;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
.logs-explorer-views-container {
|
||||
margin-bottom: 24px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -10,7 +9,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding-bottom: 10px;
|
||||
|
||||
.views-tabs-container {
|
||||
@@ -197,7 +195,6 @@
|
||||
|
||||
.logs-explorer-views-type-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -213,32 +210,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.table-view-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
.time-series-view-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-y: visible;
|
||||
|
||||
.time-series-view-container-header {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.time-series-view {
|
||||
flex-shrink: 0;
|
||||
height: 65vh;
|
||||
min-height: 450px;
|
||||
padding-bottom: 140px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,18 +499,16 @@ function LogsExplorerViewsContainer({
|
||||
</div>
|
||||
)}
|
||||
{selectedPanelType === PANEL_TYPES.TABLE && !showLiveLogs && (
|
||||
<div className="table-view-container">
|
||||
<LogsExplorerTable
|
||||
data={
|
||||
(data?.payload?.data?.newResult?.data?.result ||
|
||||
data?.payload?.data?.result ||
|
||||
[]) as QueryDataV3[]
|
||||
}
|
||||
isLoading={isLoading || isFetching}
|
||||
isError={isError}
|
||||
error={error as APIError}
|
||||
/>
|
||||
</div>
|
||||
<LogsExplorerTable
|
||||
data={
|
||||
(data?.payload?.data?.newResult?.data?.result ||
|
||||
data?.payload?.data?.result ||
|
||||
[]) as QueryDataV3[]
|
||||
}
|
||||
isLoading={isLoading || isFetching}
|
||||
isError={isError}
|
||||
error={error as APIError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Check, ChevronDown, Plus } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { Dropdown } from 'antd';
|
||||
import getAll from 'api/v1/user/get';
|
||||
import { useListUsers } from 'api/generated/services/users';
|
||||
import EditMemberDrawer from 'components/EditMemberDrawer/EditMemberDrawer';
|
||||
import InviteMembersModal from 'components/InviteMembersModal/InviteMembersModal';
|
||||
import MembersTable, { MemberRow } from 'components/MembersTable/MembersTable';
|
||||
@@ -34,9 +33,8 @@ function MembersSettings(): JSX.Element {
|
||||
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
|
||||
const [selectedMember, setSelectedMember] = useState<MemberRow | null>(null);
|
||||
|
||||
const { data: usersData, isLoading, refetch: refetchUsers } = useQuery({
|
||||
queryFn: getAll,
|
||||
queryKey: ['getOrgUser', org?.[0]?.id],
|
||||
const { data: usersData, isLoading, refetch: refetchUsers } = useListUsers({
|
||||
query: { queryKey: ['getOrgUser', org?.[0]?.id] },
|
||||
});
|
||||
|
||||
const allMembers = useMemo(
|
||||
@@ -44,8 +42,7 @@ function MembersSettings(): JSX.Element {
|
||||
(usersData?.data ?? []).map((user) => ({
|
||||
id: user.id,
|
||||
name: user.displayName,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
email: user.email ?? '',
|
||||
status: toMemberStatus(user.status ?? ''),
|
||||
joinedOn: toISOString(user.createdAt),
|
||||
updatedAt: toISOString(user?.updatedAt),
|
||||
@@ -64,9 +61,7 @@ function MembersSettings(): JSX.Element {
|
||||
const q = searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(m) =>
|
||||
m?.name?.toLowerCase().includes(q) ||
|
||||
m.email.toLowerCase().includes(q) ||
|
||||
m.role.toLowerCase().includes(q),
|
||||
m?.name?.toLowerCase().includes(q) || m.email.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -181,7 +176,7 @@ function MembersSettings(): JSX.Element {
|
||||
<div className="members-settings__search">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search by name, email, or role..."
|
||||
placeholder="Search by name or email..."
|
||||
value={searchQuery}
|
||||
onChange={(e): void => {
|
||||
setSearchQuery(e.target.value);
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Input, Modal, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useUpdateMyUserV2 } from 'api/generated/services/users';
|
||||
import changeMyPassword from 'api/v1/factor_password/changeMyPassword';
|
||||
import editUser from 'api/v1/user/id/update';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { Check, FileTerminal, MailIcon, UserIcon } from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
@@ -17,6 +17,7 @@ function UserInfo(): JSX.Element {
|
||||
const { t } = useTranslation(['routes', 'settings', 'common']);
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
const { mutateAsync: updateMyUser } = useUpdateMyUserV2();
|
||||
|
||||
const [currentPassword, setCurrentPassword] = useState<string>('');
|
||||
const [updatePassword, setUpdatePassword] = useState<string>('');
|
||||
@@ -92,10 +93,7 @@ function UserInfo(): JSX.Element {
|
||||
);
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await editUser({
|
||||
displayName: changedName,
|
||||
userId: user.id,
|
||||
});
|
||||
await updateMyUser({ data: { displayName: changedName } });
|
||||
|
||||
notifications.success({
|
||||
message: t('success', {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { Button, Form, Input } from 'antd';
|
||||
import editOrg from 'api/organization/editOrg';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { useUpdateMyOrganization } from 'api/generated/services/orgs';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { IUser } from 'providers/App/types';
|
||||
import { requireErrorMessage } from 'utils/form/requireErrorMessage';
|
||||
@@ -14,42 +16,34 @@ function DisplayName({ index, id: orgId }: DisplayNameProps): JSX.Element {
|
||||
const { t } = useTranslation(['organizationsettings', 'common']);
|
||||
const { org, updateOrg } = useAppContext();
|
||||
const { displayName } = (org || [])[index];
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const {
|
||||
mutateAsync: updateMyOrganization,
|
||||
isLoading,
|
||||
} = useUpdateMyOrganization({
|
||||
mutation: {
|
||||
onSuccess: (_, { data }) => {
|
||||
toast.success(t('success', { ns: 'common' }), {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
updateOrg(orgId, data.displayName ?? '');
|
||||
},
|
||||
onError: (error) => {
|
||||
const apiError = convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO>,
|
||||
);
|
||||
toast.error(
|
||||
apiError?.getErrorMessage() ?? t('something_went_wrong', { ns: 'common' }),
|
||||
{ richColors: true, position: 'top-right' },
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values: FormValues): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const { displayName } = values;
|
||||
const { statusCode, error } = await editOrg({
|
||||
displayName,
|
||||
orgId,
|
||||
});
|
||||
if (statusCode === 204) {
|
||||
notifications.success({
|
||||
message: t('success', {
|
||||
ns: 'common',
|
||||
}),
|
||||
});
|
||||
updateOrg(orgId, displayName);
|
||||
} else {
|
||||
notifications.error({
|
||||
message:
|
||||
error ||
|
||||
t('something_went_wrong', {
|
||||
ns: 'common',
|
||||
}),
|
||||
});
|
||||
}
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
setIsLoading(false);
|
||||
notifications.error({
|
||||
message: t('something_went_wrong', {
|
||||
ns: 'common',
|
||||
}),
|
||||
});
|
||||
}
|
||||
const { displayName } = values;
|
||||
await updateMyOrganization({ data: { id: orgId, displayName } });
|
||||
};
|
||||
|
||||
if (!org) {
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Check, ChevronDown, Plus } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { Dropdown } from 'antd';
|
||||
import {
|
||||
getGetServiceAccountQueryKey,
|
||||
useListServiceAccounts,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import type {
|
||||
GetServiceAccount200,
|
||||
ListServiceAccounts200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useListServiceAccounts } from 'api/generated/services/serviceaccount';
|
||||
import CreateServiceAccountModal from 'components/CreateServiceAccountModal/CreateServiceAccountModal';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import ServiceAccountDrawer from 'components/ServiceAccountDrawer/ServiceAccountDrawer';
|
||||
@@ -59,29 +51,13 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
parseAsBoolean.withDefault(false),
|
||||
);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const seedAccountCache = useCallback(
|
||||
(data: ListServiceAccounts200) => {
|
||||
data.data.forEach((account) => {
|
||||
queryClient.setQueryData<GetServiceAccount200>(
|
||||
getGetServiceAccountQueryKey({ id: account.id }),
|
||||
(old) => old ?? { data: account, status: data.status },
|
||||
);
|
||||
});
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
const {
|
||||
data: serviceAccountsData,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch: handleCreateSuccess,
|
||||
} = useListServiceAccounts({
|
||||
query: { onSuccess: seedAccountCache },
|
||||
});
|
||||
} = useListServiceAccounts();
|
||||
|
||||
const allAccounts = useMemo(
|
||||
(): ServiceAccountRow[] =>
|
||||
@@ -97,10 +73,10 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
[allAccounts],
|
||||
);
|
||||
|
||||
const disabledCount = useMemo(
|
||||
const deletedCount = useMemo(
|
||||
() =>
|
||||
allAccounts.filter(
|
||||
(a) => a.status?.toUpperCase() !== ServiceAccountStatus.Active,
|
||||
(a) => a.status?.toUpperCase() === ServiceAccountStatus.Deleted,
|
||||
).length,
|
||||
[allAccounts],
|
||||
);
|
||||
@@ -112,9 +88,9 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
result = result.filter(
|
||||
(a) => a.status?.toUpperCase() === ServiceAccountStatus.Active,
|
||||
);
|
||||
} else if (filterMode === FilterMode.Disabled) {
|
||||
} else if (filterMode === FilterMode.Deleted) {
|
||||
result = result.filter(
|
||||
(a) => a.status?.toUpperCase() !== ServiceAccountStatus.Active,
|
||||
(a) => a.status?.toUpperCase() === ServiceAccountStatus.Deleted,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,9 +98,7 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
result = result.filter(
|
||||
(a) =>
|
||||
a.name?.toLowerCase().includes(q) ||
|
||||
a.email?.toLowerCase().includes(q) ||
|
||||
a.roles?.some((role: string) => role.toLowerCase().includes(q)),
|
||||
a.name?.toLowerCase().includes(q) || a.email?.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -174,15 +148,15 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
},
|
||||
},
|
||||
{
|
||||
key: FilterMode.Disabled,
|
||||
key: FilterMode.Deleted,
|
||||
label: (
|
||||
<div className="sa-settings-filter-option">
|
||||
<span>Disabled ⎯ {disabledCount}</span>
|
||||
{filterMode === FilterMode.Disabled && <Check size={14} />}
|
||||
<span>Deleted ⎯ {deletedCount}</span>
|
||||
{filterMode === FilterMode.Deleted && <Check size={14} />}
|
||||
</div>
|
||||
),
|
||||
onClick: (): void => {
|
||||
setFilterMode(FilterMode.Disabled);
|
||||
setFilterMode(FilterMode.Deleted);
|
||||
setPage(1);
|
||||
},
|
||||
},
|
||||
@@ -192,8 +166,8 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
switch (filterMode) {
|
||||
case FilterMode.Active:
|
||||
return `Active ⎯ ${activeCount}`;
|
||||
case FilterMode.Disabled:
|
||||
return `Disabled ⎯ ${disabledCount}`;
|
||||
case FilterMode.Deleted:
|
||||
return `Deleted ⎯ ${deletedCount}`;
|
||||
default:
|
||||
return `All accounts ⎯ ${totalCount}`;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react';
|
||||
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import ServiceAccountsSettings from '../ServiceAccountsSettings';
|
||||
|
||||
@@ -149,7 +149,7 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: /Disable Service Account/i }),
|
||||
await screen.findByRole('button', { name: /Delete Service Account/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -187,14 +187,16 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
await user.click(screen.getByRole('button', { name: /Save Changes/i }));
|
||||
|
||||
await screen.findByDisplayValue('CI Bot Updated');
|
||||
expect(listRefetchSpy).toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(listRefetchSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('"New Service Account" button opens the Create Service Account modal', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<NuqsTestingAdapter>
|
||||
<NuqsTestingAdapter hasMemory>
|
||||
<ServiceAccountsSettings />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const IS_SERVICE_ACCOUNTS_ENABLED = false;
|
||||
|
||||
@@ -9,5 +9,5 @@ export const SA_QUERY_PARAMS = {
|
||||
ADD_KEY: 'add-key',
|
||||
EDIT_KEY: 'edit-key',
|
||||
REVOKE_KEY: 'revoke-key',
|
||||
DISABLE_SA: 'disable-sa',
|
||||
DELETE_SA: 'delete-sa',
|
||||
} as const;
|
||||
|
||||
@@ -8,7 +8,6 @@ export function toServiceAccountRow(
|
||||
id: sa.id,
|
||||
name: sa.name,
|
||||
email: sa.email,
|
||||
roles: sa.roles,
|
||||
status: sa.status,
|
||||
createdAt: toISOString(sa.createdAt),
|
||||
updatedAt: toISOString(sa.updatedAt),
|
||||
@@ -18,19 +17,18 @@ export function toServiceAccountRow(
|
||||
export enum FilterMode {
|
||||
All = 'all',
|
||||
Active = 'active',
|
||||
Disabled = 'disabled',
|
||||
Deleted = 'deleted',
|
||||
}
|
||||
|
||||
export enum ServiceAccountStatus {
|
||||
Active = 'ACTIVE',
|
||||
Disabled = 'DISABLED',
|
||||
Deleted = 'DELETED',
|
||||
}
|
||||
|
||||
export interface ServiceAccountRow {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
status: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
|
||||
@@ -692,9 +692,6 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
registerShortcut(GlobalShortcuts.NavigateToSettingsBilling, () =>
|
||||
onClickHandler(ROUTES.BILLING, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToSettingsAPIKeys, () =>
|
||||
onClickHandler(ROUTES.API_KEYS, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToSettingsNotificationChannels, () =>
|
||||
onClickHandler(ROUTES.ALL_CHANNELS, null),
|
||||
);
|
||||
@@ -720,7 +717,6 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettings);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettingsIngestion);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettingsBilling);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettingsAPIKeys);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettingsNotificationChannels);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToLogsPipelines);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToLogsViews);
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
Github,
|
||||
HardDrive,
|
||||
Home,
|
||||
Key,
|
||||
Keyboard,
|
||||
Layers2,
|
||||
LayoutGrid,
|
||||
@@ -366,13 +365,6 @@ export const settingsNavSections: SettingsNavSection[] = [
|
||||
isEnabled: false,
|
||||
itemKey: 'service-accounts',
|
||||
},
|
||||
{
|
||||
key: ROUTES.API_KEYS,
|
||||
label: 'API Keys',
|
||||
icon: <Key size={16} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'api-keys',
|
||||
},
|
||||
{
|
||||
key: ROUTES.INGESTION_SETTINGS,
|
||||
label: 'Ingestion',
|
||||
|
||||
@@ -158,7 +158,6 @@ export const routesToSkip = [
|
||||
ROUTES.MEMBERS_SETTINGS,
|
||||
ROUTES.SERVICE_ACCOUNTS_SETTINGS,
|
||||
ROUTES.INGESTION_SETTINGS,
|
||||
ROUTES.API_KEYS,
|
||||
ROUTES.ERROR_DETAIL,
|
||||
ROUTES.LOGS_PIPELINES,
|
||||
ROUTES.BILLING,
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { useQuery, UseQueryResult } from 'react-query';
|
||||
import list from 'api/v1/pats/list';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { APIKeyProps } from 'types/api/pat/types';
|
||||
|
||||
export const useGetAllAPIKeys = (): UseQueryResult<
|
||||
SuccessResponseV2<APIKeyProps[]>,
|
||||
APIError
|
||||
> =>
|
||||
useQuery<SuccessResponseV2<APIKeyProps[]>, APIError>({
|
||||
queryKey: ['APIKeys'],
|
||||
queryFn: () => list(),
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import {
|
||||
getGetServiceAccountRolesQueryKey,
|
||||
useCreateServiceAccountRole,
|
||||
useDeleteServiceAccountRole,
|
||||
useGetServiceAccountRoles,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export interface RoleUpdateFailure {
|
||||
roleName: string;
|
||||
error: unknown;
|
||||
onRetry: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface UseServiceAccountRoleManagerResult {
|
||||
currentRoles: AuthtypesRoleDTO[];
|
||||
isLoading: boolean;
|
||||
applyDiff: (
|
||||
localRoleIds: string[],
|
||||
availableRoles: AuthtypesRoleDTO[],
|
||||
) => Promise<RoleUpdateFailure[]>;
|
||||
}
|
||||
|
||||
export function useServiceAccountRoleManager(
|
||||
accountId: string,
|
||||
): UseServiceAccountRoleManagerResult {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useGetServiceAccountRoles({ id: accountId });
|
||||
|
||||
const currentRoles = useMemo<AuthtypesRoleDTO[]>(() => data?.data ?? [], [
|
||||
data?.data,
|
||||
]);
|
||||
|
||||
// the retry for these mutations is safe due to being idempotent on backend
|
||||
const { mutateAsync: createRole } = useCreateServiceAccountRole();
|
||||
const { mutateAsync: deleteRole } = useDeleteServiceAccountRole();
|
||||
|
||||
const invalidateRoles = useCallback(
|
||||
() =>
|
||||
queryClient.invalidateQueries(
|
||||
getGetServiceAccountRolesQueryKey({ id: accountId }),
|
||||
),
|
||||
[accountId, queryClient],
|
||||
);
|
||||
|
||||
const applyDiff = useCallback(
|
||||
async (
|
||||
localRoleIds: string[],
|
||||
availableRoles: AuthtypesRoleDTO[],
|
||||
): Promise<RoleUpdateFailure[]> => {
|
||||
const currentRoleIds = new Set(
|
||||
currentRoles.map((r) => r.id).filter(Boolean),
|
||||
);
|
||||
const desiredRoleIds = new Set(
|
||||
localRoleIds.filter((id) => id != null && id !== ''),
|
||||
);
|
||||
|
||||
const addedRoles = availableRoles.filter(
|
||||
(r) => r.id && desiredRoleIds.has(r.id) && !currentRoleIds.has(r.id),
|
||||
);
|
||||
|
||||
const removedRoles = currentRoles.filter(
|
||||
(r) => r.id && !desiredRoleIds.has(r.id),
|
||||
);
|
||||
|
||||
const allOperations = [
|
||||
...addedRoles.map((role) => ({
|
||||
role,
|
||||
run: (): ReturnType<typeof createRole> =>
|
||||
createRole({ pathParams: { id: accountId }, data: { id: role.id } }),
|
||||
})),
|
||||
...removedRoles.map((role) => ({
|
||||
role,
|
||||
run: (): ReturnType<typeof deleteRole> =>
|
||||
deleteRole({ pathParams: { id: accountId, rid: role.id } }),
|
||||
})),
|
||||
];
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
allOperations.map((op) => op.run()),
|
||||
);
|
||||
|
||||
await invalidateRoles();
|
||||
|
||||
const failures: RoleUpdateFailure[] = [];
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
const { role, run } = allOperations[index];
|
||||
failures.push({
|
||||
roleName: role.name ?? 'unknown',
|
||||
error: result.reason,
|
||||
onRetry: async (): Promise<void> => {
|
||||
await run();
|
||||
await invalidateRoles();
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return failures;
|
||||
},
|
||||
[accountId, currentRoles, createRole, deleteRole, invalidateRoles],
|
||||
);
|
||||
|
||||
return {
|
||||
currentRoles,
|
||||
isLoading,
|
||||
applyDiff,
|
||||
};
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useQuery, UseQueryResult } from 'react-query';
|
||||
import getUser from 'api/v1/user/id/get';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { UserResponse } from 'types/api/user/getUser';
|
||||
|
||||
const useGetUser = (userId: string, isLoggedIn: boolean): UseGetUser =>
|
||||
useQuery({
|
||||
queryFn: () => getUser({ userId }),
|
||||
queryKey: [userId],
|
||||
enabled: !!userId && !!isLoggedIn,
|
||||
});
|
||||
|
||||
type UseGetUser = UseQueryResult<SuccessResponseV2<UserResponse>, unknown>;
|
||||
|
||||
export default useGetUser;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user